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

Claude Cookbooks: RAG — базовый, с индексом сводок и с реранкингом

Полный перевод рецепта «Retrieval Augmented Generation» из Claude Cookbooks: RAG по документации Claude, отдельная оценка поиска (precision, recall, F1, MRR) и сквозной точности ответа LLM-судьёй, два улучшения — индекс со сводками чанков и реранкинг моделью — с замерами 71 % → 79 % → 81 %.

Рецепт из Claude Cookbooks (лицензия MIT). Он показывает весь путь RAG-системы: собрать простейший вариант, научиться измерять его по частям и улучшить двумя приёмами. Ценность рецепта — не в коде поиска (он элементарный), а в дисциплине оценки: каждое улучшение подтверждено числами на 100 вопросах.

  • Когда. После урока 8.2.1 «Retrieval-Augmented Generation» — там конвейер RAG, эмбеддинги и косинусная близость собираются руками. Метрики поиска и реранкинг подробно разобраны в уроке 8.2.2 «Продвинутый RAG и оценка»; LLM-судья — в уроке 7.3.2.
  • Что нужно. Ключи Anthropic и Voyage AI (эмбеддинги). Без ключей — см. врезку «Запуск на своей машине» ниже.
  • Сколько стоит. Полный прогон — это 100 вопросов × (ответ + оценка) на каждый уровень, плюс генерация сводок для всех чанков и реранкинг. Авторы предупреждают про лимиты API; для учёбы берите первые 20 вопросов.
  • Что важно вынести. (1) Поиск и ответ оцениваются отдельно; (2) MRR показывает, насколько высоко стоит первый правильный чанк; (3) реранкинг почти не меняет полноту, но сильно поднимает MRR и итоговую точность.

Claude отлично справляется с широким кругом задач, но может буксовать на вопросах, специфичных для вашего бизнеса. Здесь и выручает генерация, дополненная поиском (Retrieval Augmented Generation, RAG). RAG позволяет Claude опираться на ваши внутренние базы знаний или документы поддержки, и это заметно улучшает ответы на вопросы из конкретной предметной области. Компании всё чаще строят RAG-приложения для поддержки клиентов, ответов на вопросы по внутренним документам, финансового и юридического анализа и многого другого.

В этом руководстве мы покажем, как построить и оптимизировать RAG-систему, взяв в качестве базы знаний документацию Claude. Мы пройдём:

  1. Настройку базовой RAG-системы на векторной базе в памяти и эмбеддингах от Voyage AI.
  2. Построение надёжного набора эвалов. Мы пойдём дальше оценок «на глаз» и покажем, как измерять качество поиска и сквозное качество независимо друг от друга.
  3. Продвинутые приёмы улучшения RAG: индекс со сводками и реранкинг с помощью Claude.

Серией точечных улучшений мы добились заметного роста по сравнению с базовым конвейером RAG по таким метрикам (что они означают, объясним чуть позже):

  • средняя точность (precision): 0,43 → 0,44;
  • средняя полнота (recall): 0,66 → 0,69;
  • средняя F1-мера: 0,52 → 0,54;
  • средний обратный ранг (Mean Reciprocal Rank, MRR): 0,74 → 0,87;
  • сквозная точность (end-to-end accuracy): 71 % → 81 %.

Эвалы в этом рецепте задуманы как отражение продакшен-системы оценки, и прогон может занять заметное время. Кроме того, при полном прогоне вы можете упереться в лимиты запросов — в зависимости от вашего уровня использования. Если экономите токены, пропустите полный сквозной эвал.

  1. Подготовка
  2. Уровень 1 — базовый RAG
  3. Система оценки
  4. Уровень 2 — индекс со сводками
  5. Уровень 3 — индекс со сводками и реранкинг

Понадобятся библиотеки:

  1. anthropic — для работы с Claude;
  2. voyageai — для качественных эмбеддингов;
  3. pandas, numpy, matplotlib и scikit-learn — для работы с данными и визуализации.

Также нужны ключи API от Anthropic и Voyage AI.

## Подготовка
%pip install anthropic
%pip install voyageai
%pip install pandas
%pip install numpy
%pip install matplotlib
%pip install seaborn
%pip install -U scikit-learn
import os
os.environ["VOYAGE_API_KEY"] = "VOYAGE KEY HERE" # сюда — ключ Voyage AI
os.environ["ANTHROPIC_API_KEY"] = "ANTHROPIC KEY HERE" # сюда — ключ Anthropic
import os
import anthropic
client = anthropic.Anthropic(
# Это значение по умолчанию, строку можно опустить
api_key=os.getenv("ANTHROPIC_API_KEY"),
)

В этом примере используется векторная база в памяти; для продакшен-приложения, возможно, стоит взять размещённое (облачное) решение.

import json
import os
import pickle
import numpy as np
import voyageai
class VectorDB:
def __init__(self, name, api_key=None):
if api_key is None:
api_key = os.getenv("VOYAGE_API_KEY")
self.client = voyageai.Client(api_key=api_key)
self.name = name
self.embeddings = [] # векторы чанков
self.metadata = [] # сами чанки (ссылка, заголовок, текст)
self.query_cache = {} # кэш эмбеддингов запросов, чтобы не платить дважды
self.db_path = f"./data/{name}/vector_db.pkl"
def load_data(self, data):
if self.embeddings and self.metadata:
print("Vector database is already loaded. Skipping data loading.")
return
if os.path.exists(self.db_path): # база уже посчитана — читаем с диска
print("Loading vector database from disk.")
self.load_db()
return
# Для эмбеддинга склеиваем заголовок чанка и его текст
texts = [f"Heading: {item['chunk_heading']}\n\n Chunk Text:{item['text']}" for item in data]
self._embed_and_store(texts, data)
self.save_db()
print("Vector database loaded and saved.")
def _embed_and_store(self, texts, data):
batch_size = 128 # API принимает не больше 128 текстов за раз
result = [
self.client.embed(texts[i : i + batch_size], model="voyage-2").embeddings
for i in range(0, len(texts), batch_size)
]
self.embeddings = [embedding for batch in result for embedding in batch] # «расплющиваем» батчи
self.metadata = data
def search(self, query, k=5, similarity_threshold=0.75):
if query in self.query_cache:
query_embedding = self.query_cache[query]
else:
query_embedding = self.client.embed([query], model="voyage-2").embeddings[0]
self.query_cache[query] = query_embedding
if not self.embeddings:
raise ValueError("No data loaded in the vector database.")
# Векторы Voyage нормированы, поэтому скалярное произведение = косинусная близость
similarities = np.dot(self.embeddings, query_embedding)
top_indices = np.argsort(similarities)[::-1] # индексы по убыванию близости
top_examples = []
for idx in top_indices:
if similarities[idx] >= similarity_threshold: # отсекаем слишком далёкие чанки
example = {
"metadata": self.metadata[idx],
"similarity": similarities[idx],
}
top_examples.append(example)
if len(top_examples) >= k:
break
self.save_db()
return top_examples
def save_db(self):
data = {
"embeddings": self.embeddings,
"metadata": self.metadata,
"query_cache": json.dumps(self.query_cache),
}
os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
with open(self.db_path, "wb") as file:
pickle.dump(data, file)
def load_db(self):
if not os.path.exists(self.db_path):
raise ValueError(
"Vector database file not found. Use load_data to create a new database."
)
with open(self.db_path, "rb") as file:
data = pickle.load(file)
self.embeddings = data["embeddings"]
self.metadata = data["metadata"]
self.query_cache = json.loads(data["query_cache"])

