Перейти к содержимому
Ступень 88.2 RAG

Продвинутый RAG и оценка

Как довести RAG от демо до рабочей системы — стратегии чанкинга, BM25 и гибридный поиск, реранкеры, векторные базы и ANN, метрики recall@k и faithfulness, GraphRAG обзорно.

УРОКstage-8/8-2-rag/02-rag-advanced.mdx2 чУрок 2 из 2
После урока вы сможете
  • Выбрать стратегию чанкинга под тип документов и объяснить компромисс размера чанка
  • Реализовать BM25 и слить его выдачу с векторным поиском через RRF
  • Объяснить двухступенчатый поиск «ретривер → реранкер» и разницу биэнкодера и кросс-энкодера
  • Измерить качество RAG — recall@k и MRR для поиска, faithfulness и answer relevance для ответа

Мини-RAG из прошлого урока собирается за вечер и прекрасно работает на демо. В реальной базе он начинает ошибаться: не находит документ с артикулом «E-1042», путает похожие разделы регламента, находит нужный кусок только на 20-м месте или вовсе разрезает ответ пополам границей чанка.

Почти все такие сбои — сбои поиска, а не генерации. Если нужного фрагмента нет в top-k, никакая модель не ответит правильно, и именно здесь она начинает галлюцинировать. Поэтому продвинутый RAG — это в основном продвинутый поиск плюс честное измерение качества. План урока повторяет конвейер:

flowchart LR
Q["Вопрос"] --> RW["Переписать запрос<br/>(по желанию)"]
RW --> B["BM25<br/>top-50"]
RW --> V["Векторы<br/>top-50"]
B --> F["Слияние RRF"]
V --> F
F --> R["Реранкер<br/>(кросс-энкодер)"]
R --> K["top-5"] --> L["LLM + ссылки"]
Типичный продакшен-конвейер: гибридный поиск, слияние, реранкинг

Размер чанка — компромисс. Маленькие чанки (одно-два предложения) дают точное совпадение с вопросом, но теряют контекст: «Он вступает в силу с 1 марта» — кто «он»? Большие (несколько страниц) сохраняют контекст, но их эмбеддинг размыт смесью тем, а в промпт попадает много шума. Хорошая отправная точка — 300–500 токенов с перекрытием 10–20%. Окончательный размер подбирают по метрикам на своих данных.

СтратегияКак режетКогда брать
Фиксированный размер + перекрытиекаждые N токенов, с нахлёстомоднородный текст, быстрый старт
Рекурсивнаясначала по абзацам, если кусок велик — по строкам, предложениям, словамбольшинство текстов; так работает RecursiveCharacterTextSplitter в LangChain (популярная библиотека для LLM-приложений)
По структуре документапо заголовкам Markdown/HTML, функциям в коде, строкам таблицдокументация, вики, код
Семантическаяграница там, где падает сходство эмбеддингов соседних предложенийдлинные тексты без разметки
«Ищем мелко, отдаём крупно»индексируем мелкие куски, в промпт отдаём родительский раздел или окно соседних предложенийкогда нужна и точность поиска, и контекст

Два приёма, которые почти всегда окупаются:

  • Заголовок раздела в каждом чанке. Чанк «Попадания стоят дешевле» без контекста бесполезен, а «[Кэш контекста] Попадания стоят дешевле» понятен и модели эмбеддингов, и LLM.
  • Качественный разбор исходников. Таблица из PDF, превращённая в кашу из чисел, не спасётся никаким чанкингом. На разборе документов (layout, OCR, таблицы) специализируются отдельные инструменты — см. китайский угол.

Рекурсивный чанкер с учётом заголовков Markdown умещается в 40 строк. Для простоты длину здесь меряем в символах, а не в токенах:

