Как мы создавали «умный» поиск по регламенту вуза

от автора

Сначала — пара слов о себе, иначе непонятно, откуда вообще взялся весь проект. Я преподаю на кафедре информационных технологий в одном из вузов нашего города и при этом больше двадцати лет пишу код не в учебных примерах, а в бою. Эти две роли — преподавателя и практикующего разработчика — в какой-то момент и сошлись в одном небольшом проекте, который я затеял для себя.

А началось всё с бытового раздражения. Чтобы узнать свои пары, мне — ровно как студентам — каждый раз приходилось заходить на сайт, искать в расписании нужную группу или свою фамилию, продираться через сокращения. И однажды я поймал себя на мысли: я же разработчик, почему я это терплю? Так появился бот в мессенджере MAX. Первая версия была совсем простой: указываешь свою группу или фамилию преподавателя — и бот показывает расписание на сегодня и на несколько дней вперёд. Свободный текст он тогда не понимал вовсе: строго группа или фамилия, строго расписание. Но даже так это оказалось куда удобнее, чем искать через сайт.

Бот прижился — сначала у меня, потом у коллег и студентов, — и, как любой живой проект, начал обрастать аппетитами. Со временем ко мне присоединилась ещё пара единомышленников, и дальше мы уже возились с проектом втроём. Сперва научили бота понимать живые формулировки («что у меня сегодня?», «когда пара у Киселёвой?»), потом добавились аудитории, замены, экзамены. А следом пошли вопросы, которых в расписании нет вовсе: как перевестись, восстановиться, что с задолженностями, стипендией, общежитием. То есть вопросы к регламенту.

И вот тут «просто показать расписание» превращается в отдельную, совсем не тривиальную задачу. Регламент вуза — это десятки, а то и сотни страниц внутренних правил. Подключить такой документ к боту можно, но не так, как обычно представляют, и не так гладко, как пишут в статьях про RAG.

А вокруг RAG (Retrieval-Augmented Generation) в интернетах обычно обитает много умных слов: векторные базы, тяжёлые модели эмбеддингов, реранкеры, впечатляющие проценты точности. У небольшого проекта стек обычно куда скромнее. В статье я опишу: какие решения мы приняли, на каких граблях поскользнулись и как в итоге научились измерять качество выдачи, а не «рисовать» красивые проценты. Моя статья пригодится тем, кто строит похожее на русском и упирается в те же места: память, мисроутинг, структура документов, оценка качества.

Почему нельзя просто «скормить весь регламент в контекст»

Соблазн очевиден: у современных моделей контекстное окно — сотни тысяч токенов, и 200-страничный регламент помещается туда целиком. Так почему бы просто не положить весь документ в промпт и не дать модели искать ответ самой?

На практике так не делают, потому что каждый запрос тащит за собой весь документ — расходы на обслуживание модели увеличиваются из-за большого объёма контекста. Кроме того обработка большого окна занимает несколько секунд на каждый ответ, и модели хуже работают с информацией из середины длинного контекста — это известный феномен «lost in the middle». И чем длиннее контекст, тем охотнее модель склеивает ответ из обрывков разных разделов, выдавая за правду то, чего нет в исходнике.

Поэтому мы остановились на RAG. Смысл в том, чтобы не грузить в модель весь документ: мы заранее режем его на небольшие фрагменты, складываем в поисковый индекс, а на каждый вопрос достаём только подходящие куски — не весь регламент, а несколько нужных абзацев.

Дальше в статье разберём детальнее именно тот порядок, в котором каждый запрос проходит через нашу поисковую систему.

Схема поисковой системы

Схема поисковой системы

Сначала решаем, нужен ли вообще поиск

Как я писал выше, ассистент изначально умел отвечать на структурные вопросы — расписание, преподаватели, аудитории и группы. Но это запросы к базе, а не к свободному тексту. Регламент — это слой поверх, где ответа в структурированных данных нет и для поиска приходится лезть в текст документов.

