Чат-бот по обществознанию: поиск по учебникам с Qwen и FAISS

В России вводятся единые государственные учебники. В моей текущей версии бота загружены два учебника по обществознанию - за 9 и 10 класс. Я хотел сделать помощника, который ищет определения и объяснения именно в этих материалах, а не в интернете и не в общих знаниях модели.

У бота нет веб-поиска. В системном промпте написано: отвечать по найденным фрагментам, и если ответа в них нет - так и сказать. Это не значит, что «модель знает только учебник»: знания из базовых весов Qwen у нее остались. Идея в том, чтобы при сборке промпта убирать все лишнее и ограничивать контекст теми параграфами, которые ученику реально нужны.

Зачем ограничивать модель учебником Обычный ответ модели не обязательно совпадает с формулировками нужного учебника. Это становится проблемой на уроке, где за ответ по конкретному учебнику ставят оценку, а за «философский» ответ из общих знаний - нет. Поэтому задача бота - не «понять обществознание», а «найти нужный параграф и передать его модели в чистом виде».

Это меняет архитектуру. Вместо большой модели с доступом в интернет нужна маленькая модель, которая получает короткий, узкий контекст и не уходит в общие рассуждения.

Что получилось

  • Первый сценарий: пользователь спрашивает «Что такое общество 10 класс». Бот отвечает, что по материалам 10 класса, § 23 «Трудовое право», и дает определение общества как системы взаимодействий людей, основанной на нормах, установленных государством. Ссылка на «Трудовое право» выглядит нерелевантной для такого вопроса - короткий и уверенный ответ еще не означает, что найден нужный материал. Номер страницы в ответе не указан, хотя системный промпт это требует.

  • Второй сценарий: запрос про основные теории происхождения человека. Бот перечисляет две: материалистический подход (Большой взрыв, эволюция через естественный отбор) и научно-фантастическую гипотезу (инопланетное вмешательство, с оговоркой, что она не подтверждена). Без исходного фрагмента учебника нельзя проверить, действительно ли там выделены именно эти два подхода и не пропущены ли другие. В этом ответе тоже нет номера страницы, и в тексте остались необработанные маркеры **.

Оба примера показывают, как бот выглядит в работе, но не доказывают точность его ответов относительно учебника.

Как устроен пайплайн Подготовка данных Я использую pymupdf4llm в режиме постраничного парсинга, чтобы каждый фрагмент знал свой номер страницы. Из PDF получается Markdown, где сохраняются заголовки, таблицы и списки.

Python

pages_data = pymupdf4llm.to_markdown(filename, page_chunks=True)
for page_dict in pages_data:
    page_text = page_dict["text"]
    page_num = page_dict["metadata"].get("page", 0) + 1
    md_chunks = markdown_splitter.split_text(page_text)
    safe_chunks = text_splitter.split_documents(md_chunks)
    
    for chunk in safe_chunks:
        chunk.metadata["Класс"] = grade
        chunk.metadata["Страница"] = page_num
        all_safe_chunks.append(chunk)

MarkdownHeaderTextSplitter настроен на три уровня заголовков: # - Глава, ## - Параграф, ### - Подраздел. Благодаря этому фрагменты получают метаданные, которые потом используются как фильтр поиска.

Второй проход - RecursiveCharacterTextSplitter с chunk_size=1000 и chunk_overlap=150, чтобы разрезать длинные секции.

Эмбеддинги - intfloat/multilingual-e5-large, база - FAISS (faiss-cpu, работает в оперативной памяти Colab).

Одно замечание про страницы: номер страницы файла может отличаться от напечатанного номера в учебнике. Я не проверял, корректно ли работает +1 в этой строке, поэтому пока не обещаю стопроцентно точных ссылок на страницы бумажного издания.

Поиск по запросу Когда пользователь пишет «дай ответы на вопросы 13 параграфа 10 класса», бот вытаскивает из запроса класс и номер параграфа регулярным выражением и передает в similarity_search фильтр по метаданным.

Python

def smart_filter(metadata):
    if target_class and metadata.get("Класс") != target_class:
        return False
    if target_paragraphs:
        par_name = metadata.get("Параграф", "")
        found = False
        for num in target_paragraphs:
            if re.search(rf'\b{num}\b', par_name):
                found = True
                break
        if not found:
            return False
    return True