import re
SEPARATORS = ["\n\n", "\n", ". ", " "] # от крупных границ к мелким
def split_recursive(text, max_len=200, seps=SEPARATORS):
"""Режет текст на куски не длиннее max_len, стараясь резать по самым крупным границам."""
if len(text) <= max_len:
return [text]
if not seps: # разделители кончились — режем «в лоб»
return [text[i:i + max_len] for i in range(0, len(text), max_len)]
sep, rest = seps[0], seps[1:]
parts = text.split(sep)
parts = [p + sep for p in parts[:-1]] + parts[-1:] # разделитель остаётся в куске
pieces, current = [], ""
for part in parts:
if len(current + part) <= max_len:
current += part # склеиваем мелкие части обратно
continue
if current:
pieces.append(current)
if len(part) > max_len: # часть сама слишком длинная — режем глубже
pieces.extend(split_recursive(part, max_len, rest))
current = ""
else:
current = part
if current:
pieces.append(current)
return [p.strip() for p in pieces if p.strip()]
def chunk_markdown(doc, max_len=200):
"""Сначала режем по заголовкам, потом — рекурсивно; заголовок раздела добавляем в каждый чанк."""
chunks = []
# Шаблон \n(?=# ) — «перевод строки, ЗА которым идёт "# "». Конструкция (?=…) только заглядывает
# вперёд и не «съедает» символы, поэтому строка заголовка остаётся в начале своего раздела.
for section in re.split(r"\n(?=# )", doc.strip()):
# partition("\n") делит строку по первому переводу строки: (до, "\n", после) — заголовок и тело
title, _, body = section.partition("\n")
for piece in split_recursive(body.strip(), max_len):
chunks.append(f"[{title.lstrip('# ')}] {piece}")
return chunks
doc = """# Лимиты API
При превышении лимита запросов сервер возвращает код 429. Подождите и повторите запрос.
# Кэш контекста
Кэш работает по префиксу. Если начало запроса совпало с прошлым, эти токены считаются попаданием в кэш. Попадания стоят дешевле. Поэтому статичную часть промпта ставят в начало, а вопрос — в конец."""
for c in chunk_markdown(doc, max_len=120):
print(f"{len(c):>3} | {c}")
100 | [Лимиты API] При превышении лимита запросов сервер возвращает код 429. Подождите и повторите запрос.
119 | [Кэш контекста] Кэш работает по префиксу. Если начало запроса совпало с прошлым, эти токены считаются попаданием в кэш.
109 | [Кэш контекста] Попадания стоят дешевле. Поэтому статичную часть промпта ставят в начало, а вопрос — в конец.

BM25: лексический поиск, сделанный правильно

Заголовок раздела «BM25: лексический поиск, сделанный правильно»

У TF-IDF два слабых места. Первое — частота: слово, повторённое в документе 50 раз, получает вес в 50 раз больше, хотя документ от этого не становится в 50 раз релевантнее. Второе — длина: длинный документ набирает совпадения просто за счёт объёма. Нормировка в косинусе из прошлого урока борется со вторым, но жёстко и без настройки. BM25BM25EN BM25 (Okapi BM25)Функция ранжирования по ключевым словам — TF-IDF с насыщением по частоте (k1) и нормировкой на длину документа (b). (Робертсон и коллеги, 1990-е) решает обе проблемы явно, с двумя параметрами-«ручками»:

score(D,Q)=∑t∈Qidf(t)⋅f(t,D) (k1+1)f(t,D)+k1(1−b+b ∣D∣avgdl),idf(t)=ln⁡(N−df(t)+0.5df(t)+0.5+1)\begin{gathered} \text{score}(D, Q) = \sum_{t \in Q} \text{idf}(t)\cdot \frac{f(t, D)\,(k_1 + 1)}{f(t, D) + k_1\left(1 - b + b\,\frac{|D|}{\text{avgdl}}\right)}, \\ \text{idf}(t) = \ln\left(\frac{N - \text{df}(t) + 0.5}{\text{df}(t) + 0.5} + 1\right) \end{gathered}
  • f(t,D)f(t, D) — сколько раз термин встречается в документе, ∣D∣|D| — длина документа, avgdl — средняя длина.
  • k1k_1 (обычно 1.2–2.0) — насыщение. Вклад термина растёт с частотой, но упирается в потолок k1+1k_1 + 1.
  • bb (обычно 0.75) — нормировка на длину. При b=0b = 0 длина не учитывается, при b=1b = 1 — полностью.

Как работает насыщение при k1=1.5k_1 = 1.5 (документ средней длины):

Повторов термина ff123510100
Множитель f(k1+1)f+k1\frac{f (k_1 + 1)}{f + k_1}1.001.431.671.922.172.46