Для начала соберём базовый конвейер RAG по-простому. В индустрии его часто называют «наивным RAG» (Naive RAG). Базовый конвейер — три шага:

  1. Нарезать документы на чанки по заголовкам — каждый чанк содержит текст одного подраздела.
  2. Посчитать эмбеддинг каждого чанка.
  3. По косинусной близости найти чанки для ответа на запрос.
flowchart LR
D["Документация"] --> C["Чанки по подзаголовкам"] --> E["Эмбеддинги voyage-2"] --> V[("VectorDB в памяти")]
Q["Вопрос"] --> QE["Эмбеддинг вопроса"] --> S["Топ-3 по близости, порог 0.75"]
V --> S --> P["Промпт: вопрос + документы"] --> M["claude-haiku-4-5"] --> A["Ответ"]
Уровень 1: наивный RAG
import json
import logging
import xml.etree.ElementTree as ET # noqa: S314
from collections.abc import Callable
from typing import Any
import matplotlib.pyplot as plt
from tqdm import tqdm
# Загружаем набор для оценки
with open("evaluation/docs_evaluation_dataset.json") as f:
eval_data = json.load(f)
# Загружаем документацию Claude (уже нарезанную на чанки)
with open("data/anthropic_docs.json") as f:
anthropic_docs = json.load(f)
# Создаём векторную базу
db = VectorDB("anthropic_docs")
db.load_data(anthropic_docs)
def retrieve_base(query, db):
results = db.search(query, k=3) # три самых близких чанка
context = ""
for result in results:
chunk = result["metadata"]
context += f"\n{chunk['text']}\n" # склеиваем их тексты в контекст
return results, context
def answer_query_base(query, db):
documents, context = retrieve_base(query, db)
prompt = f"""
You have been tasked with helping us to answer the following query:
<query>
{query}
</query>
You have access to the following documents which are meant to provide context as you answer the query:
<documents>
{context}
</documents>
Please remain faithful to the underlying context, and only deviate from it if you are 100% sure that you know the answer already.
Answer the question now, and avoid providing preamble such as 'Here is the answer', etc
"""
response = client.messages.create(
model="claude-haiku-4-5",
max_tokens=2500,
messages=[{"role": "user", "content": prompt}],
temperature=0, # для воспроизводимости эвала
)
return response.content[0].text

Перевод промпта. Тебе поручено помочь нам ответить на запрос: <query>…</query>. У тебя есть документы, которые должны дать контекст для ответа: <documents>…</documents>. Пожалуйста, придерживайся этого контекста и отступай от него, только если на 100 % уверен, что знаешь ответ сам. Ответь сейчас, без вступлений вроде «Вот ответ».

Оценивая RAG-приложение, критически важно оценивать поиск и систему целиком по отдельности.

Мы синтетически сгенерировали набор для оценки из 100 примеров. В каждом:

  • вопрос;
  • чанки документации, относящиеся к вопросу, — это то, что поиск должен найти по этому вопросу;
  • правильный ответ на вопрос.

Набор довольно трудный. Для ответа на некоторые вопросы нужно свести воедино больше одного чанка, поэтому важно, чтобы система умела подгружать несколько чанков сразу. Набор можно посмотреть в файле evaluation/docs_evaluation_dataset.json.

Запустите следующую ячейку, чтобы увидеть начало набора:

# Смотрим на начало набора для оценки
import json
def preview_json(file_path, num_items=3):
try:
with open(file_path) as file:
data = json.load(file)
if isinstance(data, list):
preview_data = data[:num_items]
elif isinstance(data, dict):
preview_data = dict(list(data.items())[:num_items])
else:
print(f"Unexpected data type: {type(data)}. Cannot preview.")
return
print(f"Preview of the first {num_items} items from {file_path}:")
print(json.dumps(preview_data, indent=2))
print(f"\nTotal number of items: {len(data)}")
except FileNotFoundError:
print(f"File not found: {file_path}")
except json.JSONDecodeError:
print(f"Invalid JSON in file: {file_path}")
except Exception as e:
print(f"An error occurred: {str(e)}")
preview_json("evaluation/docs_evaluation_dataset.json")

Конец вывода — третий пример набора:

"id": "1811c10d",
"question": "What are some key success metrics to consider when evaluating Claude's performance on a classification task, and how do they relate to choosing the right model to reduce latency?",
"correct_chunks": [
"https://docs.claude.com/en/docs/about-claude/use-cases/classification#evaluation-metrics",
"https://docs.claude.com/en/docs/test-and-evaluate/strengthen-guardrails/reduce-latency#1-choose-the-right-model"
],
"correct_answer": "When evaluating Claude's performance on a classification task, some key success metrics to consider include accuracy, F1 score, consistency, structure, speed, bias and fairness. ..."
}
]
Total number of items: 100

(Вопрос: «Какие ключевые метрики успеха учитывать при оценке Claude на задаче классификации и как они связаны с выбором модели для снижения задержки?» Правильные чанки — два раздела из разных страниц документации; правильный ответ перечисляет точность, F1, согласованность, структуру, скорость, предвзятость и справедливость и говорит, что выбор подходящей модели — простой способ снизить задержку. Это как раз вопрос, где нужно свести два чанка.)

Будем оценивать систему по пяти ключевым метрикам: точность (precision), полнота (recall), F1-мера, средний обратный ранг (MRR) и сквозная точность (end-to-end accuracy).

