допилилБ добавлена жестка обработка длины для page_content, dense_content, sparce_content, доп защита на sparce ну и + тест
This commit is contained in:
parent
6a25927813
commit
1f2a1c5532
4 changed files with 144 additions and 6 deletions
88
.ai_explain/search_main_explained.md
Normal file
88
.ai_explain/search_main_explained.md
Normal 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` стоит донастроить на локальном наборе регрессионных вопросов.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()],
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Reference in a new issue