Второй повтор добавляет 0.43, десятый — почти ничего (0.03), предел — 2.5.

Теперь длина. Пусть документ вдвое длиннее среднего (∣D∣/avgdl=2|D| / \text{avgdl} = 2), b=0.75b = 0.75. Скобка в знаменателе равна 1−0.75+0.75⋅2=1.751 - 0.75 + 0.75 \cdot 2 = 1.75, то есть k1k_1 «раздувается» до 1.5⋅1.75≈2.631.5 \cdot 1.75 \approx 2.63. Одно вхождение термина даёт множитель 1⋅2.51+2.63≈0.69\frac{1 \cdot 2.5}{1 + 2.63} \approx 0.69 вместо 1.00 у документа средней длины: длинный документ должен содержать слово чаще, чтобы получить тот же вклад. Итоговый score — сумма таких множителей, умноженных на idf, по всем словам запроса.

IDF здесь записан в варианте из библиотеки Lucene, на которой работают Elasticsearch и OpenSearch. Благодаря «+1» под логарифмом он всегда положителен. В классической формуле без «+1» слово, которое встречается больше чем в половине документов, получало бы отрицательный вес и уводило бы score в минус.

BM25 — ранжирование по умолчанию в Elasticsearch и OpenSearch; в Python есть пакет rank_bm25. Для русского языка BM25 заметно выигрывает от стемминга (стеммер Snowball) или лемматизации (pymorphy3): тогда «контексте» и «контекст» станут одним термином. Для китайского нужна сегментация словсегментация слов (в китайском тексте)EN word segmentationВ китайском письме нет пробелов, поэтому границы слов приходится находить алгоритмом. Поэтому по-китайски токенизатор часто так и называют — «сегментатор слов»., например jieba.

BM25 и векторный поиск ошибаются по-разному. BM25 находит точные идентификаторы («Qwen3-235B-A22B», «E-1042»), но не понимает перефразировок. Эмбеддинги понимают смысл и даже другой язык, но могут «размазать» редкий артикул. гибридный поискгибридный поискEN hybrid searchОдновременный лексический (BM25) и векторный поиск с последующим слиянием выдач, например через RRF. запускает оба поиска и сливает выдачи.

Проблема слияния: шкалы несравнимы. Косинус лежит в [−1,1][-1, 1], BM25 — от нуля до десятков. Самое простое и устойчивое решение — слияние по обратным рангамслияние по обратным рангам (RRF)EN reciprocal rank fusion (RRF)score(d) = Σ 1 / (k + rank_i(d)), обычно k = 60. Использует только ранги, поэтому не требует согласовывать шкалы оценок. (Reciprocal Rank Fusion; Cormack, Clarke, Büttcher, 2009). Оно смотрит только на места в выдачах:

RRF(d)=∑i1k+ranki(d),k=60\text{RRF}(d) = \sum_{i} \frac{1}{k + \text{rank}_i(d)}, \qquad k = 60

Документ, который стоит высоко в обоих списках, получает больше всего. Константа k=60k = 60 сглаживает разницу между первым и, скажем, пятым местом, чтобы один список не задавил другой.

def rrf(rankings, k=60):
"""Reciprocal Rank Fusion: score(d) = Σ 1 / (k + rank), ранги с единицы."""
scores = {}
for ranking in rankings:
for rank, doc in enumerate(ranking, start=1):
scores[doc] = scores.get(doc, 0.0) + 1.0 / (k + rank)
return sorted(scores, key=lambda d: (-scores[d], d)), scores
bm25 = ["d3", "d1", "d7", "d2"] # нашёл точное «E-1042», но не понял перефразировку
dense = ["d1", "d5", "d3", "d8"] # понял смысл, но d3 — только третьим
fused, s = rrf([bm25, dense])
print(fused[:4])
print({d: round(s[d], 4) for d in fused[:4]})
['d1', 'd3', 'd5', 'd7']
{'d1': 0.0325, 'd3': 0.0323, 'd5': 0.0161, 'd7': 0.0159}

d1 и d3 нашлись в обоих списках и обошли документы, которые нашёл только один поиск. Альтернатива RRF — взвешенная сумма α⋅dense+(1−α)⋅sparse\alpha\cdot\text{dense} + (1-\alpha)\cdot\text{sparse} после нормировки оценок в [0,1][0, 1]. Она гибче (например, в поиске по товарам можно поднять вес ключевых слов), но α\alpha нужно подбирать.