Отсюда вылезает первое архитектурное решение: не каждый запрос должен доходить до векторного поиска. «Когда у группы пара по МДК.01.01?» — это расписание. «Как восстановиться после отчисления?» — это регламент. Значит, нужен роутер, который классифицирует намерение раньше, чем мы потратим деньги на LLM или эмбеддинг. Ниже приведена схема нашего каскадного роутинга:

keyword → LLM → embedding → content_lookup → fallback

keyword → LLM → embedding → content_lookup → fallback

Keyword идёт первым и при совпадении замыкает каскад: если запрос матчится по ключевым словам или расписанию, LLM и эмбеддинг не вызываются вообще.

Если по keyword ничего не нашлось в структурированных данных, подключается LLM-роутер: он разбирает запрос и возвращает небольшую структуру {intent, scope, confidence} — три поля, каждое со своей задачей.

  • intent — что человек хочет в принципе: спросить расписание, узнать про преподавателя, найти ответ в регламенте. По этому намерению мы решаем, в какую ветку вообще отправить запрос.

  • scope — про что конкретно вопрос: какая сущность (группа, преподаватель, аудитория) и её код. Для «когда у 228-й пара?» это группа с кодом 228. Scope сужает поиск до нужного объекта, чтобы не перебирать всё подряд.

  • confidence — насколько сама модель уверена в своём разборе, число от 0 до 1. Если уверенность ниже нужного уровня, решению LLM мы не доверяем и уходим к эмбеддинг-роутеру.

Эмбеддинг-роутер — это страховка на случай, когда ни ключевые слова, ни LLM не сработали (или LLM вообще выключен). Устроен он так. Для каждого намерения мы заранее заготовили набор «якорей» — типичных примеров запросов: для расписания это «что у меня сегодня», «когда пара», для регламента — «как перевестись», «правила восстановления» и т.п. Все якоря единожды переводятся в векторы при первом запуске инстанса бота, и для каждого вуза или школы они могут быть уникальны.

Когда приходит вопрос, мы считаем его вектор и сравниваем с якорями по косинусной близости. Побеждает намерение того якоря, который оказался ближе всех, — но только если он действительно близко. Если ни один якорь не дотянул до нужного уровня, роутер молчит и решение не навязывает: лучше промолчать, чем угадывать неверно.

Готовые решения кэшируются прямо в памяти процесса (без всякого Redis), по нормализованному тексту запроса, — повторный такой же вопрос не пересчитываем. А первичная подготовка (расчёт векторов для самих якорей) при одновременных запросах не дублируется: если сразу несколько запросов дёрнули роутер, пока он ещё не прогрет, якоря считаются один раз, одним общим батчем, а не по кругу на каждый.

Если ни один уровень каскада не сработал — запрос уходит в content_lookup, то есть как раз в RAG по документам и сайту.

LLM в этом каскаде мы включали осторожно и использовали следующий порядок раскатки off → shadow → canary → full. В shadow-режиме LLM вызывается и пишет телеметрию, но его решение к ответу не применяется: в ответе используется вердикт эмбеддинг-роутера. Так мы видим, что LLM предложил бы, и сравниваем с тем, что реально мы отдали в ответе пользователю. В canary-режиме LLM применяется по-настоящему, но только на небольшой доле запросов. Если на этой доле не просели ключевые показатели — задержка ответа, частота ошибок LLM и то, как часто роутер верно угадывает намерение (всё это видно в телеметрии, о ней ниже), — долю можно поднимать. А в full-режиме LLM уже применяется ко всему трафику.

Режим

Что делает LLM-роутер

Что видит пользователь

off

не вызывается

решение keyword или эмбеддинг-роутера

shadow

вызывается, пишет телеметрию

решение эмбеддинг-роутера (LLM не применяется)

canary

применяется на малой доле запросов

на этой доле — решение LLM, на остальном как раньше

full

применяется всегда

решение LLM

