Перейти к содержимому

5.2 · Гибридный поиск (BM25) и реранкинг

Неделя 5 · RAG · ⏱ ~5 ч · Теория, Практика

Векторный поиск хорошо понимает смысл, но плохо находит точные термины: артикулы, промокоды, названия служб доставки, номера зон и тарифов, системные статусы заказа. Для поддержки магазина это критично: вопрос про TEA15 или LAMP-LIGHT-MINI должен находить именно этот кусок, а не соседний про TEA10 или лампу «Свет». Гибридный поиск (BM25 + эмбеддинги) и реранкер — стандартная «продакшн-конфигурация» RAG; её ожидают увидеть в проекте и спрашивают на собеседованиях: «как объединить две выдачи», «чем bi-encoder отличается от cross-encoder», «сколько кандидатов давать реранкеру».

Почему эмбеддинги промахиваются на точных терминах

Заголовок раздела «Почему эмбеддинги промахиваются на точных терминах»

Эмбеддинг кодирует смысл «в целом». Для модели артикулы LAMP-LIGHT и LAMP-LIGHT-MINI — почти одно и то же («что-то про лампу Свет»), промокоды TEA10 и TEA15 — тоже («скидка на чай»). Редкий идентификатор модель могла вообще не видеть при обучении и разбила его на бессмысленные куски токенов. А покупатель, который пишет точный промокод или артикул, хочет именно его: у ламп разная гарантия, у промокодов разные условия.

Классический лексический поиск устроен наоборот: он ничего не понимает в смысле, зато точное совпадение редкого слова ценит очень высоко.

Эмбеддинги (dense) BM25 (sparse, лексический)
Синонимы, перефразировки («открытая пачка» ↔ «вскрытый чай») Хорошо Плохо
Формы слова (возврат, возврата, вернуть) Хорошо Плохо без стемминга
Разные алфавиты («Свет мини» ↔ LAMP-LIGHT-MINI) Терпимо (мультиязычная модель) Никак
Точные идентификаторы: артикулы, промокоды, номера Плохо Отлично
Опечатки Терпимо Плохо
Нужна модель / GPU Да Нет

Они ошибаются на разных вопросах — поэтому их и объединяют.

Лексический поиск, который стоит рядом с эмбеддингами почти в каждой RAG-системе, — это BM25. Он эволюция TF-IDF, стандарт полнотекстового поиска (Elasticsearch, Lucene, SQLite FTS5). Для каждого слова запроса считается вклад в скор документа:

  • TF (term frequency) — сколько раз слово встречается в документе. Но с насыщением: 10 упоминаний не в 10 раз лучше одного (параметр k1).
  • IDF (inverse document frequency) — насколько слово редкое во всей коллекции. «заказ», «доставка», «товар» есть почти везде — вес маленький; TEA15 или LAMP-LIGHT-MINI — в паре чанков, вес огромный.
  • Нормализация на длину документа (параметр b) — длинный документ не должен выигрывать просто потому, что в нём больше слов.

Итоговый скор — сумма вкладов слов запроса. Никаких векторов и моделей — только статистика слов, поэтому BM25 быстрый и полностью объяснимый.

from rank_bm25 import BM25Okapi
corpus_tokens = [tokenize(c["text"]) for c in chunks]
bm25 = BM25Okapi(corpus_tokens)
scores = bm25.get_scores(tokenize("промокод TEA15 не сработал")) # one score per chunk

В коде выше осталась неопределённой функция tokenize, и это не мелочь. BM25 сравнивает не смысл, а токены: если TEA15 в вопросе и TEA15 в базе превратились в разные токены, совпадения нет, и вся сила лексического поиска пропадает. text.split() для базы знаний магазина — плохой токенизатор. TEA15, с запятой и «Свет» с кавычками не совпадут с TEA15 и свет; Промокод и промокод окажутся разными словами; артикул LAMP-LIGHT-MINI останется одним токеном и не найдётся по слову mini. Вариант получше для нашего случая:

import re
TOKEN = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*|[а-я]+")
def tokenize(text: str) -> list[str]:
tokens = []
for t in TOKEN.findall(text.lower().replace("ё", "е")):
tokens.append(t) # whole token: lamp-light-mini, tea15, 3
if "-" in t:
tokens.extend(t.split("-")) # parts: lamp, light, mini
# TODO: Russian stemming: возврата, возврате -> возврат
return tokens

Принципы: нижний регистр и ё → е; промокод с цифрами (TEA15) — один токен; артикул с дефисами — и целиком, и по частям (запрос LAMP-LIGHT целиком совпадёт только со своей лампой, у LAMP-LIGHT-MINI — лишь по частям); числа (вес, номер зоны) — отдельные токены. Русские слова меняют окончания: «возврат», «возврата», «вернуть» для BM25 — три разных слова. Частично это лечит стемминг (Snowball-стеммер для русского, например, из пакета snowballstemmer), но синонимы («открытая пачка» ↔ «вскрытый чай») не лечит ничего, кроме эмбеддингов и переписывания запроса (гл. 5.1). Хорошая комбинация: BM25 запускается по переписанным запросам из plan_query, которые ближе к формулировкам kb/, плюс по исходному вопросу — в нём идентификаторы покупателя сохранены дословно.

Альтернатива rank_bm25 в памяти — SQLite FTS5 (встроенный полнотекстовый индекс с функцией bm25()): живёт в том же index.db, что и векторы. Токенайзер unicode61 приводит к нижнему регистру и кириллицу, а встроенный стеммер porter — только английский. Для проекта годится любой вариант; rank_bm25 проще для эксперимента.

Теперь на каждый вопрос у нас две выдачи: векторная и BM25. Как объединить их в одну? Скоры несравнимы: косинус ~0.3–0.9, BM25 — от 0 до десятков, зависит от запроса. Нормализовать их (min-max) можно, но хрупко. RRF игнорирует значения скоров и использует только ранги:

RRF(d) = Σ по всем выдачам 1 / (k + rank_i(d)) k обычно = 60

Документ, который стоит высоко в обеих выдачах, получает больше всего; документ, найденный только одной системой, тоже может пробиться, если стоит у неё на первом месте.

def rrf(rankings: list[list[int]], k: int = 60, top: int = 50) -> list[int]:
scores: dict[int, float] = {}
for ranking in rankings: # each ranking: chunk ids, best first
for rank, cid in enumerate(ranking, start=1):
scores[cid] = scores.get(cid, 0.0) + 1.0 / (k + rank)
return sorted(scores, key=scores.get, reverse=True)[:top]

Пример: чанк A — 1-й в векторе и 3-й в BM25: 1/61 + 1/63 ≈ 0.0323. Чанк B — 1-й только в BM25: 1/61 ≈ 0.0164. Чанк C — 2-й и 2-й: 2/62 ≈ 0.0323. A и C почти равны, B ниже. Можно ввести веса выдач (w_i / (k + rank)), но начинать стоит без них.

RRF работает и для multi-query из гл. 5.1: каждая выдача по каждому запросу — ещё один ranking.

RRF собирает хороший список кандидатов, но порядок внутри него грубый: обе системы оценивали связь вопроса с куском косвенно — через векторы или через совпадение слов. В промпт же пойдут только 5 верхних кусков, и важно, чтобы правильный оказался среди них. Поэтому верх списка переоценивают более точной и более медленной моделью — реранкером.

Эмбеддинги — это bi-encoder: вопрос и документ кодируются независимо, потом сравниваются векторы. Это быстро (векторы документов посчитаны заранее), но модель никогда не видит вопрос и документ вместе.

Cross-encoder получает пару «вопрос + документ» целиком в одном входе и выдаёт одно число — релевантность. Он видит, как слова вопроса соотносятся со словами документа (attention между ними), поэтому заметно точнее: например, замечает, что в вопросе «Свет мини», а в чанке — гарантия на просто «Свет». Цена — нельзя посчитать заранее: на каждый вопрос нужно прогнать модель по каждому кандидату. На всей базе — слишком медленно; на 20–50 кандидатах — нормально.