docs = real_vectorstore.similarity_search(f"query: {message.text}", k=4, filter=smart_filter)

Здесь стоит быть точным. В моей реализации это фильтрация результатов, а не предварительное отсечение: LangChain проверяет фильтр по каждому документу, обходя результаты поиска. k=4 означает «до четырех фрагментов»: после фильтрации их может быть меньше. Если жесткий фильтр ничего не вернул, код повторяет поиск только с фильтром по классу - при этом контекст может прийти из другого параграфа.

Фильтр срабатывает, только если класс или параграф названы в запросе. Без явного «10 класс» поиск идет по всей базе; если указан только номер параграфа, поиск может выбрать этот номер в обоих учебниках.

Генерация ответа Контекст собирается из найденных фрагментов, каждому приписывается его класс, параграф и страница.

Python

meta_tag = f"[{grade} | {paragraph} | Стр. {page}]".strip(" |")
context_parts.append(f"{meta_tag}\n{doc.page_content}")
context_text = "\n\n".join(context_parts)

Дальше это уходит в системный промпт Qwen2.5-3B-Instruct вместе с указанием: отвечать строго по тексту и в конце написать, на какой странице найдена информация.

Python

messages = [
    {"role": "system", "content": f"Ты школьный ИИ-репетитор по обществознанию. Отвечай строго на основе предоставленного текста...\n\nУчебник:\n{context_text}"},
    {"role": "user", "content": message.text}
]
outputs = model.generate(**inputs, max_new_tokens=400, temperature=0.1)

Ограниченный контекст не лишает модель ее базовых знаний - она по-прежнему может что-то дописать от себя. Низкая температура помогает держаться ближе к найденному тексту, но я не проверял отдельно, как do_sample и остальные настройки генерации влияют на частоту таких вставок.

После каждого ответа я вызываю torch.cuda.empty_cache(). Это освобождает кешированную неиспользуемую память, а не память самой модели.

Что под капотом

  • Инфраструктура: Google Colab, бесплатная видеокарта T4.

  • Модель: Qwen/Qwen2.5-3B-Instruct.

  • Квантизация: 4-битная NF4 через bitsandbytes, чтобы модель поместилась в видеопамять T4.

  • Эмбеддинги: intfloat/multilingual-e5-large.

  • Векторная база: FAISS, работает в оперативной памяти.

  • Интерфейс: простой чат-бот.

Все крутится на одной бесплатной машине.

Где прототип еще сырой Честно о том, что пока не идеально:

  1. Форматирование ответов не настроено. На скриншотах видно, что модель выдает Материалистический подход как сырой текст с маркдоуном.

  2. Метка параграфа теряется на страницах без заголовка. Если параграф начинается на стр. 139 и продолжается на стр. 140 без нового заголовка, фрагменты со второй страницы могут остаться без метаданных «Параграф». Это значит, что при запросе конкретного параграфа они не попадут в выдачу.

  3. Нет проверки фактов против контекста. Модель получает ограниченный контекст, но скрипт никак не сверяет итоговую фразу в ответе с фрагментом, откуда она взялась. Это задача на будущую итерацию (дополнительный фактчекинг перед отправкой).

  4. Класс пользователя не запоминается. Если он не назван в текущем сообщении, бот не знает, какой учебник нужен. Это неудобно и требует реализации хранения состояний сессии.

Зачем это вообще нужно Школьнику нужен помощник, который отвечает по тем материалам, которые будет спрашивать учитель, а не по всей сумме знаний модели. С переходом на единые учебники это становится еще и практично: база из нескольких книг может покрыть предмет целиком, без споров «а по какому учебнику это написано».

В планах - масштабировать пайплайн на историю и биологию, где строгая фактология важна еще больше.

Что дальше Известные мне задачи на ближайшие спринты:

  • Настроить форматирование ответов в чате

  • Реализовать память сессий, чтобы запоминать класс пользователя.

  • Добавить валидацию: проверять ответ на соответствие контексту перед отправкой.

  • Решить проблему «потерянных» метаданных на страницах без заголовка параграфа.

А как вы ограничиваете LLM в узкоспециализированных проектах, чтобы она не уходила в общие знания? Буду рад обсудить в комментариях!

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Есть ли смысл от такого продукта
0%Да0
100%Нет1
Проголосовал 1 пользователь. Воздержавшихся нет.