биэнкодербиэнкодерEN bi-encoderЗапрос и документ кодируются независимо, сходство — скалярное произведение векторов. Векторы документов считаются заранее, поэтому поиск быстрый. быстр, потому что кодирует запрос и документ по отдельности. Но модель ни разу не видит их вместе и не может сопоставить слова вопроса со словами документа через внимание. кросс-энкодеркросс-энкодерEN cross-encoderМодель читает пару «запрос + документ» целиком и выдаёт оценку релевантности. Точнее биэнкодера, но заранее ничего не посчитать — применяется как реранкер. подаёт на вход пару целиком ([вопрос] [SEP] [документ], где [SEP] — специальный токенспециальный токен (спецтокен)EN special tokenУправляющий токен, а не кусок текста — начало/конец текста (BOS/EOS), заполнитель для батча (PAD), разметка ролей в чате (например, <|im_start|> у Qwen).-разделитель) и выдаёт одно число — релевантность. Это заметно точнее, но заранее ничего не посчитать: на каждый запрос нужен отдельный проход модели по каждому кандидату.

Отсюда двухступенчатая схема: быстрый гибридный поиск выбирает 50–100 кандидатов из миллиона, реранкерреранкерEN rerankerВторая ступень поиска — точная, но медленная модель (обычно кросс-энкодер) пересортировывает десятки кандидатов от быстрого ретривера. пересортировывает только их, и в промпт уходят лучшие 3–5.

Биэнкодер (ретривер)Кросс-энкодер (реранкер)
Входзапрос и документ отдельнопара «запрос + документ»
Векторы документов заранееданет
Проходов модели на запрос1 (только вопрос)по одному на кандидата
Точностьхорошаявыше
Где применятьвся базаtop-50…100 кандидатов

Открытые реранкеры: BAAI/bge-reranker-v2-m3 (лёгкий мультиязычный кросс-энкодер) и Qwen3-Reranker на 0.6B, 4B и 8B. Промежуточный вариант — late interaction в стиле ColBERT: вектор для каждого токена и сходство по лучшим совпадениям токенов. Его умеет выдавать BGE-M3.

# Код требует скачать модель (~2 ГБ), в среде курса не запускался.
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")
question = "Почему API возвращает 429?"
candidates = ["...", "...", "..."] # top-50 после гибридного поиска
scores = reranker.predict([(question, c) for c in candidates])
top5 = [c for _, c in sorted(zip(scores, candidates), reverse=True)[:5]]

Полный перебор (E @ q) честно находит лучших соседей и прекрасно работает до нескольких миллионов векторов. Дальше нужен приближённый поиск ближайших соседейприближённый поиск ближайших соседей (ANN)EN approximate nearest neighbor search (ANN)Поиск похожих векторов без полного перебора (HNSW, IVF, PQ) ценой небольшой потери полноты. — приближённый поиск, который проверяет лишь малую часть базы и иногда пропускает настоящего соседа. Этот компромисс тоже меряют полнотой: какая доля «настоящих» top-10 найдена индексом.

  • HNSWHNSW (иерархический граф «малого мира»)EN Hierarchical Navigable Small World (HNSW)Один из самых распространённых ANN-индексов в векторных БД — многослойный граф, по которому поиск спускается от редких верхних слоёв к плотным нижним; предложен Ю. Мальковым и Д. Яшуниным (arXiv:1603.09320). — многослойный граф. Верхние слои редкие (длинные «перелёты»), нижние плотные. Поиск начинается сверху и жадно идёт к соседу, который ближе к запросу, спускаясь слой за слоем — как поездка: сначала самолётом до нужного региона, потом электричкой до города, потом пешком до дома. Параметры: M (сколько соседей у узла) и ef (ширина поиска: больше — точнее и медленнее). HNSW предложили российские исследователи Ю. Мальков и Д. Яшунин; сегодня это один из самых распространённых индексов в векторных БД.
  • IVF — векторы заранее делят на кластеры алгоритмом k-means (урок 2.3.2); при запросе обходят только nprobe ближайших кластеров.
  • PQ (product quantization) — сжатие векторов. Вектор из 1024 чисел float32 занимает 4096 байт; разбив его на 64 части и закодировав каждую одним байтом, получаем 64 байта — в 64 раза меньше ценой небольшой потери точности. Это родственник квантования весов из урока 7.2.1.