┌──────────────┐ top-50 ┌──────────────┐ top-5 ┌────────┐
вопрос ──► │ вектор + BM25│ ────────► │ cross-encoder│ ───────► │ промпт │
│ + RRF (быстро, грубо) │ (медленно, точно) └────────┘
└──────────────┘ └──────────────┘

Это классический двухэтапный retrieve-then-rerank: широкий дешёвый поиск обеспечивает recall, дорогой реранкер — precision.

# Illustrative: choose a reranker on sbert.net / Hugging Face; multilingual for Russian questions
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")
pairs = [(question, chunks[cid]["text"]) for cid in candidate_ids]
scores = reranker.predict(pairs, batch_size=16)
reranked = [cid for _, cid in sorted(zip(scores, candidate_ids), reverse=True)][:5]

Варианты реранкера: локальные cross-encoder’ы из Sentence Transformers (английские cross-encoder/ms-marco-* — маленькие и быстрые; мультиязычные — тяжелее), API-реранкеры (Cohere, Voyage и др.), или LLM как реранкер (дорого, но гибко). Наши вопросы и база знаний — на русском, поэтому английский реранкер не подходит: нужен мультиязычный.

Скорость локального cross-encoder’а на CPU или Apple Silicon — от десятков миллисекунд до нескольких секунд на 20–50 пар, зависит от размера модели, длины чанков и машины. Измеряем.

Всё разобранное выше — BM25 рядом с эмбеддингами, объединение выдач, реранкер поверх кандидатов — сведено в один рецепт в статье Anthropic «Contextual Retrieval», главном чтении этой главы (первый пункт маршрута). Статья добавляет к нему ещё один шаг и измеряет вклад каждого.

«Contextual» означает: перед индексацией к каждому чанку дописывается короткое пояснение, сгенерированное LLM по всему документу («Этот фрагмент — со страницы товара „Лампа «Свет мини»“ (артикул LAMP-LIGHT-MINI), раздел „Гарантия“, описывает срок гарантии и как подать претензию»), и дополненный текст идёт и в эмбеддинги, и в BM25. Чтобы не платить за весь документ на каждом чанке, используется prompt caching: документ кэшируется, меняется только чанк. Наши breadcrumbs из гл. 4.3 (Товары > Лампа «Свет мини» > Гарантия) — бесплатный частичный аналог; LLM-пояснение добавляет смысл, которого нет в заголовках.

Результаты статьи на её бенчмарках (метрика — доля промахов в top-20, т.е. 1 − recall@20):

Конфигурация Снижение доли промахов
Contextual Embeddings −35%
Contextual Embeddings + Contextual BM25 −49%
+ реранкинг −67%

Цифры статьи — на их данных. На нашей базе знаний они будут другими: именно поэтому строим собственную таблицу.

Чтобы увидеть вклад гибрида и реранкера, одной знакомой метрики мало.

  • hit@k — доля вопросов, где правильный кусок попал в первые k (как в гл. 4.3 и гл. 5.1).
  • MRR (Mean Reciprocal Rank) — среднее 1 / rank первого правильного куска (0, если не найден). Реранкер часто почти не меняет hit@20 (кандидаты те же), но поднимает правильный кусок с 7-го места на 1-е — это видно по MRR и hit@3, а для ответа важно, попадёт ли он в 5 кусков промпта.
def reciprocal_rank(ranked_texts: list[str], must_contain: list[str]) -> float:
for i, t in enumerate(ranked_texts, start=1):
if all(s.lower() in t.lower() for s in must_contain):
return 1.0 / i
return 0.0

BM25 и RRF — чистый Python, провайдер не важен. Различия в двух местах: реранкер и Contextual Retrieval.