Точность — доля найденных чанков, которые действительно релевантны. Она отвечает на вопрос: «Сколько из найденных нами чанков были правильными?»

Главное:

  • высокая точность означает эффективную систему с малым числом ложных срабатываний;
  • низкая точность говорит, что находится много нерелевантных чанков;
  • наша система находит минимум 3 чанка на запрос, и это может влиять на точность.

Формула:

Precision=True PositivesTotal Retrieved=∣Retrieved∩Correct∣∣Retrieved∣\text{Precision} = \frac{\text{True Positives}}{\text{Total Retrieved}} = \frac{|\text{Retrieved} \cap \text{Correct}|}{|\text{Retrieved}|}

Словами: число верно найденных чанков (пересечение найденных и правильных) делим на число всех найденных. Пример переводчика: нашли 3 чанка, правильный среди них 1 → точность 1/3≈0,331/3 \approx 0{,}33.

Полнота измеряет, насколько полно система находит нужное. Она отвечает на вопрос: «Сколько из всех существующих правильных чанков нам удалось найти?»

Главное:

  • высокая полнота означает, что вся нужная информация покрыта;
  • низкая полнота говорит, что важные чанки пропускаются;
  • полнота критична, чтобы у LLM была вся необходимая информация.

Формула:

Recall=True PositivesTotal Correct=∣Retrieved∩Correct∣∣Correct∣\text{Recall} = \frac{\text{True Positives}}{\text{Total Correct}} = \frac{|\text{Retrieved} \cap \text{Correct}|}{|\text{Correct}|}

Словами: число верно найденных чанков делим на число всех правильных. Пример: правильных чанков 2, нашли из них 1 → полнота 1/2=0,51/2 = 0{,}5.

F1-мера — сбалансированная оценка, объединяющая точность и полноту. Она особенно полезна, когда нужна одна цифра для оценки системы, в том числе при неравномерном распределении классов.

Главное:

  • F1 — от 0 до 1; 1 означает идеальные точность и полноту;
  • это среднее гармоническое точности и полноты: оно тяготеет к меньшему из двух значений;
  • полезна, когда важны и ложные срабатывания, и пропуски.

Формула:

F1 Score=2×Precision×RecallPrecision+Recall\text{F1 Score} = 2 \times \frac{\text{Precision} \times \text{Recall}}{\text{Precision} + \text{Recall}}

Пример: при точности 0,33 и полноте 0,5 получаем 2⋅0,33⋅0,5/(0,33+0,5)≈0,402 \cdot 0{,}33 \cdot 0{,}5 / (0{,}33 + 0{,}5) \approx 0{,}40 — ближе к меньшему значению, чем обычное среднее 0,415.

Как читать F1:

  • 1,0 — идеальные точность и полнота;
  • 0,0 — худший результат;
  • в целом чем выше F1, тем лучше система.
  • Между точностью и полнотой часто приходится выбирать.
  • Минимальное число находимых чанков в нашей системе склоняет баланс в пользу полноты.
  • Оптимальный баланс зависит от задачи.
  • Во многих RAG-системах приоритет у полноты: LLM способна сама отфильтровать менее релевантное при генерации.

MRR показывает, насколько хорошо система ранжирует релевантную информацию: как быстро пользователь нашёл бы нужное, просматривая результаты сверху вниз.

Главное:

  • MRR — от 0 до 1; 1 — идеал (правильный чанк всегда первый);
  • учитывается только позиция первого правильного результата для каждого запроса;
  • чем выше MRR, тем лучше ранжирование.

Формула:

MRR=1∣Q∣∑i=1∣Q∣1ranki\text{MRR} = \frac{1}{|Q|} \sum_{i=1}^{|Q|} \frac{1}{\text{rank}_i}

где ∣Q∣|Q| — общее число запросов, а ranki\text{rank}_i — позиция первого релевантного результата для ii-го запроса. Пример: три запроса, первый правильный чанк стоит на местах 1, 3 и 2 → (1+1/3+1/2)/3≈0,61(1 + 1/3 + 1/2)/3 \approx 0{,}61.

Чтобы решить, верен ли сгенерированный ответ, мы используем LLM-судью (LLM-as-judge): модель сравнивает ответ с вопросом и эталонным ответом. (В тексте оригинала судья — Claude 3.5 Sonnet; в текущем коде — claude-sonnet-4-6.)

Формула:

End to End Accuracy=Number of Correct AnswersTotal Number of Questions\text{End to End Accuracy} = \frac{\text{Number of Correct Answers}}{\text{Total Number of Questions}}

Словами: доля вопросов, на которые итоговый ответ признан верным. Эта метрика оценивает весь конвейер — от поиска до генерации ответа.

