Хочу поделиться своим опытом создания системы контекстного поиска. Плотно занялся LLM год назад, можно сказать, что я «молодой специалист».
И так, появился первый заказчик. Осознал идею, разбил на этапы и приступил к работе.
Одна из задач — по свободному запросу пользователя находить в базе знаний релевантный термин. От точности поиска зависела вся остальная логика системы и успех проекта.
Выбор модели RAG
Я изначально «интуитивно» смотрел в сторону локальных моделей RAG: во‑первых, чтобы не платить за токены, но самое главное — обеспечить безопасность данных, таково было условие проекта. Почитал форумы, провёл тесты и остановился на «multilingual‑e5-large», несмотря на её мультиязычность. Как ни странно, она показывает лучшие результаты по сравнению с моделями, заточенными под родную кириллицу.
Префиксы
По заверениям авторов, E5 нужны префиксы для корректной работы. Хотя на этапе выбора и тестирования она и без них довольно точно находила нужное значение.
Для запроса ставим префикс:
f"query: {problem_text} "Для записей в базе знаний:
f"passage: {p['name']}. {desc} "
Структура базы знаний
Путём экспериментов и тестов, пришёл к следующей структуре. Пример JSON:
Пример корневого свойства, структура дочернего аналогична.{ "id": "00", "name": "Наименование свойства", "aspects": [ {"type": "1", "text": "Описание 1"}, {"type": "2", "text": "Описание 2"}, {"type": "3", "text": "Наименование свойства"} ], "root_id": "00", "parent_name": null, "matrix_property": true, "categories": [], "synonyms": [], "negative_synonyms": []}
Корневые и дочерние свойства
Если пытаться впихнуть в одно свойство все возможные варианты его описания, у модели размывается фокус. Я разбавил структуру. У каждого свойства, по умолчанию есть корневая запись с наименованием и описанием, синонимами. При необходимости добавляем дочерние варианты с аналогичной структурой. Так модель с большей вероятностью подберет подходящий вариант. Даже если их будет несколько, это не критично — скриптом (Python) поднимаем до root ID и получаем искомое свойство.
Главное не переборщить с вариантами — их описание требует много усилий и времени, а их чрезмерное количество для одного свойства в итоге может вытеснить из рейтинга другие свойства, близкие по смыслу и полезные для последующей аналитики и использования.
Композитные свойства
Специфика задачи — в базе знаний оказалось много свойств, которые сочетают в себе несколько смыслов.
Пример: «Индекс ценности фичи» = Бизнес‑ценность + Сложность реализации.
Аспект 1 — бизнес ценность, Аспект 2 — сложность реализации
Использовать «корневые и дочерние» не вариант — при поиске должны «сыграть все смыслы». Поэтому декомпозируем описание свойства на аспекты.
Эмбеддим их отдельно и получаем score каждого, а затем используем функцию meanпри агрегации общего score. Можно использовать более жесткий вариант — Min.
Агрегация происходит по ID свойства. Для его извлечения нужен «лишний» запрос в исходную базу знаний, что не очень хорошо. Поэтому я решил все метаданные, включая ID, хранить непосредственно в базе эмбеддингов. Место они занимают немного, и нет риска рассинхронизации, т.к все загружается из единого источника.
Negative_synonyms
То чем свойство не является. "negative_synonyms": [].Полезная фича для тонкого тюнинга. Использую в исключительных случаях.
Например «Чёрный ящик» имеет два смысла: физический объект и непрозрачность процесса.
Ембеддинг каждого антисинонима выполняется отдельно. Здесь использую функцию Max,то есть беру максимальное значение из всех возможных. Штрафую попавшие в основной рейтинг свойства.
penalty = 2.0 if n_score >= NEG_THRESHOLD else 1.0
В моем случае с E5, опытным путём — NEG_THRESHOLD = 0.83. Т.е. если score негативного синонима больше порогового значения — начисляем штраф.
Я пробовал заполнять это поле поголовно для всех свойств базы знаний, но в итоге отказался, так как под штраф попадали близкие по смыслу свойства, важные для дальнейшей логики работы системы.
С чем модель E5 не справилась
Даже после применённых выше приёмов, модель по прежнему не могла различать «отрицание».
Например: «Подвижный» и «Неподвижный». Отличие всего на две буквы выдаёт почти одинаковые score.
Я пробовал различные варианты описаний — не помогло. Позже выяснил, Е5 не умеет это делать от слова совсем.
Пришлось задействовать силы LLM. Выбралqwen2.5:7b-instruct-q5_K_M.
-
Перед эмбеддингом LLM классифицирует запрос. Например, на всё тот же «Подвижный» или «Неподвижный».
-
От Е5, получаем список кандидатов, сравниваем их
"categories": []из метаданных с результатами классификации запроса от LLM и штрафуем тех, чья категория не совпала. -
Если запрос не относится к категории — ставлю
Noneбез штрафа, то есть шаг классификации пропускается. -
Score свойств, которые выиграли в номинации «категория», я не стал искусственно завышать, чтобы они имели одинаковые условия конкуренции в рейтинге с остальными свойствами, к которым номинация «категория» вообще не относится.
Есть риск, что LLM ошибётся, но тесты показали, что хороший промпт + few‑shot JSON, заполненный качественными примерами, выдаёт стабильно корректный результат.
Reranking
Несмотря на то, что удалось добиться высокой точности поиска с минимальным Loss, я решил в завершение цикла добавить Reranking. На топ 5–10 кандидатов он отнимает мизерную долю времени. Выбрал jina‑reranker‑v3.
-
Jina я отдаю точно такие же данные, что и E5, для сохранения консистентности.
-
Сырой логит использую как детектор сигнала: в случае если он меньше нуля — откатываюсь обратно на рейтинг E5. Значит Jina бессильна.
-
Далее нормализую сырой логит через функцию sigmoid, агрегирую результаты композитных свойств (аналогично Е5) и получаю итоговый рейтинг.
Итоговый Pipeline
-
Загружаем библиотеки и модели с весами.
-
Загружаем предварительно созданные эмбеддинги по базе знаний вместе с метаданными (ID свойств, категории, и данные для Jina). Чтобы не тратить драгоценные секунды во время поиска.
-
LLM квалифицирует запрос и возращает признак — подвижный/неподвижный.
-
E5 по запросу возвращает релевантных кандидатов через фильтр
POS_THRESHOLD.Сравниваем метаданные кандидатов с признаком, полученным от LLM. Через штраф отсеиваем тех, кто не попадает в «категорию» и сработали «антисинонимы». Рассчитываем итоговый pure_score кандидата. -
Формула расчета результирующего score перед отправкой в Jina:
pure_score = (p_score / penalty) * cat_factor-
pure_score — итоговый рейтинг;
-
p_score — score который вернул E5;
-
penalty — штраф за негативный синоним;
-
cat_factor — штраф за неправильную категорию.
-
-
Отдаем топ отобранных кандидатов в Jina, получаем сырые логиты. Если логиты ВСЕХ аспектов кандидата отрицательные, возращаемся к score Е5. Тут возможны варианты: «все», «хотя бы один положительный», «хотя бы один отрицательный».
-
Преобразуем сырые логиты через sigmoid, агрегируем по
Mean,получаем итоговый рейтинг. -
Поднимаем по Root_Id до корневого свойства, получаем искомый результат.
Мелочи и нюансы
-
Все значения фильтров, порогов и штрафов — это настраиваемые параметры, что очень удобно при отладке и тестировании и можно вывести в интерфейс пользователя.
-
Синонимы не должны дублировать слова из описания, излишняя плотность одинаковых слов только вредит поиску, а отличные от описания синонимы дают бОльший охват при поиске.
-
Желательно чтобы все свойства в базе знаний имели полноценное описание (наименование, описание, синонимы) и примерно одинаковое количество символов. Если часть свойств оставить короткими, например только «Наименование» они иногда начинают ошибочно доминировать в рейтинге над свойством с полноценным описанием, создавая лишний шум.
-
С другой стороны, если оставить в базе знаний только «Наименования» свойств, обе модели точно выдают правильный результат, НО НЕ ВСЕГДА!
-
В итоге, я пришел к компромиссному варианту — «Название свойства» (без описания и синонимов) я ставлю как отдельный, дополнительный аспект — даю шанс моделям проявить себя без подсказок, «из коробки». Все всплески нивелируются через фильтр
mean. Е5 перестает доминировать, а у Jina пропадают отрицательные логиты, так как в батче идут сразу несколько аспектов вместе, представляя общий контекст свойства.
"aspects": [ {"type": "1", "text": "Описание 1"}, {"type": "2", "text": "Описание 2"}, {"type": "3", "text": "Наименование свойства"}
-
В большинстве случаев score Е5 у правильного ответа становится чуть выше (иногда на десятые доли, а это ощутимый прирост), если синонимы идут через запятую с маленькой буквы. Пробовал точку с запятой, кавычки и другие варианты. Запятая работает если количество синонимов не больше 5 шт, а больше и не стоит иначе размывается фокус.
Надеюсь, что кому‑то я помог. Хочу услышать ваше мнение и советы!
Игорь
ссылка на оригинал статьи https://habr.com/ru/articles/1050482/