На ноябрь 2026 эндпоинта реранкинга нет ни у DeepSeek (в API Reference его нет), ни у Ollama: в документации Ollama его нет, а запрос на такую функцию в репозитории Ollama до сих пор открыт. Основной путь — CrossEncoder из Sentence Transformers, как в «Теории», с мультиязычной моделью (BAAI/bge-reranker-v2-m3 — та же семья, что bge-m3). Модель локальная, бесплатная, текст никуда не уходит.

Альтернатива — LLM как реранкер: отдаём DeepSeek вопрос и 20 пронумерованных кандидатов и просим вернуть JSON со списком номеров по релевантности. Это работает, но: +1 вызов на горячем пути, длинный вход (20 чанков), результат недетерминирован, а JSON-режим DeepSeek не проверяет схему — см. 5.1 · Переформулировка запроса и метаданные. Это эксперимент, а не основной вариант.

В статье Anthropic дешевизну даёт prompt caching: документ кэшируется один раз, а для каждого чанка платим в основном за сам чанк. Как это выглядит у разных провайдеров:

  • Claude — кэш явный: блок с документом помечается через cache_control, и есть минимальная длина кэшируемого блока (смотрим документацию).
  • DeepSeek — кэш автоматический и всегда включён (Context Caching). Повторяющийся префикс запроса берётся с диска. Отсюда правило: сначала неизменная часть (инструкция + вся страница), в самом конце — чанк. Тогда для всех чанков одной страницы префикс совпадает. Попадание видно в usage: prompt_cache_hit_tokens и prompt_cache_miss_tokens. Кэш best-effort: 100% попаданий не обещают, а cache_control в Anthropic-совместимом эндпоинте DeepSeek игнорируется.
  • Ollama — денег не стоит, но даже на 400–500 чанках нашей базы генерация пояснений локальной моделью на ноутбуке может занять часы: каждый вызов заново читает всю страницу. Сначала замеряем скорость на 20 чанках.

Порядок стоимости на DeepSeek (цены deepseek-flash на 13.11.2026, непиковое время: вход с попаданием в кэш $0.003 за 1M токенов, с промахом $0.15, выход $0.60; в пиковые часы вдвое дороже). Возьмём страницу kb/ на 3K токенов, 10 чанков по ~300 токенов и пояснение ~100 токенов на чанк. Если все повторы попадут в кэш: 9 × 3K = 27K токенов по цене попадания + ~6K по цене промаха (первая страница и сами чанки) + ~1K выхода ≈ $0.0016 за страницу, то есть меньше десяти центов на всю базу из 50 страниц. Это прикидка, а не обещание: перед запуском на всём индексе прогоняем 20 страниц и считаем фактическую стоимость по usage.

# Illustrative: keep the page as a stable prefix, put the chunk last
def contextualize(ds, model: str, page_md: str, chunk: str) -> tuple[str, dict]:
resp = ds.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": CONTEXT_PROMPT}, # same for all calls
{"role": "user", "content": f"<document>\n{page_md}\n</document>\n\n"
f"<chunk>\n{chunk}\n</chunk>"}, # chunk at the very end
],
max_tokens=150,
extra_body={"thinking": {"type": "disabled"}},
)
u = resp.usage
stats = {"hit": getattr(u, "prompt_cache_hit_tokens", None),
"miss": getattr(u, "prompt_cache_miss_tokens", None)}
return resp.choices[0].message.content.strip(), stats

Безопасность: в этом проекте и страница, и чанк — публичная база знаний магазина, поэтому DeepSeek здесь подходит. Правило курса при этом не меняется: личные данные клиентов и настоящие сообщения покупателей в облачные модели не уходят — только в локальную; вопросы для golden set выдумываем.

