RAG на кончиках пальцев

от автора

Хочу поделиться своим опытом создания системы контекстного поиска. Плотно занялся 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.

  1. Перед эмбеддингом LLM классифицирует запрос. Например, на всё тот же «Подвижный» или «Неподвижный».

  2. От Е5, получаем список кандидатов, сравниваем их "categories": [] из метаданных с результатами классификации запроса от LLM и штрафуем тех, чья категория не совпала.

  3. Если запрос не относится к категории — ставлю None без штрафа, то есть шаг классификации пропускается.

  4. Score свойств, которые выиграли в номинации «категория», я не стал искусственно завышать, чтобы они имели одинаковые условия конкуренции в рейтинге с остальными свойствами, к которым номинация «категория» вообще не относится.

Есть риск, что LLM ошибётся, но тесты показали, что хороший промпт + few‑shot JSON, заполненный качественными примерами, выдаёт стабильно корректный результат.

Reranking

Несмотря на то, что удалось добиться высокой точности поиска с минимальным Loss, я решил в завершение цикла добавить Reranking. На топ 5–10 кандидатов он отнимает мизерную долю времени. Выбрал jina‑reranker‑v3.

  1. Jina я отдаю точно такие же данные, что и E5, для сохранения консистентности.

  2. Сырой логит использую как детектор сигнала: в случае если он меньше нуля — откатываюсь обратно на рейтинг E5. Значит Jina бессильна.

  3. Далее нормализую сырой логит через функцию sigmoid, агрегирую результаты композитных свойств (аналогично Е5) и получаю итоговый рейтинг.

Итоговый Pipeline

  1. Загружаем библиотеки и модели с весами.

  2. Загружаем предварительно созданные эмбеддинги по базе знаний вместе с метаданными (ID свойств, категории, и данные для Jina). Чтобы не тратить драгоценные секунды во время поиска.

  3. LLM квалифицирует запрос и возращает признак — подвижный/неподвижный.

  4. E5 по запросу возвращает релевантных кандидатов через фильтрPOS_THRESHOLD. Сравниваем метаданные кандидатов с признаком, полученным от LLM. Через штраф отсеиваем тех, кто не попадает в «категорию» и сработали «антисинонимы». Рассчитываем итоговый pure_score кандидата.

  5. Формула расчета результирующего score перед отправкой в Jina: pure_score = (p_score / penalty) * cat_factor

    • pure_score — итоговый рейтинг;

    • p_score — score который вернул E5;

    • penalty — штраф за негативный синоним;

    • cat_factor — штраф за неправильную категорию.

  6. Отдаем топ отобранных кандидатов в Jina, получаем сырые логиты. Если логиты ВСЕХ аспектов кандидата отрицательные, возращаемся к score Е5. Тут возможны варианты: «все», «хотя бы один положительный», «хотя бы один отрицательный».

  7. Преобразуем сырые логиты через sigmoid, агрегируем по Mean, получаем итоговый рейтинг.

  8. Поднимаем по 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/