The Daily Newsstand · Free, Always
Friday, October 9, 2026

RAG по 11 000 файлов: почему семантический поиск не находит по артикулу

Translate

Привет! Меня зовут Дмитрий, я работаю в компании Cortex IT и автоматизирую бизнес-процессы наших клиентов. Среди прочего мы строим RAG-ассистентов по внутренней документации.

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

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

Задача выглядела как хрестоматийный RAG: проиндексировать документы, поднять бота в мессенджере, отвечать по найденным фрагментам. Дальше о том, где он ломался и что мы с этим сделали.

Так путь вопроса выглядит в итоговой версии. Выделенная стрелка с фильтрами подробно разобрана ниже.

Схема поиска ответа на вопрос пользователя

Схема поиска ответа на вопрос пользователя

Почему семантический поиск промахивается по артикулу

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

А теперь запрос менеджера: «какая мощность главного двигателя у ФС-420М модификации 2022 года».

Для эмбеддинг-модели ФС-420М сводится к нескольким малоинформативным токенам. Семантически они почти не отличаются от ФС-420С, ФС-320М и других соседних модификаций из того же каталога. Модель находит «похожее» и приносит паспорт соседней модели, где всё выглядит правдоподобно, кроме цифр.

Хуже всего, что ошибка тихая. Менеджер получает уверенный ответ с числом, и число это от другого станка.

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

Решение первое: гибридный поиск одной моделью

Первая версия искала только по плотным векторам. Стандартный приём: гибрид, то есть к семантическому поиску добавить лексический, который ищет точное совпадение слов. Классический вариант: BM25. Мы так и планировали: multilingual-e5-large для смысла, BM25 для слов, две модели на каждый чанк.

В итоге обошлись одной. BAAI/bge-m3 за один проход выдаёт и плотный вектор (1024 измерения), и разреженный: веса по токенам, как у BM25. Только BM25 считает вес по формуле из частот слов, а здесь его выдаёт обученный слой модели, глядя на контекст токена.

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

Оба вектора лежат в одной коллекции Qdrant, у каждого свой индекс. Результаты Qdrant объединяет через RRF (reciprocal rank fusion): два независимых списка кандидатов сливаются по обратным рангам, и веса между «семантикой» и «лексикой» подбирать руками не нужно.

client.query_points(
    collection_name=...,
    prefetch=[
        models.Prefetch(query=dense_vec,  using="dense",  limit=initial_limit, filter=query_filter),
        models.Prefetch(query=sparse_vec, using="sparse", limit=initial_limit, filter=query_filter),
    ],
    query=models.FusionQuery(fusion=models.Fusion.RRF),
    limit=initial_limit,
)

Если переходите на BGE-M3 с моделей, где к запросу приписывали инструкцию или префикс query:, уберите его: M3 он не нужен, а в разреженном векторе служебные слова приписки получают вес и начинают совпадать с документами буквально.

Решение второе: фильтры по метаданным, но не вместо поиска

Гибрид улучшает попадание, но не гарантирует его. Когда в каталоге много моделей с почти одинаковыми индексами, нужный документ может не попасть даже в 20 результатов одного поиска.

Поэтому перед поиском быстрая модель переписывает вопрос в структуру:

{ "query": "...", "category_filter": "...", "model_filter": "...", "intent": "..." }

Модели верить нельзя, поэтому мы проверяем её ответ. Категорию сверяем со списком известных по нечёткому совпадению, и если похожей нет, фильтр по категории отбрасывается. В названии модели StanLine расставляем пробел, чтобы он находился как Stan Line.

model_filter и category_filter в эмбеддинги не попадают. Это условие по payload: Qdrant накладывает его внутри обоих поисков, и dense, и sparse. На схеме выше стрелка с фильтрами идёт мимо BGE-M3 прямо в Qdrant.

Поля machine_model и category в payload проиндексированы как полнотекстовые, и MatchText ищет совпадение по словам: все слова фильтра должны найтись в поле, лишние слова в поле не мешают. ФС-420М найдёт документ с моделью ФС-420М Pro, а ФС-420 документ с ФС-420М уже не найдёт, потому что это разные токены. По той же причине StanLine разбивается на два слова: иначе фильтр искал бы одно слово, которого в поле нет.

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

  • есть и модель, и категория: три поиска, по модели, по категории и без фильтров;

  • есть только что-то одно: два поиска, с этим фильтром и без;

  • фильтров нет: один обычный гибридный поиск.