def calculate_mrr(retrieved_links: list[str], correct_links: set[str]) -> float:
for i, link in enumerate(retrieved_links, 1): # позиции считаем с 1
if link in correct_links:
return 1 / i # обратный ранг первого правильного
return 0 # правильных среди найденных нет
def evaluate_retrieval(
retrieval_function: Callable, evaluation_data: list[dict[str, Any]], db: Any
) -> tuple[float, float, float, float, list[float], list[float], list[float]]:
precisions = []
recalls = []
mrrs = []
for i, item in enumerate(tqdm(evaluation_data, desc="Evaluating Retrieval")):
try:
retrieved_chunks, _ = retrieval_function(item["question"], db)
# Чанк опознаём по ссылке на раздел документации
retrieved_links = [
chunk["metadata"].get("chunk_link", chunk["metadata"].get("url", ""))
for chunk in retrieved_chunks
]
except Exception as e:
logging.error(f"Error in retrieval function: {e}")
continue
correct_links = set(item["correct_chunks"])
true_positives = len(set(retrieved_links) & correct_links)
precision = true_positives / len(retrieved_links) if retrieved_links else 0
recall = true_positives / len(correct_links) if correct_links else 0
mrr = calculate_mrr(retrieved_links, correct_links)
precisions.append(precision)
recalls.append(recall)
mrrs.append(mrr)
if (i + 1) % 10 == 0: # каждые 10 вопросов печатаем промежуточные средние
print(
f"Processed {i + 1}/{len(evaluation_data)} items. Current Avg Precision: {sum(precisions) / len(precisions):.4f}, Avg Recall: {sum(recalls) / len(recalls):.4f}, Avg MRR: {sum(mrrs) / len(mrrs):.4f}"
)
avg_precision = sum(precisions) / len(precisions) if precisions else 0
avg_recall = sum(recalls) / len(recalls) if recalls else 0
avg_mrr = sum(mrrs) / len(mrrs) if mrrs else 0
# F1 считается по средним точности и полноте
f1 = (
2 * (avg_precision * avg_recall) / (avg_precision + avg_recall)
if (avg_precision + avg_recall) > 0
else 0
)
return avg_precision, avg_recall, avg_mrr, f1, precisions, recalls, mrrs
def evaluate_end_to_end(answer_query_function, db, eval_data):
correct_answers = 0
results = []
total_questions = len(eval_data)
for i, item in enumerate(tqdm(eval_data, desc="Evaluating End-to-End")):
query = item["question"]
correct_answer = item["correct_answer"]
generated_answer = answer_query_function(query, db) # ответ нашей RAG-системы
prompt = f"""
You are an AI assistant tasked with evaluating the correctness of answers to questions about Anthropic's documentation.
Question: {query}
Correct Answer: {correct_answer}
Generated Answer: {generated_answer}
Is the Generated Answer correct based on the Correct Answer? You should pay attention to the substance of the answer, and ignore minute details that may differ.
Small differences or changes in wording don't matter. If the generated answer and correct answer are saying essentially the same thing then that generated answer should be marked correct.
However, if there is any critical piece of information which is missing from the generated answer in comparison to the correct answer, then we should mark this as incorrect.
Finally, if there are any direct contradictions between the correect answer and generated answer, we should deem the generated answer to be incorrect.
Respond in the following XML format:
<evaluation>
<content>
<explanation>Your explanation here</explanation>
<is_correct>true/false</is_correct>
</content>
</evaluation>
"""
try:
response = client.messages.create(
model="claude-sonnet-4-6", # судья — модель сильнее отвечающей
max_tokens=1500,
messages=[
{"role": "user", "content": prompt},
{"role": "assistant", "content": "<evaluation>"}, # prefill: ответ сразу начнётся с XML
],
temperature=0,
stop_sequences=["</evaluation>"], # останавливаемся на закрывающем теге
)
response_text = response.content[0].text
print(response_text)
# Разбираем XML из ответа доверенной модели
evaluation = ET.fromstring(response_text) # noqa: S314
is_correct = evaluation.find("is_correct").text.lower() == "true"
if is_correct:
correct_answers += 1
results.append(is_correct)
logging.info(f"Question {i + 1}/{total_questions}: {query}")
logging.info(f"Correct: {is_correct}")
logging.info("---")
except ET.ParseError as e:
logging.error(f"XML parsing error: {e}")
is_correct = "true" in response_text.lower() # запасной вариант, если XML битый
results.append(is_correct)
except Exception as e:
logging.error(f"Unexpected error: {e}")
results.append(False)
if (i + 1) % 10 == 0:
current_accuracy = correct_answers / (i + 1)
print(
f"Processed {i + 1}/{total_questions} questions. Current Accuracy: {current_accuracy:.4f}"
)
# time.sleep(2)
accuracy = correct_answers / total_questions
return accuracy, results

Перевод промпта судьи. Ты — ИИ-ассистент, который оценивает правильность ответов на вопросы о документации Anthropic. Вопрос: … Правильный ответ: … Сгенерированный ответ: … Верен ли сгенерированный ответ с учётом правильного? Обращай внимание на суть ответа и не обращай внимания на мелкие расхождения в деталях. Небольшие различия в формулировках не важны: если оба ответа по сути говорят одно и то же, сгенерированный ответ считается верным. Но если в сгенерированном ответе нет какой-то критически важной информации из правильного — отметь его как неверный. Наконец, если ответы прямо противоречат друг другу, сгенерированный ответ неверен. Ответь в формате XML: <evaluation><content><explanation>объяснение</explanation><is_correct>true/false</is_correct></content></evaluation>.

import json
import os
import matplotlib.pyplot as plt
import seaborn as sns
def plot_performance(results_folder="evaluation/json_results", include_methods=None, colors=None):
# Цвета по умолчанию
default_colors = ["skyblue", "lightgreen", "salmon"]
if colors is None:
colors = default_colors
# Загружаем JSON-файлы с результатами
results = []
for filename in os.listdir(results_folder):
if filename.endswith(".json"):
file_path = os.path.join(results_folder, filename)
with open(file_path) as f:
try:
data = json.load(f)
if "name" not in data:
print(f"Warning: {filename} does not contain a 'name' field. Skipping.")
continue
if include_methods is None or data["name"] in include_methods:
results.append(data)
except json.JSONDecodeError:
print(f"Warning: {filename} is not a valid JSON file. Skipping.")
if not results:
print("No JSON files found with matching 'name' fields.")
return
# Проверяем, что все метрики на месте
required_metrics = [
"average_precision",
"average_recall",
"average_f1",
"average_mrr",
"end_to_end_accuracy",
]
for result in results.copy():
if not all(metric in result for metric in required_metrics):
print(f"Warning: {result['name']} is missing some required metrics. Skipping.")
results.remove(result)
if not results:
print("No valid results remaining after validation.")
return
# Сортируем методы по сквозной точности
results.sort(key=lambda x: x["end_to_end_accuracy"])
# Готовим данные для графика
methods = [result["name"] for result in results]
metrics = required_metrics
# Настраиваем график
plt.figure(figsize=(14, 6))
sns.set_style("whitegrid")
x = range(len(metrics))
width = 0.8 / len(results)
# Палитра
num_methods = len(methods)
color_palette = colors[:num_methods] + sns.color_palette("husl", num_methods - len(colors))
# Столбцы для каждого метода
for i, (result, color) in enumerate(zip(results, color_palette, strict=False)):
values = [result[metric] for metric in metrics]
offset = (i - len(results) / 2 + 0.5) * width
bars = plt.bar([xi + offset for xi in x], values, width, label=result["name"], color=color)
# Подписи значений над столбцами
for bar in bars:
height = bar.get_height()
plt.text(
bar.get_x() + bar.get_width() / 2.0,
height,
f"{height:.2f}",
ha="center",
va="bottom",
fontsize=8,
)
# Оформление
plt.xlabel("Metrics", fontsize=12)
plt.ylabel("Values", fontsize=12)
plt.title("RAG Performance Metrics (Sorted by End-to-End Accuracy)", fontsize=16)
plt.xticks(x, metrics, rotation=45, ha="right")
plt.legend(title="Methods", bbox_to_anchor=(1.05, 1), loc="upper left")
plt.ylim(0, 1)
plt.tight_layout()
plt.show()
import pandas as pd
avg_precision, avg_recall, avg_mrr, f1, precisions, recalls, mrrs = evaluate_retrieval(
retrieve_base, eval_data, db
)
e2e_accuracy, e2e_results = evaluate_end_to_end(answer_query_base, db, eval_data)
# Таблица с результатами по каждому вопросу
df = pd.DataFrame(
{
"question": [item["question"] for item in eval_data],
"retrieval_precision": precisions,
"retrieval_recall": recalls,
"retrieval_mrr": mrrs,
"e2e_correct": e2e_results,
}
)
# Сохраняем в CSV
df.to_csv("evaluation/csvs/evaluation_results_detailed.csv", index=False)
print("Detailed results saved to evaluation/csvs/evaluation_results_one.csv")
# Печатаем итоги
print(f"Average Precision: {avg_precision:.4f}")
print(f"Average Recall: {avg_recall:.4f}")
print(f"Average MRR: {avg_mrr:.4f}")
print(f"Average F1: {f1:.4f}")
print(f"End-to-End Accuracy: {e2e_accuracy:.4f}")
# Сохраняем итоги в файл (по ним строится график)
with open("evaluation/json_results/evaluation_results_one.json", "w") as f:
json.dump(
{
"name": "Basic RAG",
"average_precision": avg_precision,
"average_recall": avg_recall,
"average_f1": f1,
"average_mrr": avg_mrr,
"end_to_end_accuracy": e2e_accuracy,
},
f,
indent=2,
)
print(
"Evaluation complete. Results saved to evaluation_results_one.json, evaluation_results_one.csv"
)

