# Объяснение изменений в `search/main.py` ## Что мы улучшили Цель изменений: сделать retrieval стабильнее и точнее, а финальную выдачу управляемой и объяснимой. Сделаны следующие шаги: - основной запрос берется из `question.search_text`, fallback на `question.text`; - подключены дополнительные запросы из `question.variants`; - подключены dense-only запросы из `question.hyde`; - sparse-запрос строится по `question.keywords` (если keywords есть); - после rerank кандидаты не теряются; - финальная выдача строится через агрегацию score по `message_id`; - ответ ограничивается `top-50`. ## Что было раньше Ранее пайплайн был линейный: - один query; - один dense и один sparse вектор; - retrieval + rerank только для ограниченного количества кандидатов; - после rerank часть кандидатов выпадала; - `message_id` выдавались почти напрямую из chunk'ов. Это делало результат менее устойчивым при перефразировках и могло терять полезные документы. ## Что стало и почему это лучше ### 1) Источник основного query - Файл: `search/main.py`, `search(...)`, строки около 497-503. - Логика: `collect_query_variants()` сначала берет `question.search_text`, затем fallback на `question.text`. - Зачем: `search_text` обычно более нормализован для поиска, чем сырой пользовательский вопрос. ### 2) Дополнительные query-варианты (`question.variants`) - Файл: `search/main.py`, `collect_query_variants(...)`, строки около 323-344. - Логика: варианты очищаются (`strip`) и дедуплицируются. - Зачем: повышает recall, если один вариант формулировки не попал в нужные chunk'и. ### 3) Dense-only расширение через `question.hyde` - Файл: `search/main.py`, `collect_hyde_queries(...)` и `qdrant_search_dense_only(...)`, строки около 347-360 и 274-320. - Логика: hyde-запросы добавляют семантических кандидатов без sparse-компоненты. - Зачем: помогает доставать семантически близкие фрагменты даже при слабом лексическом совпадении. ### 4) Sparse-основа через `question.keywords` - Файл: `search/main.py`, `build_sparse_query_text(...)`, строки около 363-379; использование в `search(...)` около 509-510. - Логика: если keywords переданы, sparse-текст = объединение keywords; иначе fallback на текущий query-вариант. - Зачем: sparse-поиск становится более управляемым и фокусным по ключевым терминам. ### 5) Кандидаты после rerank больше не теряются - Файл: `search/main.py`, `rerank_points(...)`, строки около 440-467. - Логика: - `head` до `RERANK_LIMIT` проходит через внешний reranker; - `tail` сохраняется и добавляется обратно. - Зачем: rerank улучшает порядок, но не выбрасывает потенциально полезные кандидаты. ### 6) Агрегация score по `message_id` - Файл: `search/main.py`, `aggregate_message_scores(...)`, строки около 470-478. - Логика: score всех chunk'ов, относящихся к одному `message_id`, суммируется. - Зачем: если сообщение встретилось в нескольких сильных chunk'ах, оно получает заслуженный приоритет. ### 7) Ограничение финального ответа `top-50` - Файл: `search/main.py`, `FINAL_TOP_K = 50` (около 178), `select_top_message_ids(...)` (около 481-486), применение в `search(...)` (около 525-527). - Логика: сортировка по убыванию aggregated score, затем срез до 50. - Зачем: контролируем размер ответа и уменьшаем шум. ## Итоговый пайплайн (коротко) 1. Собираем базовые query: `search_text/text + variants`. 2. Для каждого query делаем dense+sparse retrieval. 3. Для `hyde` делаем dense-only retrieval. 4. Объединяем и дедуплицируем кандидатов по point id. 5. Делаем rerank для head, сохраняем tail. 6. Преобразуем кандидаты в `message_id` и агрегируем score. 7. Берем `top-50` и возвращаем в `results[0].message_ids`. ## Как объяснить на созвоне (готовый питч) - Мы перешли от single-query к multi-query retrieval, чтобы увеличить recall. - Разделили роли сигналов: `variants` для расширения формулировок, `hyde` для семантики, `keywords` для лексики. - Убрали потерю кандидатов после rerank: rerank теперь переставляет приоритеты, а не режет выдачу. - Финальный ранк делаем на уровне `message_id`, а не chunk, чтобы учитывать вклад нескольких чанков одного сообщения. - Ограничили выдачу до 50, чтобы интерфейс и API получали компактный и релевантный список. ## На что обратить внимание (ограничения) - Сейчас в агрегации используется сумма score; при необходимости можно экспериментировать с max/mean. - `tail` после rerank использует исходный score из Qdrant, он по шкале может отличаться от reranker score. - Параметры `DENSE_PREFETCH_K`, `SPRASE_PREFETCH_K`, `RETRIEVE_K`, `RERANK_LIMIT` стоит донастроить на локальном наборе регрессионных вопросов.