Отдельная история — русский язык. Регрессионный набор роутера — это, по сути, коллекция русскоязычных ловушек. «Киселёва» и «Киселева» должны опознаваться как один человек — это нормализация ё/е. Фамилия может совпасть с обычным словом: преподаватель с фамилией Суббота против дня недели «суббота». Один и тот же код означает разное в зависимости от контекста: 228 — это и номер группы, и номер аудитории. Точечные коды дисциплин вроде МДК.01.01 наивный парсер разваливает на части. А вопрос, начинающийся с «когда…», тот же парсер норовит увести не туда. Плюс устойчивость к опечаткам: слова из запроса сверяются со словарём расписания не буквально, а с допуском на ошибки — по расстоянию Левенштейна. Без этого роутер то и дело промахивался бы на ровном месте.

И ещё одна важная вещь, без которой настраивать всё это было бы гаданием: на каждый запрос мы записываем короткую строчку статистики. В ней видно, кто принял решение — ключевые слова, LLM или эмбеддинг-роутер, — насколько он был уверен, не упал ли LLM и сколько всё заняло по времени.

Из этой статистики и выросли конкретные правки. Например, время ожидания LLM-роутера мы срезали с 10 секунд до 5. Данные показали, что почти все запросы к модели укладываются секунды в четыре — ждать дольше почти нет смысла. А те редкие, что висели все 10 секунд, чаще всего всё равно заканчивались ничем: модель так и не отвечала, и приходилось отдавать не самый удачный запасной ответ. Лучше не тянуть до последнего, а раньше переключиться на эмбеддинг-роутер.

Статистика пишется в фоне — сам запрос не ждёт, пока новая строка ляжет в БД, а если под нагрузкой таких недописанных строк скапливается слишком много, новые мы просто отбрасываем. Логика здесь простая: потерять часть статистики не страшно, а вот забить память горой висящих записей и уронить сервис по памяти — очень даже.

Эмбеддинги и история про нехватку памяти

Модель для эмбеддингов настраивается отдельно от чат-модели, поэтому их можно комбинировать как удобно: чат — через облачное API, эмбеддинги — локально на своём железе, или наоборот. Поддерживаем Ollama, OpenAI-совместимые сервисы и Yandex.

Локально эмбеддинги считает контейнер text-embeddings-inference (TEI) — компактный сервер инференса. Он отдаёт OpenAI-совместимый эндпоинт, поэтому отдельный провайдер под него писать не пришлось: используем тот же openai-compatible, что и для облачных сервисов.

А теперь грабли. Изначально в TEI стоял bge-m3 — хорошая мультиязычная модель на 1024 измерения. На демо-боксе с ~7.7 ГБ памяти при прогреве она разъедалась примерно до 6.4 ГБ и упиралась в лимит контейнера 6.5 ГБ — после чего прилетал OOM-killer. Лечится не бубном, а трезвым выбором: дефолтную модель заменили на multilingual-e5-small — 118M параметров, 384 измерения, мультиязычная. Она укладывается примерно в гигабайт и считает заметно быстрее. На открытых бенчмарках e5-small проигрывает bge-m3 совсем немного — единицы процентов по качеству поиска. А для наших коротких административных запросов (например, «когда подавать заявление на перевод») эта разница настолько мала, что на практике её не видно. bge-m3 остался задокументированной опцией для тех, у кого хватает железа.

Из этой истории вышел ещё один урок — не про модель, а про код, и он важнее, чем кажется на первый взгляд: смешать в индексе векторы двух разных моделей — значит начать сравнивать несравнимое. Разные размерности (384 против 1024) — это ещё полбеды: код упрётся в несовпадение длин и сразу упадёт. Настоящая беда — когда размерность случайно совпала, а модель другая: тогда всё посчитается молча и вернёт правдоподобную чушь, без единой ошибки в логах. Выдача просто тихо деградирует, и узнаем мы это только от клиента, а это худшее, что может случиться с молодой командой энтузиастов.

Поэтому размерность у нас нигде не захардкожена. Она вычисляется в рантайме из самого вектора (dim = vectors[0].length) и хранится рядом с данными: у документа записаны и модель, и размерность её эмбеддингов, у каждого чанка — та модель, которой он посчитан. Дальше это работает как страховка на нескольких уровнях. При поиске мы берём только векторы, помеченные текущей моделью, — чужие в сравнение не попадают. Если в пределах одного батча размерности вдруг разъехались — сервис бросает исключение и не пускает кривые векторы в хранилище. А если модель меняют целиком, то чанки, посчитанные старой моделью, помечаются устаревшими и не используются в поиске вообще, чтобы случайно не подмешались к свежим. Такое решение спасает от самого коварного класса багов: тех, что не роняют сервис, а втихаря портят его качество.