Конец вывода (перед ним — 100 оценок судьи в XML; в последней судья объясняет, что сгенерированный ответ верен: он описывает те же два способа задать ключ API, что и эталон, — передать его при создании клиента или через переменную окружения ANTHROPIC_API_KEY):

Processed 100/100 questions. Current Accuracy: 0.7100
Average Precision: 0.4283
Average Recall: 0.6592
Average MRR: 0.7367
Average F1: 0.5193
End-to-End Accuracy: 0.7100
# Визуализируем результат
plot_performance("evaluation/json_results", ["Basic RAG"], colors=["skyblue"])

(График: пять столбцов для «Basic RAG» — 0,43, 0,66, 0,52, 0,74, 0,71.)

Уровень 2 — сводки документов для лучшего поиска

Заголовок раздела «Уровень 2 — сводки документов для лучшего поиска»

В этом разделе улучшим поиск с помощью сводок документов. Вместо того чтобы считать эмбеддинги прямо по чанкам, мы напишем для каждого чанка краткую сводку и будем использовать её вместе с исходным текстом при расчёте эмбеддинга.

Идея в том, чтобы эмбеддинг точнее схватывал суть каждого чанка — это может улучшить поиск.

Шаги:

  1. Загружаем исходные чанки документов.
  2. Для каждого чанка Claude пишет сводку в 2–3 предложения.
  3. Сохраняем исходный текст и сводку каждого чанка в новый JSON-файл data/anthropic_summary_indexed_docs.json.

Такой подход с обогащёнными сводками даёт больше контекста при расчёте эмбеддингов и поиске и потенциально помогает системе лучше сопоставлять документы с запросами пользователей.

import json
from tqdm import tqdm
def generate_summaries(input_file, output_file):
# Загружаем исходные документы
with open(input_file) as f:
docs = json.load(f)
# Контекст о базе знаний в целом
knowledge_base_context = "This is documentation for Anthropic's, a frontier AI lab building Claude, an LLM that excels at a variety of general purpose tasks. These docs contain model details and documentation on Anthropic's APIs."
summarized_docs = []
for doc in tqdm(docs, desc="Generating summaries"):
prompt = f"""
You are tasked with creating a short summary of the following content from Anthropic's documentation.
Context about the knowledge base:
{knowledge_base_context}
Content to summarize:
Heading: {doc["chunk_heading"]}
{doc["text"]}
Please provide a brief summary of the above content in 2-3 sentences. The summary should capture the key points and be concise. We will be using it as a key part of our search pipeline when answering user queries about this content.
Avoid using any preamble whatsoever in your response. Statements such as 'here is the summary' or 'the summary is as follows' are prohibited. You should get straight into the summary itself and be concise. Every word matters.
"""
response = client.messages.create(
model="claude-haiku-4-5",
max_tokens=150, # сводка короткая
messages=[{"role": "user", "content": prompt}],
temperature=0,
)
summary = response.content[0].text.strip()
summarized_doc = {
"chunk_link": doc["chunk_link"],
"chunk_heading": doc["chunk_heading"],
"text": doc["text"],
"summary": summary,
}
summarized_docs.append(summarized_doc)
# Сохраняем документы со сводками в новый JSON-файл
with open(output_file, "w") as f:
json.dump(summarized_docs, f, indent=2)
print(f"Summaries generated and saved to {output_file}")
# generate_summaries('data/anthropic_docs.json', 'data/anthropic_summary_indexed_docs.json')

Перевод. Контекст базы знаний: «Это документация Anthropic — передовой лаборатории ИИ, создающей Claude, большую языковую модель, которая хорошо справляется с широким кругом задач. Здесь описаны модели и API Anthropic». Промпт: «Тебе нужно написать короткую сводку следующего фрагмента документации Anthropic. Контекст о базе знаний: … Фрагмент: заголовок и текст. Дай краткую сводку в 2–3 предложения, передающую главное. Мы будем использовать её как ключевую часть поиска при ответах на вопросы пользователей. Никаких вступлений: фразы вроде „вот сводка“ запрещены. Сразу переходи к сути и будь краток — важно каждое слово».

Вызов generate_summaries закомментирован: готовый файл со сводками уже лежит в репозитории.

Теперь создаём новую векторную базу на обогащённых сводками чанках. Заголовок чанка, исходный текст и новая сводка склеиваются в один текст, по которому считается эмбеддинг.

Особенности:

  1. Эмбеддинги считаются через Voyage AI по объединённому тексту (заголовок + сводка + исходное содержимое).
  2. Эмбеддинги и все метаданные (включая сводки) хранятся в векторной базе.
  3. Есть кэширование для повторных запросов.
  4. База сохраняется на диск, чтобы быстро загружаться в следующих сессиях.

Такой индекс со сводками призван давать более информативные эмбеддинги — а значит, более точный и уместный поиск.