Что выбрать:

ИнструментЧто этоКогда
NumPy / FAISS (Meta)библиотека внутри вашей программы, без отдельного серверапрототип, до миллионов векторов, офлайн-эксперименты
Chromaвстраиваемая БД (хранит данные в файле рядом с программой)локальные проекты и демо
pgvectorрасширение PostgreSQL — популярной базы данных с языком запросов SQLу вас уже есть Postgres; нужны SQL-фильтры и транзакции
Qdrant, Milvusотдельные серверы векторного поискабольшие объёмы, фильтры по метаданным, масштабирование
Elasticsearch / OpenSearchпоисковый движок с BM25 и векторамигибридный поиск «из коробки»

Векторная базавекторная база данныхEN vector database отличается от «просто индекса» тем, что хранит метаданные и умеет фильтровать («только документы отдела продаж за 2026 год»), обновлять и удалять записи, разграничивать доступ.

Иногда виноват сам вопрос.

  • Переписывание с учётом диалога. «А сколько у неё параметров?» без истории не найти ничего. Дешёвый вызов LLM превращает его в «Сколько параметров у DeepSeek-V3?».
  • Несколько формулировок (multi-query). LLM пишет 3–5 вариантов вопроса, поиск идёт по каждому, выдачи сливают через RRF.
  • HyDE (Gao и др., 2022): попросить LLM написать гипотетический ответ и искать по его эмбеддингу. Ответ похож на документ больше, чем короткий вопрос.

Без чисел улучшения RAG превращаются в гадание: «кажется, стало лучше». Оценивают две части отдельно, потому что чинятся они по-разному.

Нужны 50–200 вопросов с разметкой: какие чанки (или документы) релевантны — это qrels, и, по возможности, эталонные ответы. Источники: реальные вопросы пользователей; вопросы, которые LLM сгенерировала по случайным чанкам (их обязательно просматривает человек); вопросы без ответа в базе, чтобы проверить, умеет ли система говорить «не знаю».

Пусть RqR_q — множество релевантных документов для вопроса qq, а topk(q)\text{top}_k(q) — первые kk результатов.

recall@k=∣Rq∩topk(q)∣∣Rq∣,MRR=1∣Q∣∑q1rank первого релевантного\begin{gathered} \text{recall@}k = \frac{\lvert R_q \cap \text{top}_k(q) \rvert}{\lvert R_q \rvert}, \\ \text{MRR} = \frac{1}{\lvert Q \rvert}\sum_{q} \frac{1}{\text{rank первого релевантного}} \end{gathered}

полнотаполнота (recall)EN recallTP / (TP + FN). Recall — буквально «отозвать, вернуть»: та же метрика recall@k в поиске и RAG. на уровне kk (recall@k) отвечает на главный для RAG вопрос: попал ли нужный кусок в то, что увидит модель? Реранкер может поменять порядок, но не вернёт то, что ретривер не нашёл. MRR оценивает порядок: насколько высоко первый релевантный результат. Ещё встречаются precision@k (доля релевантных среди top-k) и nDCG, который учитывает степень релевантности.

def recall_at_k(runs, qrels, k):
"""Средний recall@k: для каждого вопроса — доля его релевантных документов в первых k, затем среднее.
runs — {вопрос: ранжированный список документов}, qrels — {вопрос: множество релевантных}."""
return sum(len(set(runs[q][:k]) & qrels[q]) / len(qrels[q]) for q in runs) / len(runs)
def mrr(runs, qrels):
"""Mean Reciprocal Rank: среднее 1 / (позиция первого релевантного), 0 — если не нашёлся."""
total = 0.0
for q, ranking in runs.items():
for pos, doc in enumerate(ranking, start=1):
if doc in qrels[q]:
total += 1 / pos
break
return total / len(runs)
runs = {"q1": ["d3", "d1", "d7"], "q2": ["d5", "d2", "d9"], "q3": ["d4", "d6", "d8"]}
qrels = {"q1": {"d1"}, "q2": {"d5", "d9"}, "q3": {"d2"}}
print(f"recall@1 = {recall_at_k(runs, qrels, 1):.3f}, recall@3 = {recall_at_k(runs, qrels, 3):.3f}, "
f"MRR = {mrr(runs, qrels):.3f}")
recall@1 = 0.167, recall@3 = 0.667, MRR = 0.500

