допилилБ добавлена жестка обработка длины для page_content, dense_content, sparce_content, доп защита на sparce ну и + тест

This commit is contained in:
Hitoshi-Hub 2026-04-18 16:25:00 +03:00
parent 6a25927813
commit 1f2a1c5532
4 changed files with 144 additions and 6 deletions

View file

@ -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` стоит донастроить на локальном наборе регрессионных вопросов.

View file

@ -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

View file

@ -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()],

View file

@ -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):