Что нужно Claude OpenAI DeepSeek Ollama
Эндпоинт реранкинга Нет (партнёры: Voyage и др.) Нет Нет Нет (на 11.2026)
Обходной путь Локальный cross-encoder или API партнёра Локальный cross-encoder Локальный cross-encoder; LLM-реранкинг через JSON Локальный cross-encoder через Sentence Transformers
Кэш для Contextual Retrieval Явный cache_control Автоматический для длинных префиксов (смотрим гайд) Автоматический по префиксу; prompt_cache_hit_tokens в usage Не нужен (бесплатно), но медленно
Порядок частей промпта Документ — в кэшируемом блоке Неизменная часть — в начале Неизменная часть — в начале, чанк — в конце Не важен для денег
  • Реранкер — локальный CrossEncoder (основной и единственный «бесплатный» путь).
  • Память. На горячем пути теперь до трёх локальных моделей: эмбеддинг-модель в Ollama (bge-m3 ~1.2 GB), cross-encoder в Python-процессе (мультиязычный реранкер — модель того же порядка размера, проверяем на карточке) и, в офлайн-режиме, генератор. Поэтому генерацию по умолчанию отдаём DeepSeek: база знаний публичная, а памяти это не занимает. Полностью офлайн-режим по объёму RAM: 8 GB — только генератор класса 3–4B (qwen3:4b, ~2.5 GB) и только при закрытых тяжёлых приложениях; 16 GB — до 7–8B; 32 GB+ — 14B и больше. До запуска прикидываем сумму по ollama ps.
  • Contextual chunking (опционально) — DeepSeek deepseek-flash с выключенным рассуждением, страница в префиксе. Ollama — если нужен полностью офлайн-вариант и есть время ждать: модель по объёму RAM, а страница плюс чанк требуют достаточного num_ctx, то есть ещё памяти. Замеряем на 5 страницах, прежде чем решать. Вариант на Claude с cache_control — «для сравнения».
  • Если качество пояснений судит LLM-судья, берём модель сильнее той, что писала пояснения (например, deepseek-v4-pro или Claude для пояснений от deepseek-flash).
  1. Открываем Anthropic: Contextual Retrieval → читаем целиком, обязательно (≈45 мин). Обратим внимание: почему BM25 добавляют к эмбеддингам, как генерируется контекст чанка (промпт в статье), роль prompt caching в стоимости, как устроен реранкинг поверх кандидатов, какие выводы в конце (что комбинировать, сколько чанков отдавать). Сравниваем их метрику с нашей hit@k.
  2. Открываем rank_bm25 → README (≈10 мин). Выносим: BM25Okapi, токенизация — наша ответственность, get_scores / get_top_n.
  3. Находим описание BM25 (статья «Okapi BM25» в Википедии подойдёт) → формула и смысл k1 и b (≈15 мин). Формулу не заучиваем — важно понять три составляющие: TF с насыщением, IDF, нормализация длины.
  4. Читаем про Reciprocal Rank Fusion: оригинальная статья Cormack, Clarke, Büttcher, 2009, «Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods» — достаточно абстракта и формулы (≈10 мин).
  5. Открываем Sentence Transformers → разделы про Cross-Encoder: Usage и Pretrained Models / Retrieve & Re-Rank (≈30 мин). Выносим: разницу bi-encoder / cross-encoder, CrossEncoder.predict, рекомендуемые модели для реранкинга и их скорость, схему retrieve & re-rank.
  6. (Опционально) Документация SQLite FTS5 на sqlite.org — функция bm25() и токенайзеры (unicode61, porter), если BM25 будет жить в той же базе (≈15 мин).
  7. Открываем карточку реранкера на Hugging Face (например, BAAI/bge-reranker-v2-m3 в организации BAAI) (≈10 мин). Выносим: языки (есть ли русский), лимит длины входа, как вызывать через CrossEncoder, лицензию.
  8. Открываем DeepSeek: Context Caching и Models & Pricing (≈15 мин). Выносим: как строятся «единицы» кэшируемого префикса, поля prompt_cache_hit_tokens/prompt_cache_miss_tokens, цены попадания и промаха, пиковые часы. Сравниваем с тем, как кэширует Anthropic в статье про Contextual Retrieval.
  • Токенизация BM25 — главный источник багов. Проверяем на 3 запросах, что TEA15, LAMP-LIGHT-MINI, «Возврата» действительно превращаются в токены, которые есть в индексе (tea15; lamp-light-mini, lamp, light, mini; возврата или его основа после стемминга).
  • Один tokenize() для корпуса и запроса. Разные функции (или стемминг только с одной стороны) — и совпадений не будет даже на одинаковых словах.
  • BM25 не знает фильтров из гл. 4.4 — фильтр по категории применяем и к BM25-выдаче (фильтруем до ранжирования или держим отдельный BM25 на категорию).
  • Архив в BM25. Старые правила возврата (returns-2025.md) по словам почти совпадают с актуальными, и BM25 охотно поднимет их наверх. archived по умолчанию исключаем и из лексического индекса, как из векторного.
  • Индексы рассинхронизированы. BM25 в памяти строится из той же таблицы chunks, что и векторы; после ingest.py его пересобираем. Идентификаторы в обеих выдачах — один и тот же chunks.id.
  • Складывать сырые скоры BM25 и косинуса — ошибка: разные шкалы, BM25 доминирует. Для того и RRF.
  • Реранкер на всей базе — неприемлемо медленно. Только top-20…50.
  • Слишком мало кандидатов для реранкера. Если правильного куска нет в top-20, реранкер его не вернёт. Сначала hit@20 первого этапа, потом precision реранкера.
  • Длина входа реранкера ограничена (часто 512 токенов) — длинные чанки обрезаются, и релевантная часть (например, строка таблицы тарифов в конце раздела) может не попасть. Знаем лимит модели.
  • Язык реранкера: английский cross-encoder на русском вопросе и русской базе работает плохо. Нужна мультиязычная модель.
  • Первый вызов медленный — загрузка модели. Грузим её один раз при старте, не на каждый запрос; при замере латентности отделяем прогрев.