import json
import os
import pickle
import numpy as np
import voyageai
class SummaryIndexedVectorDB:
def __init__(self, name, api_key=None):
if api_key is None:
api_key = os.getenv("VOYAGE_API_KEY")
self.client = voyageai.Client(api_key=api_key)
self.name = name
self.embeddings = []
self.metadata = []
self.query_cache = {}
self.db_path = f"./data/{name}/summary_indexed_vector_db.pkl"
def load_data(self, data_file):
# База уже в памяти?
if self.embeddings and self.metadata:
print("Vector database is already loaded. Skipping data loading.")
return
# Есть ли сохранённая база на диске?
if os.path.exists(self.db_path):
print("Loading vector database from disk.")
self.load_db()
return
with open(data_file) as f:
data = json.load(f)
texts = [
f"{item['chunk_heading']}\n\n{item['text']}\n\n{item['summary']}" for item in data
] # эмбеддинг считаем по заголовку + тексту + сводке вместе
# Больше 128 документов — считаем батчами в цикле
batch_size = 128
result = [
self.client.embed(texts[i : i + batch_size], model="voyage-2").embeddings
for i in range(0, len(texts), batch_size)
]
# «Расплющиваем» список батчей
self.embeddings = [embedding for batch in result for embedding in batch]
self.metadata = data # храним весь элемент как метаданные
self.save_db()
# Сохраняем базу на диск
print("Vector database loaded and saved.")
def search(self, query, k=3, similarity_threshold=0.75):
query_embedding = None
if query in self.query_cache:
query_embedding = self.query_cache[query]
else:
query_embedding = self.client.embed([query], model="voyage-2").embeddings[0]
self.query_cache[query] = query_embedding
if not self.embeddings:
raise ValueError("No data loaded in the vector database.")
similarities = np.dot(self.embeddings, query_embedding)
top_indices = np.argsort(similarities)[::-1]
top_examples = []
for idx in top_indices:
if similarities[idx] >= similarity_threshold:
example = {
"metadata": self.metadata[idx],
"similarity": similarities[idx],
}
top_examples.append(example)
if len(top_examples) >= k:
break
self.save_db()
return top_examples
def save_db(self):
data = {
"embeddings": self.embeddings,
"metadata": self.metadata,
"query_cache": json.dumps(self.query_cache),
}
# Создаём папку, если её нет
os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
with open(self.db_path, "wb") as file:
pickle.dump(data, file)
def load_db(self):
if not os.path.exists(self.db_path):
raise ValueError(
"Vector database file not found. Use load_data to create a new database."
)
with open(self.db_path, "rb") as file:
data = pickle.load(file)
self.embeddings = data["embeddings"]
self.metadata = data["metadata"]
self.query_cache = json.loads(data["query_cache"])

Улучшенный поиск по эмбеддингам с индексом сводок

Заголовок раздела «Улучшенный поиск по эмбеддингам с индексом сводок»

Здесь реализуем поиск по новой базе с индексом сводок: эмбеддинги учитывают сводку вместе с исходным текстом.

Что меняется в поиске:

  1. Ищем по эмбеддингу запроса и берём kk самых близких документов.
  2. Для каждого найденного документа в контекст для LLM попадают заголовок, сводка и полный текст.
  3. По этому обогащённому контексту генерируется ответ.

Используя сводки и при расчёте эмбеддингов, и при поиске, мы стремимся дать LLM более полный и сфокусированный контекст: у неё есть и краткий обзор (сводка), и подробности (полный текст) каждого чанка.

def retrieve_level_two(query, db):
results = db.search(query, k=3)
context = ""
for result in results:
chunk = result["metadata"]
context += f"\n <document> \n {chunk['chunk_heading']}\n\nText\n {chunk['text']} \n\nSummary: \n {chunk['summary']} \n </document> \n" # показываем модели все три части
return results, context
def answer_query_level_two(query, db):
documents, context = retrieve_base(query, db)
prompt = f"""
You have been tasked with helping us to answer the following query:
<query>
{query}
</query>
You have access to the following documents which are meant to provide context as you answer the query:
<documents>
{context}
</documents>
Please remain faithful to the underlying context, and only deviate from it if you are 100% sure that you know the answer already.
Answer the question now, and avoid providing preamble such as 'Here is the answer', etc
"""
response = client.messages.create(
model="claude-haiku-4-5",
max_tokens=2500,
messages=[{"role": "user", "content": prompt}],
temperature=0,
)
return response.content[0].text
# Создаём базу с индексом сводок
level_two_db = SummaryIndexedVectorDB("anthropic_docs_v2")
level_two_db.load_data("data/anthropic_summary_indexed_docs.json")
import pandas as pd
# Запускаем эвалы
avg_precision, avg_recall, avg_mrr, f1, precisions, recalls, mrrs = evaluate_retrieval(
retrieve_level_two, eval_data, level_two_db
)
e2e_accuracy, e2e_results = evaluate_end_to_end(answer_query_level_two, level_two_db, eval_data)
# Таблица по вопросам
df = pd.DataFrame(
{
"question": [item["question"] for item in eval_data],
"retrieval_precision": precisions,
"retrieval_recall": recalls,
"retrieval_mrr": mrrs,
"e2e_correct": e2e_results,
}
)
# Сохраняем в CSV
df.to_csv("evaluation/csvs/evaluation_results_detailed_level_two.csv", index=False)
print("Detailed results saved to evaluation_results_detailed.csv")
# Итоги
print(f"Average Precision: {avg_precision:.4f}")
print(f"Average Recall: {avg_recall:.4f}")
print(f"Average MRR: {avg_mrr:.4f}")
print(f"Average F1: {f1:.4f}")
print(f"End-to-End Accuracy: {e2e_accuracy:.4f}")
# Сохраняем итоги
with open("evaluation/json_results/evaluation_results_level_two.json", "w") as f:
json.dump(
{
"name": "Summary Indexing",
"average_precision": avg_precision,
"average_recall": avg_recall,
"average_f1": f1,
"average_mrr": avg_mrr,
"end_to_end_accuracy": e2e_accuracy,
},
f,
indent=2,
)
print(
"Evaluation complete. Results saved to evaluation_results_level_two.json, evaluation_results_detailed_level_two.csv"
)

Итог прогона:

Processed 100/100 questions. Current Accuracy: 0.7900
Average Precision: 0.4533
Average Recall: 0.7142
Average MRR: 0.7733
Average F1: 0.5546
End-to-End Accuracy: 0.7900
# Визуализируем результат
plot_performance("evaluation/json_results", ["Basic RAG", "Summary Indexing"])