Сама функция близости не даст молча посчитать мусор на векторах разной длины — при разности длин падает с ошибкой:

export function cosineSimilarity(a: number[], b: number[]): number {  if (a.length !== b.length) {    // разная длина — сравнивать нечего    throw new Error(`dimension mismatch (${a.length} vs ${b.length})`);  }  let dot = 0, normA = 0, normB = 0;  for (let i = 0; i < a.length; i++) {    dot += a[i] * b[i];    normA += a[i] * a[i];    normB += b[i] * b[i];  }  if (normA === 0 || normB === 0) return 0;  return dot / (Math.sqrt(normA) * Math.sqrt(normB));}

Чанкинг: достаём структуру до нарезки

Первым делом документ надо нарезать на фрагменты. Самый простой подход — это накроить по фиксированной длине; но так предложения и пункты регламента ломаются посередине, и кусок текста теряет смысл. Этим грубым способом мы обходимся только с контентом сайта, где структуры почти нет. Поэтому документы мы режем по их собственной структуре — по заголовкам разделов. Фрагмент выходит примерно в тысячу символов, с перекрытием на стыках, и к каждому приписан ближайший заголовок, под которым он находится. Так границы получаются чистыми — пункт правил не рвётся посередине, — а у каждого фрагмента появляется понятный источник: тот самый раздел, который потом попадёт в ссылку при выдаче.

И вот здесь обнаружилась неприятная, совсем не очевидная ошибка. Скрипт нарезки ориентируется на заголовки в разметке markdown — строки, начинающиеся с # или ##. Но регламенты обычно приходят в виде PDF или Word-файлов, а после извлечения текста от разметки не остаётся и следа — на выходе сплошной плоский текст. Строка «Раздел 5. Организация учебного процесса» превращается в обычный абзац, а не заголовок — и документ нарезается как одна сплошная простыня текста. Ровно та структура, ради которой всё затевалось, на реальных файлах просто не работала.

Поэтому мы придумали простой способ, как помечать заголовки перед нарезкой. Перед нарезкой мы проходим по строкам, и те из них, что начинаются с ключевых слов «Раздел», «Глава» или «Статья» с номером (хоть арабским, хоть римским) и не являются заголовками, — помечаем заголовком, дописывая ##. Уже готовые заголовки и обычный текст при этом не трогаем. А дальше отрабатывает тот же самый скрипт нарезки по заголовкам.

// Перед нарезкой помечаем строки-заголовки, которых не было в plain-текстеexport function normalizeRegulationHeadings(body: string): string {  return body    .split(/\r?\n/)    .map((line) => {      const trimmed = line.trimStart();      if (/^#{1,6}\s/.test(trimmed)) return line; // уже заголовок — не трогаем      // «Раздел 5 …», «Глава IV …», «Статья 12 …» → делаем заголовком      if (/^(раздел|глава|статья)\s+([0-9]+|[ivxlcdm]+)\b/i.test(trimmed)) {        return `## ${trimmed.replace(/\s+$/, "")}`;      }      return line; // обычный текст    })    .join("\n");}

И самое приятное: этот шаг мы смогли измерить (чем и как именно мерили, описано ниже).

Поиск: гибрид и RRF

Когда запрос дошёл до RAG, мы считаем его эмбеддинг и ищем релевантные фрагменты. Чтобы поиск не промахивался на формулировках, мы комбинируем два сигнала: близость векторов (смысл) и совпадение по ключевым словам (точные термины, коды, названия). Гибрид «смысл + слова» устойчивее, чем каждый сигнал по отдельности: вектор ловит перефразировки, keyword — точные коды и редкие термины, которые модель эмбеддингов может не распознать.

Разберём подробнее оба пути поиска.

  • Первый путь — по ключевым словам: достаём кандидатов из БД и ранжируем по тому, сколько слов из запроса встречается во фрагменте (сравниваем слова без учёта окончаний, чтобы разные формы одного слова совпадали).

  • Второй путь — по смыслу: если эмбеддинги включены, считаем косинусную близость между вектором запроса и векторами фрагментов, причём прямо в приложении, без отдельного векторного поиска в самой базе — на наших объёмах этого хватает.

От каждого пути берём только верхушку — несколько десятков лучших фрагментов. Два списка объединяем не по сырым оценкам (они у путей несравнимы), а по местам в каждом списке — алгоритмом RRF (Reciprocal Rank Fusion): чем выше фрагмент в списках, тем больше его вес, а попавший лишь в один список учитывается с меньшим весом. Затем фрагменты группируем обратно по документам и отдаём пятёрку лучших. Если один путь пустой — берем второй; а если оба — сразу возвращаем пустой массив.

Сама функция RRF укладывается в несколько строк:

// Место в списке важнее сырой оценки. k = 60 сглаживает вклад «хвоста».export function reciprocalRankFusion(lists, idOf, k = 60) {  const scores = new Map();  for (const list of lists) {    list.forEach((item, index) => {      const rank = index + 1;      const increment = 1 / (k + rank); // 1-е место весит больше 10-го      const id = idOf(item);      const existing = scores.get(id);      if (existing) existing.score += increment; // если фрагмент в обоих списках, складываем      else scores.set(id, { item, score: increment });    });  }  return [...scores.values()].sort((a, b) => b.score - a.score);}

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

Хранилище — обычная MongoDB, без отдельной векторной базы. При наших объёмах (сотни-тысячи фрагментов на регламент, а не миллионы) все векторы можно просто перебрать и посчитать близость напрямую, без специальных индексов. А отдельная векторная база — это ещё один сервис, который надо поднимать, бэкапить и мониторить: лишняя нагрузка ради выигрыша, которого у нас пока нет.

Ответ только по найденному, со ссылкой, которую не выдумали

Найденные фрагменты уходят в модель с жёсткой инструкцией: отвечай только на основе этих фрагментов, если ответа в них нет — так и скажи, не выдумывай. Ответ собирается из текста регламента, а не из «общих знаний» модели.

Если в найденном нет релевантного — бот не фантазирует, а честно отказывается: «по регламенту учреждения у меня нет точного ответа, уточните в учебной части». Отказ у нас — не отдельная функция, а следствие правильной архитектуры: он наступает, когда ни один уровень каскада не отработал уверенно (ни ключевые слова, ни LLM, ни смысловой поиск) или в найденном не нашлось ничего достаточно близкого к вопросу. А насколько охотно бот молчит, задают две настройки — минимальная уверенность LLM и минимальная близость смыслового поиска: строже — чаще «не знаю», мягче — наоборот.

Ещё один приём против выдумок и галлюцинаций модели — ссылку на источник мы подставляем сами, а не просим об этом модель. Модель прекрасно умеет сочинить правдоподобный «пункт 4.2», которого в документе нет. Поэтому блок «Источники:» мы собираем не из ответа модели, а из данных о тех самых фрагментах, которые нашёл поиск: название документа, раздел, ссылка. Если модель всё же приписала свой список источников вопреки инструкции — мы его отрезаем и ставим свой. Не нашлось источника — блока просто нет: лучше не показать ничего, чем показать выдуманную ссылку. В итоге ссылка в ответе всегда соответствует найденному фрагменту и разойтись с настоящим документом не может.

Для пользователя это выглядит так:

Вопрос: как восстановиться после отчисления?

Ответ: Восстановление возможно в течение 5 лет после отчисления. Заявление подаётся в деканат, срок рассмотрения — до 5 рабочих дней.

Источники: — Регламент учебной деятельности, Раздел 7. Порядок восстановления

А сам блок «Источники:» собирается из метаданных найденных фрагментов, а не из текста ответа:

// Ссылки берём из НАЙДЕННЫХ фрагментов, модель цитировать не просим —// значит, она не сошлётся на несуществующий пункт.export function renderSourceCitations(siteResults, documentResults): string {  const lines = ["Источники:"];  for (const doc of documentResults.slice(0, 3)) {    const head = doc.heading   ? `, ${doc.heading}`   : "";    const link = doc.sourceUrl ? ` — ${doc.sourceUrl}` : "";    lines.push(`- ${doc.title}${head}${link}`);  }  return lines.length > 1 ? lines.join("\n") : ""; // нет источника — нет и блока}

Как мы измеряем качество ответов

Фраза «стало лучше» ничего не значит, пока её нельзя проверить, а проверять изначально нам было нечем. Единственный тест — 28 вручную написанных примеров — проверял только роутер: правильно ли бот понимает, о чём его спросили. А насколько хорошо он находит ответ в самом документе — про это тест не говорил ничего.

Поэтому мы сделали отдельный тест — уже для поиска по документам. Взяли небольшой регламент: выдуманный, но похожий на настоящий, полтора десятка разделов — про поступление, переводы, отчисление, восстановление и так далее. К нему придумали около сорока вопросов и заранее пометили, где ответ. Если ответ в тексте есть — записали, в каком он разделе. А ещё добавили вопросы, ответа на которые в тексте нет и не было никогда: на такие бот должен не выкручиваться, а честно сказать «не знаю».

На этих вопросах мы проверяем две вещи. Первая — как часто нужный раздел попадает в начало списка результатов: на первое место, в первую тройку или в первую пятёрку (в таблице ниже это «топ-1», «топ-3», «топ-5»). Вторая — как часто бот правильно отказывается там, где отвечать не на что.

Гонять тест можно двумя способами. Быстрый работает сам при каждой сборке: ищет только по ключевым словам, без нейросети, — поэтому не тратит ни денег, ни времени на запросы в сеть и, главное, полностью воспроизводим: одни и те же вопросы всегда дают ровно те же цифры. Именно это и нужно от автотеста — иначе результат «плавал» бы от прогона к прогону, и было бы не понять, сдвинула метрику наша правка или просто модель в этот раз ответила иначе. Полный тест запускаем вручную — он прогоняет те же вопросы уже через настоящие эмбеддинги, то есть медленнее, дороже и с небольшим разбросом между прогонами. Базовые числа, от которых мы отталкиваемся, замерены на быстром варианте — нарезка по символам и поиск по ключевым словам, без эмбеддингов:

Метрика

Значение

Топ-1

66.7%

Топ-3

90.9%

Топ-5

93.9%

Точность отказов

83.3%

Чтобы проверить именно это, мы взяли наш тестовый регламент, вырезали из него все заголовки — получилась та же «плоская» каша, что даёт выгрузка из PDF, — и прогнали поиск до и после. Попадание нужного раздела в топ-3 выросло с 90.9% до 100%, а число промахов упало с 4 до 1. Это не «вроде получше», а реальный замер на одних и тех же вопросах.

Итог годовой работы

RAG со стороны кажется чем-то сложным и почти магическим, а внутри — набор довольно приземлённых инженерных решений, и каждое со своими компромиссами. Мы не гнались за модным стеком: где-то выбрали простое вместо красивого, где-то набили шишки (привет, OOM) и потом выправились, а где-то честно оставили «пока не сделано». Именно из таких мелочей и складывается система, которой можно реально пользоваться, а не только показывать на слайдах.

И главная мысль всей статьи. Дело не в том, простые у тебя технологии или модные, а в том, проверяешь ли ты результат цифрами или веришь на слово. Без замеров «стало лучше» — это ощущение, а «точность в процентах» — красивая цифра для рекламы. Поэтому мы сперва научились измерять, а потом уже улучшать. Не так эффектно, как «внедрили нейросеть», зато каждую цифру можно перепроверить.

Поиск, о котором мы рассказали, — не финал, а срез в моменте: он живёт, его продолжают дорабатывать. Всё это — часть CampusHub, ИИ-помощника для учебных заведений в MAX-мессенджере.

Если вы строите что-то похожее на русском — надеюсь, наши грабли сэкономят вам пару вечеров. Всем бобра! 🙂

ссылка на оригинал статьи https://habr.com/ru/articles/1056304/