Код в projects/02-support-kb-rag/rag/hybrid.py, rag/rerank.py, eval — в eval/. Golden set — те же 20 вопросов eval/golden.jsonl из гл. 5.1. Идентификаторы должны быть в самой базе: в kb/products/ — строка «Артикул: …» (MUG-NORTH, TEA-SENCHA, LAMP-LIGHT, LAMP-LIGHT-MINI), в promo.md — промокоды (TEA10, TEA15), в delivery.md — службы доставки и номера зон, в orders.md — статусы заказа (paid, shipped, delivered). Чего нет — дописываем и перезапускаем ingest.py.

  • BM25. rag/hybrid.py: tokenize() с поддержкой артикулов с дефисами, промокодов с цифрами, ё → е (стемминг — по желанию, замеряем, помогает ли); build_bm25(category=None, include_archived=False) по таблице chunks; bm25_search(query, k, category=None, include_archived=False) -> list[int] (id чанков). Офлайн-тест: на 5 фейковых чанках запрос TEA15 находит чанк с TEA15 первым, а не с TEA10; запрос LAMP-LIGHT ставит страницу «Свет» выше страницы «Свет мини».

  • RRF. rrf(rankings, k=60, top=50). Тест на рукописном примере из теории (A, B, C).

  • Гибрид. hybrid_search(question, k): для каждого запроса из plan_query (гл. 5.1) — векторная выдача и BM25-выдача (BM25 — по переписанным запросам и по исходному вопросу), всё в RRF; фильтр по категории и исключение архива применены к обеим выдачам.

  • Реранкер. rag/rerank.py: модель грузится один раз; rerank(question, candidate_ids, top=5). Подаём реранкеру исходный вопрос покупателя или переписанный запрос — пробуем оба на 5 вопросах и выбираем.

  • (Опционально) Contextual chunking. Скрипт contextualize.py: для каждого чанка LLM пишет 1–2 предложения контекста по всей странице, дописывает их к тексту, переиндексирует в отдельный index_ctx.db. Основной вариант — DeepSeek: страница в неизменном префиксе, чанк в конце, рассуждение выключено. Логируем prompt_cache_hit_tokens/prompt_cache_miss_tokens и считаем долю попаданий. Сначала прогоняем 20 страниц, считаем фактическую стоимость и только потом запускаем на всём. Офлайн-вариант — Ollama (сначала замеряем скорость). «Для сравнения» — Claude с явным cache_control.

  • (Опционально) LLM-реранкер. rerank_llm(question, candidate_ids) на DeepSeek (JSON со списком номеров, валидация, fallback на порядок RRF). Добавляем строку в таблицу eval и сравниваем с cross-encoder’ом по MRR, латентности и стоимости.

  • Eval. eval/run_retrieval_eval.py считает для каждого варианта hit@3, hit@5, hit@20, MRR, латентность p50:

    Вариант hit@3 hit@5 hit@20 MRR p50, мс
    вектор /20 /20 /20
    гибрид (вектор + BM25, RRF)
    гибрид + реранкер
    (опц.) + contextual
  • Разбор по типам вопросов: отдельно считаем hit@3 на 4 вопросах с точными идентификаторами («действует ли TEA15 на кофе?», «какая гарантия на LAMP-LIGHT-MINI?», «что значит статус shipped?», «сколько стоит „Быстрокурьер“ в зону 3 для посылки 2 кг?») — там BM25 должен дать основной прирост.

  • Решение: в eval/RESULTS.md — какую конфигурацию берём в ответный пайплайн гл. 5.3 и почему (качество vs латентность).

