5.2 · Гибридный поиск (BM25) и реранкинг
Неделя 5 · RAG · ⏱ ~5 ч · Теория, Практика
Зачем это AI-инженеру
Заголовок раздела «Зачем это AI-инженеру»Векторный поиск хорошо понимает смысл, но плохо находит точные термины: артикулы, промокоды, названия служб доставки, номера зон и тарифов, системные статусы заказа. Для поддержки магазина это критично: вопрос про 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 | Да | Нет |
Они ошибаются на разных вопросах — поэтому их и объединяют.
BM25 за 5 минут
Заголовок раздела «BM25 за 5 минут»Лексический поиск, который стоит рядом с эмбеддингами почти в каждой 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 проще для эксперимента.
Reciprocal Rank Fusion (RRF)
Заголовок раздела «Reciprocal Rank Fusion (RRF)»Теперь на каждый вопрос у нас две выдачи: векторная и 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.
Реранкинг: bi-encoder vs cross-encoder
Заголовок раздела «Реранкинг: bi-encoder vs cross-encoder»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 questionsfrom 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 пар, зависит от размера модели, длины чанков и машины. Измеряем.
Contextual Retrieval: что именно предлагает Anthropic
Заголовок раздела «Contextual Retrieval: что именно предлагает Anthropic»Всё разобранное выше — 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.0DeepSeek и Ollama: как это сделать у нас
Заголовок раздела «DeepSeek и Ollama: как это сделать у нас»BM25 и RRF — чистый Python, провайдер не важен. Различия в двух местах: реранкер и Contextual Retrieval.
Реранкер: только локальный cross-encoder
Заголовок раздела «Реранкер: только локальный cross-encoder»На ноябрь 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 · Переформулировка запроса и метаданные. Это эксперимент, а не основной вариант.
Contextual Retrieval на DeepSeek: кэш работает сам
Заголовок раздела «Contextual Retrieval на DeepSeek: кэш работает сам»В статье 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 lastdef 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).
Маршрут изучения
Заголовок раздела «Маршрут изучения»- Открываем Anthropic: Contextual Retrieval → читаем целиком, обязательно (≈45 мин). Обратим внимание: почему BM25 добавляют к эмбеддингам, как генерируется контекст чанка (промпт в статье), роль prompt caching в стоимости, как устроен реранкинг поверх кандидатов, какие выводы в конце (что комбинировать, сколько чанков отдавать). Сравниваем их метрику с нашей hit@k.
- Открываем rank_bm25 → README (≈10 мин). Выносим:
BM25Okapi, токенизация — наша ответственность,get_scores/get_top_n. - Находим описание BM25 (статья «Okapi BM25» в Википедии подойдёт) → формула и смысл
k1иb(≈15 мин). Формулу не заучиваем — важно понять три составляющие: TF с насыщением, IDF, нормализация длины. - Читаем про Reciprocal Rank Fusion: оригинальная статья Cormack, Clarke, Büttcher, 2009, «Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods» — достаточно абстракта и формулы (≈10 мин).
- Открываем Sentence Transformers → разделы про Cross-Encoder: Usage и Pretrained Models / Retrieve & Re-Rank (≈30 мин). Выносим: разницу bi-encoder / cross-encoder,
CrossEncoder.predict, рекомендуемые модели для реранкинга и их скорость, схему retrieve & re-rank. - (Опционально) Документация SQLite FTS5 на sqlite.org — функция
bm25()и токенайзеры (unicode61,porter), если BM25 будет жить в той же базе (≈15 мин). - Открываем карточку реранкера на Hugging Face (например,
BAAI/bge-reranker-v2-m3в организации BAAI) (≈10 мин). Выносим: языки (есть ли русский), лимит длины входа, как вызывать черезCrossEncoder, лицензию. - Открываем 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-выдаче?
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?
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: ответы со ссылками на источник.