Поставьте рядом два условных артикула: AB-001 и AB-01. Человеческий глаз видит одну и ту же деталь с лишним нулём. Каталог видит две разные строки, и за каждой может стоять своя деталь, свой производитель, своя применимость. Все примеры артикулов и совместимости в этой статье условные: они нужны, чтобы показать устройство поиска, а не чтобы кому-то рекомендовать замену узла.
Вопрос, с которого стоит начинать проектирование RAG для запчастей, звучит не «как найти побольше похожего», а «в каких случаях система вообще вправе говорить слово «подходит»». Поиск по описанию и поиск по номеру решают разные задачи, и если смешать их в одну векторную воронку, получится очень вежливый и очень уверенный механизм продажи неподходящих деталей.
Номер сначала: производитель плюс точная строка
Артикул — это идентификатор, а не текст. Он не имеет «смысла» в том значении, в каком его имеет фраза «фильтр масляный для дизеля». Поэтому первая ступень поиска должна быть самой скучной: определить, о каком производителе идёт речь, и найти запись, у которой хранимый номер равен введённому.
Почему именно пара «производитель + номер». У разных производителей номера могут совпадать или быть похожими по формату, а деталь будет разной. Если пользователь написал номер без бренда, система не должна молча выбирать первый попавшийся вариант: она возвращает все записи с таким номером и просит уточнить производителя. Если бренд указан, он превращается в условие поиска наравне с номером.
В Qdrant такая логика ложится на фильтры по payload. Условия блока must объединяются как «И», а условие точного совпадения проверяет равенство хранимого значения полю. Документация по фильтрации показывает шаблон такого вида, мы адаптировали его под своё поле sku (Qdrant: Filtering):
{"must": [
{"key": "brand", "match": {"value": "BRAND_X"}},
{"key": "sku", "match": {"value": "AB-001"}}
]}Здесь важен выбор типа индекса. Для точного значения подходит keyword-индекс, а текстовый индекс токенизирует строку, то есть режет её на термины, и для сохранения точного идентификатора не годится. Для поля sku индекс создаётся запросом PUT /collections/{collection_name}/index с field_name и field_schema="keyword" (Qdrant: Payload indexes). Там же есть практическая оговорка: payload-индексы занимают память и диск, поэтому индексировать стоит поля, которые реально участвуют в фильтрах. У нас это бренд и артикул, а не весь набор характеристик подряд.
Отдельная тонкость связана с тем, что одно условие может проверять несколько значений. Если в поле лежит список, для совпадения достаточно одного элемента. Это удобно для кросс-номеров и устаревших обозначений: деталь можно хранить с основным артикулом и массивом прежних. Но тогда в каталоге надо различать «этот номер — основной» и «этот номер — старое обозначение»: при совпадении по старому номеру ответ должен прямо об этом сказать, иначе пользователь решит, что нашёл актуальную позицию.
Заметьте, что на этой ступени нет ни эмбеддингов, ни LLM. Если запись найдена по точному номеру и бренду, это не «релевантный результат», а конкретная строка каталога. Всё, что дальше делает модель, — это пересказ уже найденных фактов.
Нормализация: что можно убрать, а что нельзя
Между тем, что человек вводит, и тем, что лежит в каталоге, всегда есть шум: пробелы по краям, регистр, неразрывные пробелы из скопированного прайса, разные виды дефиса. Нормализация нужна. Опасна она тогда, когда превращается в «упрощение»: удалить все дефисы, слэши и точки, привести к верхнему регистру, выкинуть ведущие нули.
Вернёмся к нашей паре. Если нормализатор выбрасывает дефисы, а затем ведущие нули, оба артикула — AB-001 и AB-01 — превратятся в AB1. Для системы это теперь одна деталь. Ни ошибки, ни предупреждения: поиск «стал устойчивее к вводу», а вместе с этим потерял различие, которое могло быть единственным отличием между двумя позициями.
Разумный подход — разделять два слоя:
- Безопасная очистка. Обрезка пробелов, замена неразрывных пробелов на обычные, единый регистр, если каталог действительно не различает регистр, единый вид дефиса из набора похожих символов. Эти операции не меняют состав значимых символов.
- Хранение исходной формы. В payload остаётся каноническая запись артикула так, как её даёт производитель. Нормализованный вариант может быть отдельным полем для вспомогательных нужд, но не заменой основного.
Если в вашем каталоге и правда встречаются записи, где разделитель не несёт смысла (одна и та же деталь пишется то с дефисом, то без), это решение принимается явно и по конкретному производителю: в виде таблицы правил, где для бренда указано, какие разделители допускаются. Общий скрипт, который «чистит всё одинаково», так делать не должен.
Почему близость строк — повод переспросить, а не ответ
Пользователи опечатываются, и это нормально. Вместо «AB-001» приходит «AB-0O1» с буквой О или «АВ-001» с кириллическими буквами. Нужен механизм, который подскажет, что ввод похож на существующий артикул. Для этого в PostgreSQL есть расширение pg_trgm.
Оно измеряет близость буквенно-цифровых строк по триграммам, а функция similarity(text, text) возвращает число от 0 до 1. Оператор % сравнивает это значение с порогом pg_trgm.similarity_threshold, а для быстрого поиска близких строк модуль поддерживает индексные классы GiST и GIN (PostgreSQL: pg_trgm).
Теперь главное для артикулов. При выделении триграмм небуквенно-цифровые символы игнорируются. Это значит, что дефис, слэш и точка для pg_trgm не существуют. Две строки, отличающиеся только разделителем, с точки зрения триграмм могут выглядеть очень похоже, а то и совпадать по набору. Для описаний это удобно, для артикулов — ловушка: вы просили «близкие», а получили «неотличимые», хотя различие как раз в игнорируемом символе. Поэтому результат триграммного поиска нельзя использовать для автоматической подстановки.
Порог тоже не работает как вероятность. Значение 0,6 не означает «в шести случаях из десяти это нужная деталь»; это лишь мера пересечения триграмм. Рядом оказываются и настоящие опечатки, и совершенно другие позиции, которые случайно делят много символов.
Правильная роль pg_trgm в такой системе — подсказка в интерфейсе: «Точного совпадения для AB-0O1 нет. Возможно, вы имели в виду: AB-001, AB-010». Пользователь сам выбирает вариант, после чего запрос повторяется уже как точный. Автоматическое исправление «на лету» превращает предположение в факт без согласия того, кто за этот факт отвечает.
Практически это выглядит так: к кандидату от триграммного поиска приписывается пометка «исправление ввода», и он идёт в тот же конвейер, что и обычный точный запрос, но только после явного подтверждения. До подтверждения ни карточка совместимости, ни цена, ни пояснение LLM к нему не привязываются.
Три запроса — три разных ответа
Чтобы увидеть, как ведут себя разные способы поиска, возьмём три условных запроса к вымышленному каталогу и сопоставим, что даёт каждый механизм. Это рассуждение на примере, а не результаты измерений.
| Запрос | Точное совпадение (keyword) | Близость строк (pg_trgm) | Смысловая выдача |
|---|---|---|---|
AB-001 + бренд | Находит ровно одну запись, если она есть. Это единственный надёжный ответ. | Дополнительно покажет AB-01, AB-010 и подобные; польза только как справка. | Не нужна и вредна: может подмешать описания «похожих» деталей. |
AB-0O1 (опечатка) | Ничего не находит. Это правильный результат: такого артикула нет. | Предлагает близкие артикулы как варианты исправления ввода. | Эмбеддинг короткой странной строки даёт случайные соседства; на него полагаться нельзя. |
| «передняя ступица, тихо гудит на скорости» | Номера нет, искать не по чему. | Бесполезен: описание не похоже на артикул по буквам. | Основной инструмент: находит по смыслу описания и симптомов, возвращая кандидатов. |
Из таблицы видно распределение ролей. Точное совпадение отвечает на вопрос «какая именно это деталь». Близость строк — «возможно, вы ошиблись при вводе». Смысловой поиск — «что бы это могло быть, если номера нет». Попытка заставить один инструмент играть все три роли приводит к тому, что в третьем запросе мы тоскуем по точности, а в первом — по гибкости.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.
Описательные запросы: лексика и смысл вместе
Когда номера нет, пользователь описывает деталь словами. Здесь работают два класса поиска, и каждый ловит то, что пропускает другой.
Смысловой (dense) поиск по эмбеддингам хорошо понимает перефразировки: «подшипник колеса» и «ступичный подшипник» окажутся рядом. Лексический поиск ловит конкретные слова и обозначения: размерность, типоразмер, название узла в точной форме. Для каталога запчастей оба нужны: описание может быть расплывчатым, а в нём всё равно встретится «M12×1,5» или «25 мм», которые вектор легко размоет.
Лексическую ветку обычно строят на разреженных (sparse) векторах: в них заданы веса отдельных терминов. Документация Qdrant поясняет, что такие веса могут происходить из статистики BM25 или моделей вроде SPLADE и miniCOIL. Для каталога есть важное предупреждение, о котором прямо говорится в тех же материалах: фиксированный словарь SPLADE ограничивает поиск неизвестных идентификаторов товаров и слов вне предметной области, а трансформерные модели требуют больше ресурсов, чем традиционный BM25 (Qdrant: Sparse vector search).
Переведём это на язык запчастей. Артикулы — как раз те «неизвестные идентификаторы», которых в словаре модели, скорее всего, нет. Модель разобьёт AB-001 на какие-то подслова, и вес этих обломков будет говорить о тексте вообще, но не об идентификаторе. Поэтому sparse-ветка на SPLADE годится для описаний и характеристик, а за точный номер всё равно отвечает keyword-фильтр из первого шага. Если же брать BM25-подобные веса по вашему собственному корпусу, словарь формируется из ваших данных, и ограничение «фиксированного словаря» выглядит иначе, но проверять это нужно на каталоге, а не принимать на веру.
Заметим также, что в документации встречаются примеры с Cloud Inference, где векторы генерируются облаком автоматически. На обычной self-hosted установке так просто перенести их нельзя: эмбеддинги и sparse-веса придётся считать своим кодом или своей моделью и класть в коллекцию готовыми. Это сразу влияет на ресурсы сервера: transformer-модели тяжелее BM25, и под них нужно планировать память и процессор.
Склейка выдач в Qdrant и её предел
Чтобы объединить смысловую и лексическую ветки, в Qdrant используется Query API: он умеет делать предварительные выборки (prefetch), а затем выполнять основной запрос по их результатам. Результаты нескольких поисков можно объединять; доступен такой механизм начиная с версии 1.10.0, а в актуальном руководстве описаны ветки dense, sparse и полнотекстовые (Qdrant: Hybrid queries).
Для слияния часто применяют RRF (reciprocal rank fusion). Он объединяет выдачи по позициям результатов: если деталь высоко в обоих списках, она поднимается. Здесь снова необходима оговорка, которую можно было бы напечатать крупным шрифтом над монитором разработчика: это ранжирование кандидатов, а не доказательство совместимости. Деталь, оказавшаяся первой после RRF, просто чаще и выше встречалась в ветках. О том, подходит ли она к конкретной машине, RRF не знает ничего.
Есть и техническая деталь, о которую спотыкаются при пагинации. Параметр offset действует только на основной запрос, а limit в prefetch должен покрывать сумму limit + offset основной выдачи. Если пользователь листает на вторую страницу, а предварительные выборки отдали всего десять кандидатов на ветку, глубины просто не хватит, и вторая страница окажется пустой или обрезанной. Закладывайте запас в prefetch с учётом того, как глубоко реально будут листать.
Фильтры здесь тоже работают на безопасность. Если бренд известен (из ввода или из выбранной модели), его стоит передавать в обе ветки как условие, чтобы кандидаты других производителей вообще не попадали в выдачу. Это дешевле, чем потом отсеивать их постобработкой, и надёжнее, чем надеяться, что эмбеддинг сам различит бренды.
Проверка применимости до объяснения
Теперь самая важная ступень. Допустим, поиск вернул кандидата — по номеру, по исправлению ввода или по описанию. Дальше можно было бы просто отдать его описание языковой модели и попросить сформулировать ответ покупателю. Именно так и рождаются фразы вроде «эта деталь подойдёт к вашему автомобилю», которых в каталоге никто не утверждал.
Порядок должен быть обратным. Сначала система проверяет, есть ли в каталоге подтверждённая запись применимости: связь «деталь — модель/модификация/диапазон выпуска» с источником данных. Эту связь можно хранить в самом payload или в реляционной таблице, на которую ссылается идентификатор позиции. Проверка — обычное сравнение структурированных полей, без вероятностей:
- Известна ли машина или узел, для которого покупатель подбирает деталь? Если нет — спросить, а не угадывать.
- Есть ли в каталоге строка применимости для пары «позиция — эта машина»?
- Совпадают ли все обязательные характеристики (размеры, исполнение, комплектность), а не только название?
Только после положительного ответа на все три пункта языковая модель получает материал для объяснения: карточку позиции и строку применимости. Её задача — изложить уже установленный факт человеческим языком, а не принимать решение. Промпту стоит прямо запрещать добавлять любые утверждения о совместимости, которых нет в переданных данных.
Если же для кандидата строки применимости нет, ответ строится иначе. «Позиция найдена в каталоге, подходит ли она к вашей машине — не подтверждено». Лучше показать описание и характеристики и предложить уточнить данные, чем придумать связь, которой в источнике нет. Для условного примера: по запросу «передняя ступица» система нашла три кандидата, у двух есть подтверждённая применимость к выбранной модели, у третьего её нет. Первые два показываются как подходящие, третий — отдельной пометкой с честным статусом.
Общая схема подготовки подобной базы — загрузка документов, нарезка, индексация — описана в статье «Как поднять RAG по своим документам на сервере»; здесь же мы исходим из того, что каталог уже подготовлен и записи структурированы.
Что всё это значит для сервера
Архитектура с тремя ступенями неравномерно нагружает машину. Keyword-фильтры и payload-индексы по бренду и артикулу дёшевы, но занимают память и диск. Триграммные индексы PostgreSQL добавляют своё место на диске и время на обновление при загрузке прайсов. Dense-эмбеддинги требуют хранения векторов и вычисления вектора запроса при каждом поиске. Sparse-модели на трансформерах тяжелее BM25 по процессору и памяти.
Поэтому под такую связку разумно брать VPS с запасом по оперативной памяти и быстрым диском: Qdrant, PostgreSQL и сервис эмбеддингов могут жить на одной машине, если каталог невелик, или быть разнесены по мере роста. У MAATRIX на VPS заявлены NVMe-диски, процессоры AMD EPYC и root-доступ, а линейка тарифов начинается с конфигурации 1 vCPU, 2 ГБ RAM, 40 ГБ NVMe (Ba) и доходит до 16 vCPU, 32 ГБ и 320 ГБ (Djed); промежуточные — Uab, Heka и Maat. Для пробного стенда с небольшим каталогом и лёгким BM25 хватит младших вариантов, а модель эмбеддингов и большой объём позиций потребуют конфигурацию выше. Эти цифры — ресурсы, а не обещание конкретной ёмкости: сколько позиций и запросов выдержит ваша связка, показывает только замер на ваших данных. Если хотите развернуть такой стенд, оформить заказ можно на странице аренды VPS MAATRIX.
Когда каталогу не хватает сведений
Хорошая система поиска запчастей отличается не тем, что всегда находит ответ, а тем, что умеет отказаться от него. Вернёмся к исходной паре: пользователь вводит AB-01, а в каталоге есть только AB-001. Точного совпадения нет. Близость строк подсказывает AB-001 как возможное исправление. Пользователь соглашается. Деталь найдена, но строки применимости к его машине в каталоге нет.
Правильный ответ в этой точке не «подходит» и не «скорее всего подходит». Он выглядит примерно так: «Позиция AB-001 найдена в каталоге. Совместимость с вашей машиной не подтверждена: у нас нет данных о применимости. Уточните модель и год выпуска или обратитесь к менеджеру».
Этот статус — не провал поиска, а его законный результат. Он защищает покупателя от возврата и ремонта, а магазин — от спора. И он же задаёт здоровую обратную связь: каждый такой ответ — это сигнал каталогу, где не хватает данных. Собирая такие случаи, вы получаете список позиций, у которых пора заполнить таблицу применимости, и это полезнее, чем ещё один бесконечно улучшаемый эмбеддинг.