Ожидаемый результат: гибрид поднимает вопросы с идентификаторами, реранкер — hit@3 и MRR при почти том же hit@20. Если реранкер ухудшил — проверяем язык модели и обрезку длинных чанков.

  • В eval/RESULTS.md таблица с цифрами по трём вариантам: вектор / гибрид / гибрид + реранкер (hit@3, hit@5, MRR, латентность) на одном golden set из 20 вопросов.
  • Есть отдельная цифра по вопросам с точными идентификаторами и выбранная конфигурация с обоснованием.
  • Токенизатор и RRF покрыты офлайн-тестами; при include_archived=False архивные страницы не попадают ни в векторную, ни в BM25-выдачу.

Вопрос 1

Почему векторный поиск плохо находит TEA15 или LAMP-LIGHT-MINI?

Эмбеддинг кодирует общий смысл: похожие идентификаторы (TEA10 и TEA15, LAMP-LIGHT и LAMP-LIGHT-MINI) для него почти одинаковы, а редкие токены модель могла не видеть при обучении. Точное совпадение строки эмбеддинг не гарантирует.

Вопрос 2

Три составляющие BM25 — назвать и объяснить одной фразой каждую.

TF с насыщением — чем чаще слово в документе, тем выше, но с убывающей отдачей; IDF — редкие в коллекции слова весят больше; нормализация длины — длинный документ не выигрывает за счёт объёма.

Вопрос 3

Почему нельзя просто сложить косинус и скор BM25?

Шкалы несравнимы и зависят от запроса: BM25 может быть 0–30, косинус 0.3–0.9; сумма фактически равна одной из выдач. RRF использует ранги, а не значения.

Вопрос 4

Чему равен RRF (k=60) для чанка, который на 2-м месте в векторной выдаче и отсутствует в BM25-выдаче?

  • a) 1/2
  • b) 1/62
  • c) 2/60
  • d) 1/61 + 1/62

b. 1/(60+2) = 1/62; из отсутствующей выдачи вклада нет.

Вопрос 5

Чем cross-encoder отличается от bi-encoder? Почему cross-encoder нельзя применить ко всей базе?

Bi-encoder кодирует вопрос и документ отдельно, и векторы документов считаются заранее; cross-encoder видит пару целиком и выдаёт релевантность — точнее, но требует прогона модели на каждую пару для каждого вопроса, на всей базе это слишком медленно.

Вопрос 6

Реранкер не изменил hit@20, но поднял hit@3 с 11 до 15. Это хорошо? Почему hit@20 не изменился?