(График: по всем пяти метрикам «Summary Indexing» выше базового варианта.)

Последнее улучшение поиска — шаг реранкинга (переранжирования), который ещё повышает релевантность найденных документов. Здесь используется способность Claude лучше понимать контекст и нюансы запроса и документов.

Функция rerank_results просит Claude заново оценить и упорядочить найденные документы:

  1. Показывает Claude запрос и сводки всех найденных документов.
  2. Просит выбрать и упорядочить самые релевантные.
  3. Разбирает ответ Claude и получает индексы документов в новом порядке.
  4. На случай ошибок или недостатка результатов есть запасные механизмы.
  5. Наконец, присваивает отобранным документам убывающие оценки релевантности.

Функция retrieve_advanced реализует новый конвейер поиска:

  1. Сначала берём из векторной базы больше документов, чем нужно (по умолчанию 20, параметр initial_k).
  2. Функцией rerank_results сужаем этот набор до самых релевантных (по умолчанию 3, параметр k).
  3. Собираем из отобранных документов новую строку контекста.

Так мы сначала забрасываем широкую сеть, а потом с помощью ИИ фокусируемся на самом важном. Сочетание векторного поиска и реранкинга моделью должно давать более точные и уместные ответы.

flowchart LR
Q["Вопрос"] --> V["Векторный поиск: 20 кандидатов"]
V --> R["claude-haiku-4-5 читает 20 сводок и выбирает 3 индекса"]
R --> C["Контекст: 3 документа"] --> A["Ответ claude-haiku-4-5"]
Уровень 3: широкий поиск, затем реранкинг моделью

Наши эвалы показывают заметные улучшения:

  • точность ответа выросла с 78 % у предыдущей системы до 85 %;
  • точность поиска (precision) выросла, потому что реранкер сокращает число документов, показываемых LLM;
  • MRR, вероятно, вырос, потому что мы просим Claude упорядочить документы по релевантности.

(Примечание переводчика: числа 78 % и 85 % в этом абзаце остались от предыдущей версии рецепта; в текущем прогоне, приведённом ниже, — 79 % и 81 %, а precision уровня 3 (0,437) даже чуть ниже, чем у уровня 2 (0,453).)

Эти улучшения показывают пользу реранкинга с помощью ИИ в конвейере поиска.

def rerank_results(query: str, results: list[dict], k: int = 5) -> list[dict]:
# Готовим сводки с индексами
summaries = []
print(len(results))
for i, result in enumerate(results):
summary = f"[{i}] Document Summary: {result['metadata']['summary']}"
summaries.append(summary)
joined_summaries = "\n\n".join(summaries)
prompt = f"""
Query: {query}
You are about to be given a group of documents, each preceded by its index number in square brackets. Your task is to select the only {k} most relevant documents from the list to help us answer the query.
<documents>
{joined_summaries}
</documents>
Output only the indices of {k} most relevant documents in order of relevance, separated by commas, enclosed in XML tags here:
<relevant_indices>put the numbers of your indices here, seeparted by commas</relevant_indices>
"""
try:
response = client.messages.create(
model="claude-haiku-4-5",
max_tokens=50,
messages=[
{"role": "user", "content": prompt},
{"role": "assistant", "content": "<relevant_indices>"}, # prefill
],
temperature=0,
stop_sequences=["</relevant_indices>"],
)
# Достаём индексы из ответа, например "4, 0, 11"
response_text = response.content[0].text.strip()
indices_str = response_text
relevant_indices = []
for idx in indices_str.split(","):
try:
relevant_indices.append(int(idx.strip()))
except ValueError:
continue # пропускаем то, что не число
print(indices_str)
print(relevant_indices)
# Если годных индексов нет — берём первые k в исходном порядке
if len(relevant_indices) == 0:
relevant_indices = list(range(min(k, len(results))))
# Убираем индексы за пределами списка
relevant_indices = [idx for idx in relevant_indices if idx < len(results)]
# Возвращаем документы в новом порядке
reranked_results = [results[idx] for idx in relevant_indices[:k]]
# Присваиваем убывающие оценки релевантности
for i, result in enumerate(reranked_results):
result["relevance_score"] = (
100 - i
) # у первого 100, дальше на 1 меньше за каждую позицию
return reranked_results
except Exception as e:
print(f"An error occurred during reranking: {str(e)}")
# Запасной вариант: первые k без реранкинга
return results[:k]
def retrieve_advanced(
query: str, db: SummaryIndexedVectorDB, k: int = 3, initial_k: int = 20
) -> tuple[list[dict], str]:
# Шаг 1: первичный поиск с запасом
initial_results = db.search(query, k=initial_k)
# Шаг 2: реранкинг
reranked_results = rerank_results(query, initial_results, k=k)
# Шаг 3: новый контекст из отобранных документов
new_context = ""
for result in reranked_results:
chunk = result["metadata"]
new_context += (
f"\n <document> \n {chunk['chunk_heading']}\n\n{chunk['text']} \n </document> \n"
)
return reranked_results, new_context
# answer_query_advanced по сути не меняется
def answer_query_advanced(query: str, db: SummaryIndexedVectorDB):
documents, context = retrieve_advanced(query, db)
prompt = f"""
You have been tasked with helping us to answer the following query:
<query>
{query}
</query>
You have access to the following documents which are meant to provide context as you answer the query:
<documents>
{context}
</documents>
Please remain faithful to the underlying context, and only deviate from it if you are 100% sure that you know the answer already.
Answer the question now, and avoid providing preamble such as 'Here is the answer', etc
"""
response = client.messages.create(
model="claude-haiku-4-5",
max_tokens=2500,
messages=[{"role": "user", "content": prompt}],
temperature=0,
)
return response.content[0].text

Перевод промпта реранкера. Запрос: … Сейчас ты получишь группу документов, перед каждым — его номер в квадратных скобках. Твоя задача — выбрать из списка только {k} самых релевантных документов, которые помогут ответить на запрос. <documents>…</documents>. Выведи только номера {k} самых релевантных документов в порядке релевантности, через запятую, внутри XML-тегов <relevant_indices>номера через запятую</relevant_indices>.