Проверьте вручную: в q2 два релевантных документа, и на первом месте найден только один — recall@1 = 0.5. Для q3 релевантный документ не найден вовсе — вклад 0 и в recall, и в MRR.

Для генерации эталонных ID нет, поэтому судьёй обычно выступает LLM (о её смещениях — урок 7.3.2). Три вопроса так называемой RAG-триады:

  1. Релевантность контекста — относится ли найденное к вопросу? Это оценка поиска без разметки.
  2. верность контекстуверность контексту (faithfulness)EN faithfulness / groundednessДоля утверждений ответа, которые подтверждаются найденным контекстом. Низкая faithfulness — признак галлюцинаций генератора. (groundedness) — подтверждается ли каждое утверждение ответа контекстом? Ответ разбивают на атомарные утверждения, судья проверяет каждое. Если из 4 утверждений контекст подтверждает 3, faithfulness = 0.75. Низкое значение — галлюцинации генератора.
  3. Релевантность ответа — отвечает ли он на заданный вопрос, а не на соседний?

Эти метрики автоматизируют библиотеки RAGAS, TruLens и модули оценки LlamaIndex.

Есть вопросы, на которые top-k кусков не ответят в принципе: «Какие главные темы в этих 500 отчётах?» или «Как связаны компания А и проект Б?» — ответ размазан по сотням мест. GraphRAGGraphRAG (RAG на графе знаний)EN GraphRAGRAG, в котором LLM заранее извлекает из корпуса сущности и связи в граф и строит сводки по его сообществам — для глобальных и многошаговых вопросов. от Microsoft (Edge и др., 2024) заранее, на этапе индексации:

  1. прогоняет LLM по всему корпусу и извлекает сущности и связи — граф знаний;
  2. делит граф на сообщества — группы тесно связанных сущностей (алгоритм Leiden) — и пишет сводку для каждого;
  3. на глобальный вопрос отвечает по схеме map-reduce: частичные ответы по сводкам сообществ, затем их объединение.

Цена — дорогая индексация: LLM читает весь корпус. LightRAG (2024) упрощает схему: граф плюс векторы, двухуровневый поиск (конкретные сущности и широкие темы) и инкрементальное обновление без перестройки графа. Начинайте с гибридного поиска и реранкера, а к графам переходите, когда eval-набор показывает, что проваливаются именно глобальные и многошаговые вопросы.

Реализуйте BM25 с нуля и посчитайте recall@k на маленьком eval-наборе из пяти вопросов. Один из них («…при большом контексте») BM25 не найдёт: в документе стоит «контекст», а в вопросе — «контексте». Это готовый пример того, где помогают стемминг или гибридный поиск.

BM25 с нуля и recall@k7 тестов
import math
import re
from collections import Counter

DOCS = [
    "Ошибка E-1042 возникает, если истёк срок действия API-ключа.",
    "Чтобы сбросить пароль, откройте настройки профиля и нажмите «Забыли пароль».",
    "Кэшированные входные токены стоят в несколько раз дешевле обычных.",
    "Стриминг отдаёт ответ по частям через Server-Sent Events.",
    "При превышении лимита запросов API возвращает код 429.",
    "API-ключ можно перевыпустить в личном кабинете.",
    "Длинный контекст стоит дороже: вы платите за каждый входной токен.",
    "Код 429: подождите и повторите запрос с экспоненциальной задержкой.",
]

# Вопросы и номера релевантных документов (qrels) — маленький eval-набор
QUERIES = {
    "q1": "что значит ошибка E-1042",
    "q2": "получили код 429 что делать",
    "q3": "как перевыпустить API-ключ",
    "q4": "сколько стоят кэшированные токены",
    "q5": "почему запрос дорогой при большом контексте",
}
QRELS = {"q1": {0}, "q2": {4, 7}, "q3": {5}, "q4": {2}, "q5": {6}}