Каждый поиск возвращает топ-20 кандидатов: мы сливаем результаты, убираем дубли по ID и оставляем 40 лучших, а реранкер за один проход выбирает из них 20. Если после всего этого пусто, делаем ещё один поиск без фильтров. Ошибка фильтра в такой схеме стоит нам части кандидатов, но не всего ответа.

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

Решение третье: таблицы, которые нельзя резать

Самое полезное в техпаспорте обычно лежит в таблицах. Наивная разбивка на чанки фиксированной длины рвёт их в самом неудачном месте: между заголовком столбца и значением.

Чанк обрывается, и в индекс попадает фрагмент, где есть число 1050, но нет строки Частота вращения шпинделя, об/мин. Модель потом находит этот фрагмент и не понимает, что это за число.

У нас сплиттер работает в двух режимах. Обычный текст режется на чанки заданного размера, рекурсивно подбирая места разрыва по естественным границам: сначала пробуем разделить по абзацам, потом по строкам, потом по предложениям и только в крайнем случае по символам. Таблицы сплиттер обрабатывает отдельно: узнаёт строку таблицы по разметке и собирает чанк так, чтобы в него всегда попадала шапка со столбцами. qui Есть ещё один простой приём, липкий заголовок раздела. Парсер запоминает последний встреченный заголовок (# PARTS LIST) и вклеивает его в начало каждого табличного чанка. Таблица со страницы 24 уносит с собой заголовок со страницы 23. Без этого чанк висит в воздухе: набор чисел без темы, который эмбеддер не сможет осмысленно разместить в пространстве.

Реранкер и как понять, что он вообще работает

После гибридного поиска кандидаты прогоняются через кросс-энкодер bge-reranker-v2-m3. Каждый из параллельных поисков возвращает по 20 кандидатов. После слияния и удаления дублей остаются 40 лучших, реранкер их переупорядочивает, и мы отдаём топ-20.

Про любой реранкер в пайплайне стоит спросить: а он что-нибудь меняет? Кросс-энкодер занимает около 2,5 ГБ памяти, добавляет время к каждому ответу и вполне может просто подтверждать порядок, который уже был.

Поэтому мы явно логируем разницу: какие документы реранкер поднял в топ, какие выкинул. Если набор не изменился, так и пишем в лог.

Promoted into top-20: doc_A.pdf#12, doc_B.pdf#3
Dropped from top-20: doc_C.pdf#7

Кода тут три строки. Зато на вопрос «нужен ли нам реранкер на этих данных» можно ответить по логам, а не на глаз.

Во что это обходится по железу

Одиннадцать тысяч документов после разбивки на чанки превратились в 399 тысяч точек в Qdrant: плотный вектор на 1024 измерения, разреженный вектор и payload.

Компонент

Потребление

Qdrant (399K точек, dense + sparse)

8-12 ГБ RAM

BGE-M3 (эмбеддинги)

~2,5 ГБ VRAM / ~4 ГБ RAM на CPU

bge-reranker-v2-m3

~2,5 ГБ VRAM / ~4 ГБ RAM на CPU

Самая важная цифра: BGE-M3 на CPU тратит 2-5 секунд на запрос, на GPU 80-200 миллисекунд. На первом этапе мы рассчитывали на двадцать одновременных пользователей. На CPU при такой нагрузке ответа ждали бы 10-30 секунд, на GPU очереди нет. Карта нужна скромная: обе модели вместе занимают около 5 ГБ, так что 8 ГБ VRAM хватает.

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

Чего RAG на техдокументации не умеет

Этот раздел я бы показывал заказчику до старта проекта: после запуска ожидания уже сложились, и переубеждать поздно.

Агрегация по всей базе. На запрос «Выведи все станки мощностью больше 15 кВт» RAG отвечать не должен. Он смотрит топ-N релевантных фрагментов, а не все 399 тысяч точек. Ответ будет выглядеть убедительно и окажется неполным. Для таких вопросов нужна структурированная база с фильтрами. Это отдельная подсистема, промптом её не заменить.

Конфликт версий. В базе лежат паспорта разных лет на один и тот же станок. Поиск найдёт оба, а какой из них актуален, из текста фрагмента обычно не следует. Решается дисциплиной именования и метаданными при загрузке документов, то есть процессом на стороне заказчика. Кодом тут мало что исправишь.

Чертежи и электросхемы. Не читаются. Вопрос «какого цвета провод идёт на массу» останется без ответа, и хорошо, если бот в этом признается, а не соберёт правдоподобную фразу из соседнего текста.

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

Если делали похожее на своей документации, интересно, как решали проблему с артикулами: гибридом, фильтрами по метаданным или как-то по-другому?

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.