From 1f2a1c55323a39f01d95175932914b305855ee20 Mon Sep 17 00:00:00 2001 From: Hitoshi-Hub Date: Sat, 18 Apr 2026 16:25:00 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B4=D0=BE=D0=BF=D0=B8=D0=BB=D0=B8=D0=BB?= =?UTF-8?q?=D0=91=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0?= =?UTF-8?q?=20=D0=B6=D0=B5=D1=81=D1=82=D0=BA=D0=B0=20=D0=BE=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D0=B0=20=D0=B4=D0=BB=D0=B8=D0=BD?= =?UTF-8?q?=D1=8B=20=D0=B4=D0=BB=D1=8F=20page=5Fcontent,=20dense=5Fcontent?= =?UTF-8?q?,=20sparce=5Fcontent,=20=D0=B4=D0=BE=D0=BF=20=D0=B7=D0=B0=D1=89?= =?UTF-8?q?=D0=B8=D1=82=D0=B0=20=D0=BD=D0=B0=20sparce=20=D0=BD=D1=83=20?= =?UTF-8?q?=D0=B8=20+=20=D1=82=D0=B5=D1=81=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .ai_explain/search_main_explained.md | 88 ++++++++++++++++++++++++++++ index/chunking.py | 34 +++++++++-- index/sparse.py | 10 +++- tests/test_chunking.py | 18 +++++- 4 files changed, 144 insertions(+), 6 deletions(-) create mode 100644 .ai_explain/search_main_explained.md diff --git a/.ai_explain/search_main_explained.md b/.ai_explain/search_main_explained.md new file mode 100644 index 0000000..df212be --- /dev/null +++ b/.ai_explain/search_main_explained.md @@ -0,0 +1,88 @@ +# Объяснение изменений в `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` стоит донастроить на локальном наборе регрессионных вопросов. diff --git a/index/chunking.py b/index/chunking.py index 60cbdfc..2f7766b 100644 --- a/index/chunking.py +++ b/index/chunking.py @@ -10,6 +10,32 @@ TIME_GAP_SECONDS = 3600 OVERLAP_MESSAGES = 2 +def _append_limited(parts: list[str], piece: str, limit: int, sep: str) -> bool: + """Append text piece to parts while respecting the final joined length limit.""" + if not piece or limit <= 0: + return False + + current_len = sum(len(p) for p in parts) + max(0, len(parts)) * len(sep) + extra_sep = len(sep) if parts else 0 + remaining = limit - current_len - extra_sep + if remaining <= 0: + return False + + parts.append(piece[:remaining]) + return len(piece) <= remaining + + +def _join_limited(pieces: list[str], sep: str, limit: int) -> str: + if limit <= 0: + return "" + result: list[str] = [] + for piece in pieces: + fully_added = _append_limited(result, piece, limit, sep) + if not fully_added: + break + return sep.join(result) + + def _clean_all(messages: list[Message]) -> list[CleanedMessage]: cleaned = [clean_message(m) for m in messages] return [c for c in cleaned if not c.is_empty] @@ -35,9 +61,9 @@ def _render_chunk( sparse_tokens.append(sparse) return IndexAPIItem( - page_content="\n".join(page_lines), - dense_content="\n".join(dense_lines), - sparse_content=" ".join(sparse_tokens), + page_content=_join_limited(page_lines, "\n", WINDOW_MAX_CHARS), + dense_content=_join_limited(dense_lines, "\n", WINDOW_MAX_CHARS), + sparse_content=_join_limited(sparse_tokens, " ", WINDOW_MAX_CHARS), message_ids=[msg.id for msg in window], ) @@ -53,7 +79,7 @@ def _split_windows(messages: list[CleanedMessage]) -> list[list[CleanedMessage]] for msg in messages: msg_text = render_page_content(msg) - msg_chars = len(msg_text) + msg_chars = min(len(msg_text), WINDOW_MAX_CHARS) time_break = ( current diff --git a/index/sparse.py b/index/sparse.py index 26404c7..9c4caaa 100644 --- a/index/sparse.py +++ b/index/sparse.py @@ -6,6 +6,7 @@ from index_schemas import SparseVector SPARSE_MODEL_NAME = "Qdrant/bm25" FASTEMBED_CACHE_PATH = "/models/fastembed" +MAX_SPARSE_TEXT_CHARS = int(os.getenv("MAX_SPARSE_TEXT_CHARS", "512")) logger = logging.getLogger("index-service") @@ -18,10 +19,17 @@ def get_sparse_model(): return SparseTextEmbedding(model_name=SPARSE_MODEL_NAME) +def _prepare_text(text: str) -> str: + if not text: + return "" + return text[:MAX_SPARSE_TEXT_CHARS] + + def embed_sparse_texts(texts: list[str]) -> list[SparseVector]: model = get_sparse_model() + prepared = [_prepare_text(t) for t in texts] result: list[SparseVector] = [] - for item in model.embed(texts): + for item in model.embed(prepared): result.append( SparseVector( indices=[int(i) for i in item.indices.tolist()], diff --git a/tests/test_chunking.py b/tests/test_chunking.py index 4ac4324..ba96493 100644 --- a/tests/test_chunking.py +++ b/tests/test_chunking.py @@ -5,7 +5,13 @@ import os _INDEX_DIR = os.path.join(os.path.dirname(__file__), "..", "index") sys.path.insert(0, _INDEX_DIR) -from chunking import build_chunks, _split_windows, WINDOW_MAX_MESSAGES, TIME_GAP_SECONDS +from chunking import ( + build_chunks, + _split_windows, + WINDOW_MAX_MESSAGES, + WINDOW_MAX_CHARS, + TIME_GAP_SECONDS, +) from cleaning import CleanedMessage from index_schemas import Message @@ -101,6 +107,16 @@ class TestBuildChunks: assert "m1" not in all_ids assert "m2" in all_ids + def test_hard_limit_for_chunk_content_lengths(self): + long_msg = _make_message("m1", 1000000, text="x" * (WINDOW_MAX_CHARS * 3)) + result = build_chunks([], [long_msg]) + assert len(result) == 1 + chunk = result[0] + assert len(chunk.page_content) <= WINDOW_MAX_CHARS + assert len(chunk.dense_content) <= WINDOW_MAX_CHARS + assert len(chunk.sparse_content) <= WINDOW_MAX_CHARS + assert chunk.message_ids == ["m1"] + class TestSplitWindows: def test_empty(self):