def tokenize(text):
    """Нижний регистр + слова из букв и цифр. Уже готово."""
    return re.findall(r"\w+", text.lower())


def bm25_scores(query, docs_tokens, k1=1.5, b=0.75):
    """BM25-score каждого документа для запроса. Возвращает список длины len(docs_tokens).

    query — список токенов; повторяющиеся термины запроса учитывайте один раз.
    N — число документов, avgdl — средняя длина документа в токенах,
    df(t) — в скольких документах есть t, f — сколько раз t встречается в документе D.

        idf(t)   = ln((N - df + 0.5) / (df + 0.5) + 1)
        score(D) = Σ по t из запроса:  idf(t) · f · (k1 + 1) / (f + k1 · (1 - b + b · |D| / avgdl))

    Термины, которых нет ни в одном документе, ничего не добавляют.
    """
    # TODO: ваш код
    ...


def top_k(scores, k):
    """Индексы k лучших документов: по убыванию score, при равенстве — меньший индекс."""
    # TODO: ваш код
    ...


def recall_at_k(retrieved, relevant, k):
    """Доля релевантных документов (relevant — множество), попавших в первые k позиций retrieved."""
    # TODO: ваш код
    ...


def mean_recall_at_k(runs, qrels, k):
    """Средний recall@k по всем запросам из qrels.

    runs  — {qid: ранжированный список индексов документов}
    qrels — {qid: множество релевантных индексов}
    """
    # TODO: ваш код
    ...


# Проверьте себя кнопкой «Запустить»:
docs_tokens = [tokenize(d) for d in DOCS]
print(bm25_scores(tokenize("код 429"), docs_tokens))

Когда тесты пройдены, попробуйте сами: добавьте к tokenize грубый стемминг — обрезайте слова длиннее 6 букв до первых 6 ([w[:6] for w in …]). «Контексте» и «контекст» станут одним термином, q5 найдётся вторым, и средний recall@2 вырастет с 0.8 до 1.0. Посчитать его можно так:

docs_tokens = [tokenize(d) for d in DOCS]
runs = {q: top_k(bm25_scores(tokenize(t), docs_tokens), len(DOCS)) for q, t in QUERIES.items()}
print(mean_recall_at_k(runs, QRELS, 2))

Последний тест рассчитан на исходный tokenize, поэтому после эксперимента он упадёт — это ожидаемо.

Проверь себя6 вопросов
1 В BM25 с k1=1.5k_1 = 1.5 к какому пределу стремится множитель f(k1+1)f+k1\frac{f(k_1+1)}{f + k_1} при f→∞f \to \infty (документ средней длины)?
2 Документ стоит 1-м в выдаче BM25 и 3-м в векторной. Его RRF-score при k=60k = 60? Округлите до 4 знаков.
3 Релевантные документы — {2,7}\{2, 7\}, выдача — [7,4,9,2,1][7, 4, 9, 2, 1]. Чему равен recall@3?
4 Почему кросс-энкодер используют только для реранкинга top-50, а не для поиска по всей базе?
5 Какие наблюдения указывают на проблему именно в поиске, а не в генерации?Можно выбрать несколько ответов
6 В каком случае GraphRAG оправдан больше всего?
  • Большинство сбоев RAG — сбои поиска; сначала меряйте recall@k, потом всё остальное.
  • Чанкинг — компромисс точности и контекста: режьте по структуре, добавляйте заголовок раздела в чанк, подбирайте размер по метрикам.
  • BM25 = TF-IDF + насыщение (k1k_1) + нормировка на длину (bb). Вместе с векторами и слиянием RRF он даёт гибридный поиск, который ловит и точные термины, и смысл.
  • Двухступенчатая схема: быстрый ретривер находит 50–100 кандидатов, кросс-энкодер-реранкер оставляет лучшие 3–5.
  • Оценивайте поиск (recall@k, MRR) и генерацию (faithfulness, релевантность ответа) отдельно; GraphRAG — для глобальных вопросов, а не по умолчанию.

Как всё это складывается в продукт — ассистент поддержки с гибридным поиском, реранкером, цитатами, золотым набором и мониторингом — в разборе из урока 11.2.2.

Дальше: модуль 8.3 — агенты. Поиск станет одним из инструментов, которые модель вызывает сама, решая, когда и что искать.