# Создаём базу с индексом сводок
level_three_db = SummaryIndexedVectorDB("anthropic_docs_v3")
level_three_db.load_data("data/anthropic_summary_indexed_docs.json")
import pandas as pd
# Запускаем эвалы
avg_precision, avg_recall, avg_mrr, f1, precisions, recalls, mrrs = evaluate_retrieval(
retrieve_advanced, eval_data, level_three_db
)
e2e_accuracy, e2e_results = evaluate_end_to_end(answer_query_advanced, level_two_db, eval_data)
# Таблица по вопросам
df = pd.DataFrame(
{
"question": [item["question"] for item in eval_data],
"retrieval_precision": precisions,
"retrieval_recall": recalls,
"retrieval_mrr": mrrs,
"e2e_correct": e2e_results,
}
)
# Сохраняем в CSV
df.to_csv("evaluation/csvs/evaluation_results_detailed_level_three.csv", index=False)
print("Detailed results saved to evaluation_results_detailed_level_three.csv")
# Итоги
print(f"Average Precision: {avg_precision:.4f}")
print(f"Average Recall: {avg_recall:.4f}")
print(f"Average F1: {f1:.4f}")
print(f"Average Mean Reciprocal Rank: {avg_mrr:4f}")
print(f"End-to-End Accuracy: {e2e_accuracy:.4f}")
# Сохраняем итоги
with open("evaluation/json_results/evaluation_results_level_three.json", "w") as f:
json.dump(
{
"name": "Summary Indexing + Re-Ranking",
"average_precision": avg_precision,
"average_recall": avg_recall,
"average_f1": f1,
"average_mrr": avg_mrr,
"end_to_end_accuracy": e2e_accuracy,
},
f,
indent=2,
)
print(
"Evaluation complete. Results saved to evaluation_results_level_three.json, evaluation_results_detailed_level_three.csv, and evaluation_results_level_three.png"
)

Итог прогона:

Processed 100/100 questions. Current Accuracy: 0.8100
Average Precision: 0.4367
Average Recall: 0.6933
Average F1: 0.5359
Average Mean Reciprocal Rank: 0.865000
End-to-End Accuracy: 0.8100
# Визуализируем результат
plot_performance(
"evaluation/json_results",
["Basic RAG", "Summary Indexing", "Summary Indexing + Re-Ranking"],
colors=["skyblue", "lightgreen", "salmon"],
)

Сводная таблица трёх уровней (по выводам выше; таблица — от переводчика):

УровеньPrecisionRecallF1MRRСквозная точность
1. Базовый RAG0,4280,6590,5190,73771 %
2. Индекс со сводками0,4530,7140,5550,77379 %
3. Сводки + реранкинг0,4370,6930,5360,86581 %

Что видно из таблицы: реранкинг почти не меняет точность и полноту поиска (он выбирает те же 3 документа из расширенного списка), зато правильный документ гораздо чаще оказывается первым (MRR 0,77 → 0,87), и итоговый ответ становится лучше. Разница 79 % → 81 % на 100 вопросах — это два вопроса; насколько она надёжна, помогает понять парный бутстрэп из урока 7.3.2.

Этот рецепт показывает, как важно измерять качество промптов эмпирически. Ноутбук Jupyter — отличное начало, но когда наборов данных и промптов становится больше, нужны инструменты, которые растут вместе с вами.

В этом разделе — Promptfoo, открытый набор инструментов для оценки LLM. Чтобы начать, перейдите в папку ./evaluation и прочитайте ./evaluation/README.md. (Сам инструмент подробно разобран во второй части курса Anthropic по эвалам.)

Promptfoo позволяет легко собирать автоматические наборы тестов, сравнивающие между собой модели, гиперпараметры и промпты.

Например, следующая ячейка показывает среднее качество Haiku и 3.5 Sonnet по всем тест-кейсам:

import json
import numpy as np
import pandas as pd
# Загружаем результаты прогона promptfoo
with open("data/end_to_end_results.json") as f:
data = json.load(f)
# Достаём результаты
results = data["results"]["results"]
# Таблица
df = pd.DataFrame(results)
# Достаём провайдера (модель), промпт и оценку
df["provider"] = df["provider"].apply(lambda x: x["label"] if isinstance(x, dict) else x)
df["prompt"] = df["prompt"].apply(lambda x: x["label"] if isinstance(x, dict) else x)
# Аккуратно извлекаем оценку
def extract_score(x):
if isinstance(x, dict) and "score" in x:
return x["score"] * 100 # переводим в проценты
return np.nan
df["score"] = df["gradingResult"].apply(extract_score)
# Средние оценки по провайдеру и промпту
result = df.groupby(["provider", "prompt"])["score"].mean().unstack()
# Пропуски заменяем нулями
result = result.fillna(0)
# Средняя по всем промптам для каждого провайдера
result["Average"] = result.mean(axis=1)
# Сортируем по средней
result = result.sort_values(by="Average", ascending=False)
# Округляем до 2 знаков
result = result.round(2)
# Общая статистика
overall_average = result["Average"].mean()
overall_std = result["Average"].std()
best_provider = result["Average"].idxmax()
worst_provider = result["Average"].idxmin()
print("\nOverall Statistics:")
print(f"Best Performing Provider: {best_provider} ({result.loc[best_provider, 'Average']:.2f}%)")
print(f"Worst Performing Provider: {worst_provider} ({result.loc[worst_provider, 'Average']:.2f}%)")
Overall Statistics:
Best Performing Provider: 3.5 Sonnet: T-0.0 (85.00%)
Worst Performing Provider: Haiku: T-0.0 (78.00%)

(Лучший провайдер — 3.5 Sonnet при температуре 0: 85 %; худший — Haiku при температуре 0: 78 %. Это сохранённые результаты старого прогона с моделями 2024 года.)

  1. Прогоните уровни 1 и 3 на первых 20 вопросах (eval_data[:20]) и сравните MRR. Совпадает ли направление улучшения с полным прогоном?
  2. Исправьте опечатку retrieve_base → retrieve_level_two в answer_query_level_two и посмотрите, меняется ли сквозная точность.
  3. Замените реранкер-модель на кросс-энкодер (урок 8.2.2, раздел о реранкерах) и сравните цену и MRR.
  4. Добавьте поверх эмбеддингов BM25 и гибридный поиск — так сделано в статье «Contextual Retrieval».
ENRU
chunkчанк, фрагмент документа
embeddingэмбеддинг, векторное представление
precision / recall / F1точность / полнота / F1-мера
Mean Reciprocal Rank (MRR)средний обратный ранг
end-to-end accuracyсквозная точность
ground truth, correct answerэталонный (правильный) ответ
summary indexingиндекс со сводками
re-rankingреранкинг, переранжирование
LLM-as-judgeLLM-судья
rate limitsлимиты запросов