Хорошо: в промпт идут 3–5 кусков, и правильный теперь чаще там. hit@20 не изменился, потому что реранкер только пересортировывает тех же кандидатов, новых не добавляет.

Вопрос 7

Что не так в этом коде?

bm25 = BM25Okapi([c["text"].split() for c in chunks])
top = bm25.get_top_n(question.split(), texts, n=5)

(вопрос: «Промокод TEA15, не сработал на лампу Свет мини, почему?»)

split() по пробелам оставляет пунктуацию и регистр (TEA15,, Промокод, Свет), не разбивает артикулы на части и не знает форм слова; токенизация вопроса и корпуса должна быть одной функцией. Плюс нет фильтра по категории и исключения архива. Нужен общий tokenize() для обеих сторон и фильтры, как в векторном поиске.

Вопрос 8

Что такое Contextual Retrieval и при чём тут prompt caching?

К каждому чанку перед индексацией LLM дописывает короткий контекст по всему документу, и этот текст идёт в эмбеддинги и BM25. Prompt caching делает это дешёвым: весь документ кэшируется один раз, а для каждого чанка платим в основном за сам чанк и короткий ответ.

Вопрос 9

Какой метрикой лучше видна польза реранкера: hit@20 или MRR? Почему?

MRR (и hit@3): реранкер меняет порядок кандидатов, а hit@20 смотрит только, есть ли кусок в двадцатке.

Вопрос 10

Покупатель спрашивает «можно ли вернуть открытую пачку чая?», а в kb/returns.md раздел называется «Возврат вскрытого чая». Почему BM25 здесь почти бесполезен и как это закрывается в нашем пайплайне?

BM25 считает совпадения слов, а «вернуть/возврат» и «открытую/вскрытого» — разные слова (синонимы и формы слова); общее только «чая», а оно встречается во многих чанках про чай. Обход: смысловую часть закрывают эмбеддинги, а переписанные запросы из plan_query (гл. 5.1) приближают формулировку к тексту kb/; BM25 при этом нужен для точных идентификаторов, которые сохраняются и в исходном вопросе, и в переписанных запросах.

Вопрос 11

Правильного куска нет в top-50 гибридной выдачи. Поможет ли более сильный реранкер?

Нет: реранкер видит только кандидатов первого этапа. Чинить надо recall первого этапа — chunking, переписывание, фильтры, BM25, больший k.

Вопрос 12

Где взять реранкер, если генерация на DeepSeek, а эмбеддинги в Ollama?

  • a) эндпоинт /rerank у DeepSeek
  • b) эндпоинт реранкинга у Ollama
  • c) локальный cross-encoder через Sentence Transformers (или LLM-реранкинг как эксперимент)
  • d) реранкер не нужен

c. На ноябрь 2026 эндпоинтов реранкинга нет ни у DeepSeek, ни у Ollama. Cross-encoder запускается локально через Sentence Transformers; LLM-реранкинг через DeepSeek возможен, но дороже, медленнее и недетерминирован.

Вопрос 13

Contextual Retrieval на DeepSeek, промпт собран так: f"<chunk>{chunk}</chunk>\n<document>{page}</document>". В usage почти нет prompt_cache_hit_tokens. Почему и как исправить?

Кэш DeepSeek работает по совпадающему префиксу, а здесь промпт начинается с чанка, который каждый раз разный, поэтому совпадающего префикса нет. Нужно поставить неизменную часть (инструкцию и страницу) в начало, а чанк — в самый конец.

Вопрос 14

Почему перенос кода Contextual Retrieval с Claude на Anthropic-совместимый эндпоинт DeepSeek не сломается, хотя cache_control там не поддерживается?

По таблице совместимости DeepSeek cache_control игнорируется, а не вызывает ошибку, а кэширование префикса у DeepSeek происходит автоматически. Важно лишь, чтобы документ стоял в начале промпта; попадания проверяем по usage.

Поиск готов и измерен. Теперь превращаем найденные куски в ответ, которому можно верить: 5.3 · Проект 2: ответы со ссылками на источник.