Модель обучена на общих данных из интернета и ничего не знает про вашу компанию: ни базы знаний, ни документации, ни свежих новостей после её обучения. Спросишь про внутренний регламент — она либо признается, что не знает, либо выдумает правдоподобный ответ. RAG (retrieval-augmented generation, «генерация, дополненная поиском») решает эту проблему: он находит нужные куски ваших данных и подмешивает их в запрос, чтобы модель отвечала по фактам, а не по памяти.
Модель видит не базу знаний, а несколько кусков, которые принёс поиск. Что осталось ниже отсечки — для неё просто не существует: ни аккуратная формулировка запроса, ни модель посильнее этого не вернут. Поэтому качество ответа решается на шаге поиска: как порезаны документы и сколько кусков берём — на схеме их три, в живой базе знаний обычно больше. Числа близости здесь условные: они показывают порядок, а не шкалу.
Почему нельзя просто «загрузить всё»
Первая наивная мысль — вставить в промпт весь корпус документов. Не выйдет: окно контекста конечно, туда не влезут гигабайты, а лишний текст ещё и путает модель и дорого стоит. Значит, на каждый вопрос нужно класть в контекст только релевантные куски. Задача сводится к поиску: как из большого корпуса быстро достать именно то, что относится к вопросу?
Эмбеддинги: текст как вектор смысла
Обычный поиск по ключевым словам плохо справляется: пользователь спрашивает «как вернуть товар», а в документе написано «оформление возврата покупки» — слова разные, смысл один. Здесь помогают эмбеддинги.
Эмбеддинг — это превращение текста в вектор (длинный список чисел), который кодирует его смысл. Ключевое свойство: тексты, близкие по смыслу, получают близкие векторы, даже если слова разные. «Вернуть товар» и «оформление возврата» окажутся рядом в этом пространстве смыслов, а «рецепт борща» — далеко.
Какую модель эмбеддингов брать (и почему для русского это важно)
«Текст переводят в эмбеддинг» — но чем именно? Это отдельная модель, не та, что отвечает, и выбор её влияет на качество поиска сильнее, чем всё остальное в этой статье.
Главное для русскоязычного корпуса: модель должна быть многоязычной. Многие популярные модели эмбеддингов обучались в основном на английском, и на русском они заметно проседают: близость считается хуже, синонимы не сближаются, а иногда русские тексты вообще ложатся в пространстве плотной кучей, где всё похоже на всё. Признак этой беды узнаваем: поиск возвращает случайные куски, и близость у них у всех примерно одинаковая. Поэтому первым делом смотрят, объявлена ли у модели поддержка русского, и проверяют на своих данных.
Как проверить за час, без теории. Соберите тридцать-пятьдесят настоящих вопросов, для каждого отметьте, какой кусок документа должен найтись, и посчитайте долю попаданий в верхние k для двух-трёх моделей. Это тот же набор для оценки поиска, о котором ниже, и он же решает вопрос выбора модели — объективно и на ваших текстах, а не по обзорам.
Остальные признаки выбора, по убыванию важности для обычного проекта:
- Предел длины входа. У модели есть максимум токенов на вход, и он задаёт, какого размера могут быть куски: кусок длиннее обрежется молча. Это связывает выбор модели с нарезкой документов, и решать их надо вместе.
- Размерность вектора. 384, 768, 1024, 1536 — больше обычно немного точнее и заметно дороже по памяти и по скорости поиска. Для корпуса в десятки тысяч кусков разница в качестве часто не стоит роста объёма; прикидка объёма — в статье про векторные базы.
- Своя или по сети. Модель эмбеддингов можно поднять у себя: они небольшие, работают и без ускорителя, и тогда ваши документы не уходят наружу — а уходят они все, потому что индексируется весь корпус, а не отдельные запросы. Для закрытых данных это обычно решающий довод.
- Стабильность. Смена модели эмбеддингов означает переиндексацию всего корпуса и новый подбор порога: векторы разных моделей несовместимы между собой. Поэтому выбор делают один раз и осознанно, а не «попробуем пока эту».
И одна асимметрия, о которой легко забыть: вопрос и документ должны переводиться в векторы одной и той же моделью. Часть моделей к тому же требует добавлять служебную приставку к запросу и к документу («query:», «passage:») — не добавили, и качество тихо падает, потому что модель обучалась именно так.
Семантический поиск
Раз смысл стал вектором, поиск похожего становится геометрией. Все куски документов заранее переводят в эмбеддинги и складывают. Приходит вопрос — его тоже переводят в вектор и ищут ближайшие векторы кусков. Это и есть семантический поиск: находим не по совпадению слов, а по близости смысла. Хранят эти векторы и ищут ближайшие в векторной базе данных — о ней отдельная статья.
Одна ловушка, на которую попадаются почти все. Близость выдаётся числом, и хочется прочитать его как оценку: 0.9 — отлично, 0.5 — так себе. Так нельзя. Число ничего не значит само по себе: каждая модель эмбеддингов раскладывает тексты по-своему, и у многих даже совсем чужие друг другу куски получают 0.6-0.8. Сравнивать эти числа осмысленно только между собой внутри одной выдачи одной модели, а порог «ниже этого — не берём» подбирают руками, на своих данных, и после смены модели подбирают заново.
Как собирается RAG
RAG складывает всё вместе в два этапа.
Подготовка (заранее): документы режут на куски (чанки) разумного размера, каждый кусок переводят в эмбеддинг и складывают в векторную базу. Это индексация базы знаний.
Словами это уже сказано, теперь то же самое кодом. Нарезка идёт внахлёст: соседние куски перекрываются на сотню слов, чтобы мысль, попавшая ровно на границу, целиком осталась хотя бы в одном из них. Все куски документа уходят на эмбеддинги одним запросом, это и быстрее, и дешевле, чем по одному. А запись в хранилище идёт не «добавить», а «удалить всё по этому doc_id и вставить заново»: после правки документа границы кусков сдвигаются, и старая нарезка в индексе только мешает.
List<String> parts = splitWithOverlap(doc.text(), 500, 100);
CreateEmbeddingResponse response = client.embeddings().create(
EmbeddingCreateParams.builder()
.model("text-embedding-3-small")
.inputOfArrayOfStrings(parts)
.build());
chunks.deleteByDocId(doc.id());
List<Embedding> vectors = response.data();
for (int i = 0; i < parts.size(); i++) {
chunks.insert(doc.id(), i, parts.get(i), vectors.get(i).embedding());
}
parts := splitWithOverlap(doc.Text, 500, 100)
res, err := client.Embeddings.New(ctx, openai.EmbeddingNewParams{
Model: openai.EmbeddingModelTextEmbedding3Small,
Input: openai.EmbeddingNewParamsInputUnion{OfArrayOfStrings: parts},
})
if err != nil {
return err
}
if err := chunks.DeleteByDocID(ctx, doc.ID); err != nil {
return err
}
for i, part := range parts {
if err := chunks.Insert(ctx, doc.ID, i, part, res.Data[i].Embedding); err != nil {
return err
}
}
return nil
const parts = splitWithOverlap(doc.text, 500, 100);
const response = await client.embeddings.create({
model: 'text-embedding-3-small',
input: parts,
});
await chunks.deleteByDocId(doc.id);
for (const [i, part] of parts.entries()) {
await chunks.insert(doc.id, i, part, response.data[i].embedding);
}
parts = split_with_overlap(doc.text, 500, 100)
response = client.embeddings.create(
model="text-embedding-3-small",
input=parts,
)
chunks.delete_by_doc_id(doc.id)
for i, part in enumerate(parts):
chunks.insert(doc.id, i, part, response.data[i].embedding)
Смотрите на порядок вызовов: сначала эмбеддинги, и только потом удаление старого и вставка. Поменяете местами - упавший запрос к модели оставит документ вообще без кусков, и он выпадет из поиска до следующей индексации.
Первый этап RAG делают один раз до всех вопросов: документ режут на куски внахлёст, каждый кусок переводят в вектор и кладут в базу вместе с идентификатором документа, по которому потом удаляют и переиндексируют.
Ответ (на каждый вопрос):
- вопрос пользователя переводят в эмбеддинг;
- семантическим поиском достают несколько самых релевантных кусков;
- эти куски подмешивают в промпт вместе с вопросом и инструкцией «отвечай только по этим фрагментам»;
- модель формулирует ответ, опираясь на факты, а не на память.
Результат — ответы по вашим актуальным данным и заметно меньше выдумок: модели дали факты и попросили не выходить за их пределы.
Второй этап живёт на каждом вопросе, и кода в нём столько же. Вопрос переводят в вектор той же моделью, что и куски: векторы разных моделей несравнимы, и подмена модели здесь ничего не сломает на сборке, а тихо испортит выдачу. Дальше берут десяток ближайших кусков, нумеруют их в промпте и отдают модели вместе с правилом отвечать только по ним - это правило и лежит в GROUNDING_RULE.
List<Float> questionVector = client.embeddings().create(
EmbeddingCreateParams.builder()
.model("text-embedding-3-small")
.input(question)
.build())
.data().get(0).embedding();
List<Found> found = chunks.nearest(questionVector, 10);
if (found.isEmpty() || found.get(0).score() < MIN_SCORE) {
return "В базе знаний нет ответа на этот вопрос.";
}
return client.chat().completions().create(
ChatCompletionCreateParams.builder()
.model("gpt-4o-mini")
.addSystemMessage(GROUNDING_RULE)
.addUserMessage("Фрагменты:\n" + numbered(found) + "\n\nВопрос: " + question)
.build())
.choices().get(0).message().content().orElse("");
q, err := client.Embeddings.New(ctx, openai.EmbeddingNewParams{
Model: openai.EmbeddingModelTextEmbedding3Small,
Input: openai.EmbeddingNewParamsInputUnion{OfString: openai.String(question)},
})
if err != nil {
return "", err
}
found, err := chunks.Nearest(ctx, q.Data[0].Embedding, 10)
if err != nil {
return "", err
}
if len(found) == 0 || found[0].Score < minScore {
return "В базе знаний нет ответа на этот вопрос.", nil
}
answer, err := client.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{
Model: openai.ChatModelGPT4oMini,
Messages: []openai.ChatCompletionMessageParamUnion{
openai.SystemMessage(groundingRule),
openai.UserMessage("Фрагменты:\n" + numbered(found) + "\n\nВопрос: " + question),
},
})
if err != nil {
return "", err
}
return answer.Choices[0].Message.Content, nil
const q = await client.embeddings.create({
model: 'text-embedding-3-small',
input: question,
});
const found = await chunks.nearest(q.data[0].embedding, 10);
if (found.length === 0 || found[0].score < MIN_SCORE) {
return 'В базе знаний нет ответа на этот вопрос.';
}
const answer = await client.chat.completions.create({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: GROUNDING_RULE },
{ role: 'user', content: `Фрагменты:\n${numbered(found)}\n\nВопрос: ${question}` },
],
});
return answer.choices[0].message.content;
q = client.embeddings.create(
model="text-embedding-3-small",
input=question,
)
found = chunks.nearest(q.data[0].embedding, 10)
if not found or found[0].score < MIN_SCORE:
return "В базе знаний нет ответа на этот вопрос."
answer = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": GROUNDING_RULE},
{"role": "user", "content": f"Фрагменты:\n{numbered(found)}\n\nВопрос: {question}"},
],
)
return answer.choices[0].message.content
Строка с порогом здесь не для красоты: если лучший найденный кусок хуже порога, модель не вызывают вовсе. Что бывает без неё - следующий раздел.
Нарезка кажется мелочью, пока не сядешь её писать. Резать по числу символов нельзя: граница попадает на середину предложения, и кусок теряет смысл ровно там, где лежал ответ. Резать строго по абзацам тоже нельзя: один абзац бывает длиннее предела модели эмбеддингов, а десять коротких подряд дадут десять почти пустых кусков, каждый из которых сам по себе ни о чём.
Рабочее правило такое: идти по готовым границам документа (абзацы и заголовки), набирать из них кусок, пока он не упёрся в предел в токенах, и начинать следующий не с чистого листа, а с хвоста предыдущего. Хвост и есть перекрытие: без него ответ, лежащий на стыке двух кусков, не найдётся целиком ни в одном.
List<String> split(List<String> blocks, int maxTokens, int overlapTokens) {
List<String> chunks = new ArrayList<>();
List<String> current = new ArrayList<>();
int used = 0;
for (String block : blocks) {
int size = tokens(block);
if (used + size > maxTokens && !current.isEmpty()) {
chunks.add(String.join("\n\n", current));
int tailBudget = Math.min(overlapTokens, maxTokens - size);
current = new ArrayList<>(overlapTail(current, tailBudget));
used = sumTokens(current);
}
current.add(block);
used += size;
}
if (!current.isEmpty()) {
chunks.add(String.join("\n\n", current));
}
return chunks;
}
func Split(blocks []string, maxTokens, overlapTokens int) []string {
var chunks, current []string
used := 0
for _, block := range blocks {
size := Tokens(block)
if used+size > maxTokens && len(current) > 0 {
chunks = append(chunks, strings.Join(current, "\n\n"))
tailBudget := min(overlapTokens, maxTokens-size)
current = OverlapTail(current, tailBudget)
used = SumTokens(current)
}
current = append(current, block)
used += size
}
if len(current) > 0 {
chunks = append(chunks, strings.Join(current, "\n\n"))
}
return chunks
}
function split(blocks: string[], maxTokens: number, overlapTokens: number): string[] {
const chunks: string[] = [];
let current: string[] = [];
let used = 0;
for (const block of blocks) {
const size = tokens(block);
if (used + size > maxTokens && current.length > 0) {
chunks.push(current.join('\n\n'));
const tailBudget = Math.min(overlapTokens, maxTokens - size);
current = overlapTail(current, tailBudget);
used = sumTokens(current);
}
current.push(block);
used += size;
}
if (current.length > 0) {
chunks.push(current.join('\n\n'));
}
return chunks;
}
def split(blocks: list[str], max_tokens: int, overlap_tokens: int) -> list[str]:
chunks: list[str] = []
current: list[str] = []
used = 0
for block in blocks:
size = tokens(block)
if used + size > max_tokens and current:
chunks.append("\n\n".join(current))
tail_budget = min(overlap_tokens, max_tokens - size)
current = overlap_tail(current, tail_budget)
used = sum_tokens(current)
current.append(block)
used += size
if current:
chunks.append("\n\n".join(current))
return chunks
Смотреть тут надо на три вещи. tokens обязан считать токенами той самой модели эмбеддингов, в которую кусок поедет, а не символами и не словами: предел объявлен в токенах, и прикидка «примерно четыре символа на токен» ломается на первом же куске с кодом или таблицей. overlapTail отдаёт хвостовые блоки прошлого куска, которые укладываются в отпущенный бюджет, а при нулевом или отрицательном бюджете не отдаёт ничего. И сам бюджет хвоста — это не просто перекрытие: из предела вычитается размер блока, который прямо сейчас ляжет в новый кусок. Без этого вычитания хвост вместе с крупным блоком вылезают за предел, и кусок обрежется молча — ровно та беда, ради которой всё и затевалось. Остаётся случай, когда блок сам длиннее предела: тут не спасёт ни хвост, ни бюджет, такой абзац приходится резать по предложениям отдельной веткой.
Главный отказ: поиск ничего не нашёл
В схеме выше есть шаг, который молча ломает всё остальное: поиск может не найти ничего подходящего. Вопрос не про ваши документы, база знаний не покрывает тему, формулировка не совпала ни с одним куском. И вот что происходит дальше, если не предусмотреть: поиск честно возвращает что-то (ближайшие куски есть всегда, даже если они не по теме), эти куски уезжают в промпт, и модель вежливо отвечает по ним — уверенно и не по делу. Ни одна часть системы не сообщила об ошибке: поиск отработал, модель ответила, пользователь получил правдоподобную неправду.
Лечится это двумя решениями, и нужны оба.
Порог на близость — и отказ вызывать модель. Если лучший найденный кусок хуже порога, модель не вызывают вовсе: пользователю отвечают «в базе знаний нет ответа на этот вопрос» и, по возможности, предлагают путь дальше (поиск по словам, обращение к человеку). Это дешевле вызова, честнее для пользователя и снимает целый класс выдумок. Про сам порог помните то, что сказано выше: абсолютное число зависит от модели эмбеддингов, его подбирают на своих данных и пересматривают при смене модели.
Разрешение не отвечать — в инструкции. Даже когда куски нашлись, в промпте должно быть явно написано: «если в приведённых фрагментах нет ответа, скажи, что не знаешь, и не достраивай из общих знаний». Без этой строки модель заполнит пробел тем, что помнит из обучения, и вы получите смесь ваших фактов с чужими — самый неприятный вид ответа, потому что его нельзя ни проверить, ни опровергнуть.
Проверять это надо специально, и это отдельный пункт набора примеров: вопросы, на которые в базе ответа нет. Правильный ответ на них — «не знаю», и доля таких правильных отказов — такая же метрика качества, как доля верных ответов. Фича, которая на любой вопрос что-то отвечает, не прошла приёмку, даже если на «хороших» вопросах отвечает отлично.
А вот ссылки на источники сами собой не появляются — это отдельная работа. Рядом с каждым куском хранят, откуда он взят; в промпте куски нумеруют; от модели требуют назвать номер, на котором она основывает ответ; и потом проверяют, что названный номер и правда лежал в контексте. Без этой проверки модель с той же уверенностью сошлётся на кусок, из которого ничего не брала.
Грабли, о которых надо знать
- качество кусков решает всё. Плохо порезали документы (слишком крупно или мелко) — поиск достаёт нерелевантное, и ответ портится. Чанкинг — не мелочь. И помните про предел длины входа у модели эмбеддингов: кусок длиннее него обрежется, хвост в вектор не попадёт — молча, без всякой ошибки. Это одна из самых частых причин «поиск не находит очевидное».
- RAG не отменяет проверку. Даже с фактами в контексте модель может исказить их при пересказе; ответ всё равно проверяют, а источники показывают пользователю.
- свежесть индекса. Данные меняются — индекс надо обновлять, иначе модель отвечает по устаревшим кускам.
Обновление индекса — это и есть вся работа
Первая индексация делается один раз и запоминается как «настройка RAG». Потом документы живут: их правят, переносят, удаляют, — и вся дальнейшая работа над фичей состоит в том, чтобы индекс за ними успевал. Здесь три ловушки, и каждая приводит к тому, что система уверенно отвечает по тому, чего больше нет.
Идентификатор источника в метаданных — обязателен. Рядом с каждым куском хранят, из какого документа он получен и какая это часть: doc_id, версия или отметка изменения, номер куска. Без doc_id невозможны ни удаление, ни обновление: вы просто не знаете, какие куски принадлежат правленому документу. Это то место, где стоит потратить полчаса на схему до первой загрузки, потому что потом придётся переиндексировать всё.
Что именно класть рядом с куском, видно из двух вопросов, на которые индекс обязан уметь ответить: «какие куски принадлежат вот этому документу» и «откуда взят вот этот кусок». Первый закрывает doc_id: без него не сделать ни удаления, ни переиндексации. Второй закрывают заголовок раздела и смещение в исходном тексте: по ним пользователю показывают источник и подсвечивают место, а не отправляют читать документ целиком.
Номер куска нужен третьим: по нему видно порядок и можно достать соседей, когда ответ лежит на границе. Вот что получается на каждый кусок при индексации.
record ChunkPoint(String id, String docId, int ordinal, int offset,
String heading, String text, List<Float> vector) {}
ChunkPoint buildPoint(String docId, int ordinal, int offset, String heading, String text) {
EmbeddingCreateParams params = EmbeddingCreateParams.builder()
.model(EmbeddingModel.TEXT_EMBEDDING_3_SMALL)
.input(heading + "\n\n" + text)
.build();
List<Float> vector = client.embeddings().create(params).data().get(0).embedding();
return new ChunkPoint(docId + ":" + ordinal, docId, ordinal, offset, heading, text, vector);
}
type ChunkPoint struct {
ID string
DocID string
Ordinal int
Offset int
Heading string
Text string
Vector []float64
}
func BuildPoint(ctx context.Context, docID string, ordinal, offset int, heading, text string) (ChunkPoint, error) {
resp, err := client.Embeddings.New(ctx, openai.EmbeddingNewParams{
Model: openai.EmbeddingModelTextEmbedding3Small,
Input: openai.EmbeddingNewParamsInputUnion{OfString: openai.String(heading + "\n\n" + text)},
})
if err != nil {
return ChunkPoint{}, err
}
return ChunkPoint{
ID: fmt.Sprintf("%s:%d", docID, ordinal),
DocID: docID,
Ordinal: ordinal,
Offset: offset,
Heading: heading,
Text: text,
Vector: resp.Data[0].Embedding,
}, nil
}
interface ChunkPoint {
id: string;
docId: string;
ordinal: number;
offset: number;
heading: string;
text: string;
vector: number[];
}
async function buildPoint(docId: string, ordinal: number, offset: number,
heading: string, text: string): Promise<ChunkPoint> {
const response = await client.embeddings.create({
model: 'text-embedding-3-small',
input: `${heading}\n\n${text}`,
});
return {
id: `${docId}:${ordinal}`,
docId,
ordinal,
offset,
heading,
text,
vector: response.data[0].embedding,
};
}
@dataclass
class ChunkPoint:
id: str
doc_id: str
ordinal: int
offset: int
heading: str
text: str
vector: list[float]
def build_point(doc_id, ordinal, offset, heading, text):
response = client.embeddings.create(
model="text-embedding-3-small",
input=f"{heading}\n\n{text}",
)
return ChunkPoint(f"{doc_id}:{ordinal}", doc_id, ordinal, offset,
heading, text, response.data[0].embedding)
Две тонкости, на которых спотыкаются. В вектор уходит заголовок раздела вместе с текстом куска: без него кусок «этот срок считается с даты подачи заявления» не найдётся по вопросу про возврат товара, потому что слова «возврат» в нём нет. И идентификатор точки собран из doc_id и номера куска, а не взят случайным: тогда повторная индексация того же документа перезаписывает точки, а не подкладывает дубликаты рядом со старыми.
Правка документа — это удалить и вставить заново, а не «обновить». При правке меняется нарезка: абзац стал длиннее, границы кусков сдвинулись, число кусков изменилось. Обновлять их по одному бессмысленно. Рабочий порядок: удалить все куски этого doc_id, нарезать документ заново, вставить. В базе это одна транзакция, и её стоит писать именно так, а не «перезаписать первые пять».
Удалённый документ продолжает отвечать. Самая обидная ловушка: регламент отменили, страницу удалили, а куски остались в индексе — и система цитирует несуществующий документ, добросовестно указывая источник. Отсюда требование: удаление документа обязано удалять его куски, и это часть того же процесса, что удаление самого документа, а не отдельная задача «когда-нибудь почистим». Если источник не умеет сообщать об удалениях (выгрузка, папка с файлами), спасает сверка: периодически сравнивать список doc_id в индексе со списком существующих документов и вычищать сирот.
Как это устроено технически, по возрастанию сложности:
- Полная переиндексация по расписанию. Раз в сутки собрать всё заново. Просто и надёжно; годится, пока корпус небольшой и суточное отставание терпимо. Полную переиндексацию обычно делают в новую таблицу или коллекцию и переключают указатель, чтобы поиск не ломался на середине.
- По событиям. Документ изменился — система публикует событие, обработчик переиндексирует этот документ. Свежесть в секунды, и это правильный вариант для данных, которые правят люди в течение дня. Обработчик должен быть идемпотентным (повтор события не должен дублировать куски) — это решается тем, что он всегда сначала удаляет куски
doc_id. - По отметке изменения. Периодически забирать всё, что изменилось с прошлого прохода. Середина между первыми двумя, и здесь легко пропустить удаления, поэтому сверка сирот обязательна.
И то, что стоит считать частью фичи, а не роскошью: наблюдаемость индекса. Число документов и кусков, отметка последней успешной индексации, число ошибок при обработке. Индекс, который тихо перестал обновляться три недели назад, обнаруживается по жалобе пользователя, а не по графику, — если графика нет.
Сколько фрагментов брать и как искать
От двух настроек зависит, найдётся ли ответ вообще.
Первая — число фрагментов в контексте (top-k). Три — мало для документации, где ответ размазан по разделам; берут 10–20, а потом переранжируют более точной моделью — кросс-энкодером, который читает пару «вопрос-фрагмент» целиком, а не сравнивает два готовых вектора: он точнее, но дороже, поэтому его применяют только к уже найденным кандидатам.
Вторая — способ поиска. Чисто векторный поиск плохо ловит точные термины и коды («ошибка 42», название параметра), поэтому его сочетают с полнотекстовым: гибридный поиск объединяет оба списка.
Качество поиска проверяют отдельно от генерации: набор вопросов с известными «правильными» фрагментами и метрика «попал ли нужный фрагмент в top-k» — считается без модели, быстро и дёшево.
Как проверять, что модель не переврала найденное
Про оценку поиска сказано отдельно и хорошо: набор вопросов, известные нужные фрагменты, доля попаданий — считается без модели. Но это только половина: поиск может отработать идеально, а ответ всё равно быть неверным. Вторую половину проверяют иначе, и вот как.
Три вопроса к ответу. У генерации в RAG три независимых свойства, и путать их не стоит:
- Опирается ли ответ на найденное (не выдумал ли модель сверх кусков). Проверяется утверждение за утверждением: для каждого — есть ли оно в приведённых фрагментах.
- Отвечает ли ответ на вопрос (а не рассказывает рядом стоящее).
- Полон ли ответ — не потерялась ли часть, которая в фрагментах была.
Чем проверять. Три способа, от дешёвого к дорогому, и на практике их сочетают.
Правилами — то, что проверяется машинно. Названный источник действительно лежал в контексте (частая выдумка — сослаться на номер, которого не было). Ответ не пуст и не длиннее предела. Если фича обязана отвечать по схеме — схема соблюдена. На вопросах без ответа в базе получен отказ, а не текст. Это дёшево, гоняется в сборке и ловит заметную долю проблем.
Моделью-судьёй — то, что требует чтения. Отдельный вызов модели, которому дают вопрос, фрагменты и ответ, и просят оценить по одному признаку за раз: «все ли утверждения ответа подтверждаются фрагментами — да/нет, и какие нет». По одному признаку за вызов работает заметно лучше, чем общая оценка «качество от 1 до 5». Судью стоит взять не той же модели, что отвечала (иначе она склонна одобрять своё), и один раз проверить на размеченных вручную примерах, что судья согласен с человеком — иначе вы измеряете мнение судьи, а не качество ответа.
Человеком — эталонный набор. Пятьдесят-сто вопросов с ответами, размеченными людьми: этот набор — источник правды, по которому калибруют и правила, и судью. Его собирают один раз (лучше всего из настоящих вопросов пользователей) и пополняют каждым случаем, когда фича ответила плохо.
Когда это гонять. Тот же набор прогоняют при любом изменении: правка промпта, смена модели эмбеддингов, другая нарезка, новое число фрагментов, смена версии модели. Смысл в сравнении с предыдущим прогоном: цифра сама по себе мало что говорит, а «было 86 % верных, стало 71 %» — говорит всё. Без набора любое улучшение фичи делается на ощупь и ломает что-то другое.
И практическое замечание: ошибки разделяйте по слою. Если ответ неверный, сначала смотрят, нашёлся ли нужный фрагмент. Не нашёлся — проблема в поиске (нарезка, модель эмбеддингов, гибрид, число фрагментов). Нашёлся, а ответ всё равно плохой — проблема в генерации (промпт, размер контекста, модель). Без этого разделения команда месяцами правит промпт при неисправном поиске.
Когда RAG не нужен
RAG продаётся как ответ на «спроси мою систему человеческим языком», и у него есть границы. Четыре случая, где его брать не надо, — и что брать вместо.
Документов мало. Десяток страниц, которые целиком влезают в контекст, не нуждаются ни в индексе, ни в векторной базе: их кладут в промпт целиком (а при неизменном наборе ещё и кешируют начало запроса). Разбор этой границы — в разделе «Глубже: длинный контекст вместо поиска» ниже. Правило: не строить поиск, пока корпус не перестал влезать.
Вопрос про агрегаты и точные данные. «Сколько заказов за март», «какая сумма возвратов у этого клиента», «есть ли товар на складе» — это запрос к базе, а не поиск по смыслу. Семантический поиск найдёт похожие тексты, а не посчитает; ответ будет правдоподобным и неверным. Правильное решение — дать модели инструмент, который выполняет запрос к базе, и отвечать по его результату. Это другая архитектура, и путать её с RAG — частая и дорогая ошибка: команда индексирует выгрузку из базы и удивляется, что цифры не сходятся.
Данные меняются каждую минуту. Цены, остатки, статусы заказов, показания датчиков. Индекс всегда отстаёт, а отставание в такой фиче — это неверный ответ, а не неудобство. Здесь тоже инструмент к источнику, а не индекс.
Ответ должен быть точным и полным по определению. Юридически значимая формулировка, полный список чего-то, расчёт. Поиск по смыслу не даёт гарантии полноты («нашлись все нужные куски») — а без этой гарантии полного списка не получится. Такие задачи решают структурированным запросом, а модель, если нужна, оформляет результат.
Что остаётся для RAG после этих вычетов — и это по-прежнему много: большой корпус текстов на человеческом языке, который меняется не постоянно, и вопросы, ответ на которые написан словами в одном-двух документах. Документация, регламенты, база знаний поддержки, архив переписки, инструкции. Признак хорошей задачи для RAG: вы можете указать пальцем на абзац, в котором лежит ответ.
Где это применяется
RAG — самый частый способ построить полезную LLM-фичу на своих данных: чат по документации и базе знаний, поддержка, отвечающая по регламентам, поиск по внутренним данным «человеческим языком», ассистент по кодовой базе. Почти любая фича «спроси мою систему на естественном языке» под капотом — это RAG.
Глубже: промпт, RAG или дообучениерасширенное
Слово «дообучить» встречается в тесте фазы дистрактором, и не зря: это первый вопрос, который задают, узнав про RAG. Три способа дать модели то, чего в ней нет, отвечают на разные вопросы.
Промпт отвечает на вопрос «как себя вести»: роль, формат, границы, тон, несколько примеров. Он дешёв, меняется за минуту и не даёт знаний: факты в промпте это те же токены, и их влезает немного.
RAG отвечает на вопрос «что знать»: факты, которых много и которые меняются. Документы индексируют, на вопрос находят нужные куски и кладут в контекст; обновление знаний это переиндексация документа, а не переобучение. Ответ можно снабдить источником, и это единственный из трёх способов, который его даёт.
Дообучение меняет саму модель на ваших примерах и отвечает на вопрос «как выглядеть»: стиль, устойчивое соблюдение формата, жаргон узкой области, поведение, которое не описать инструкцией. Свежие факты оно не даёт: дообученная на документации модель по-прежнему достраивает детали и устаревает с первым изменением. Требует сотен-тысяч размеченных примеров, стоит денег на обучение и повторяется при каждой смене базовой модели. Его берут, когда маленькая дообученная модель должна заменить большую с длинным промптом ради цены и скорости, или когда формат вывода не держится инструкцией.
Порядок проб: сначала промпт, потом RAG, если не хватает фактов, и дообучение как последнее средство и только для формы. Для базы знаний, документации и поддержки ответ почти всегда RAG, и «дообучить на нашей документации» это ошибка, которую стоит распознавать по формулировке.
Глубже: гибридный поиск и переранжированиерасширенное
Выше сказано, что гибридный поиск объединяет два списка, а переранжирование делает кросс-энкодер. Как именно это работает, влияет на качество больше, чем выбор модели.
Два списка с несопоставимыми оценками. Полнотекстовый поиск (BM25) отдаёт список с оценками в своих единицах, векторный отдаёт список с косинусной близостью от нуля до единицы; складывать их напрямую нельзя. Стандартный способ объединения это слияние по рангам (RRF): каждому документу начисляют сумму 1 / (k + место) по каждому списку с константой около 60, и документ, который высоко в обоих, поднимается наверх. Способ не требует калибровки и работает из коробки; взвешенное сложение нормированных оценок точнее, но требует подбора весов на наборе с разметкой.
Кросс-энкодер. Векторный поиск сравнивает два готовых вектора, посчитанных независимо; кросс-энкодер читает пару «вопрос и фрагмент» вместе одним проходом и оценивает, отвечает ли фрагмент на вопрос. Точнее в разы, но и дороже: десятки миллисекунд на пару на видеокарте, поэтому его применяют не ко всему корпусу, а к верхним двадцати-пятидесяти кандидатам от гибридного поиска, и оставляют пять-десять лучших для контекста. Вариант дороже: переранжирование самой языковой моделью, которой показывают кандидатов и просят выбрать; годится для малых объёмов.
Как измерять. Качество поиска проверяют без генерации: набор вопросов с известными нужными фрагментами и доля попаданий в верхние k, отдельно для полнотекстового, векторного, гибрида и гибрида с переранжированием. Обычная картина: гибрид даёт заметный прирост над каждым по отдельности, переранжирование добавляет ещё, и после этого улучшать нужно нарезку документов, а не поиск.
Глубже: длинный контекст вместо поискарасширенное
Статья начинается с утверждения, что «загрузить всё» нельзя. Окна выросли до сотен тысяч и миллионов токенов, и для многих задач это утверждение устарело: десяток документов кладут в контекст целиком, и поиск не нужен.
Когда положить всё. Корпус помещается в окно с запасом (десятки страниц, а не тысячи), вопросов к нему немного, а ответ требует видеть документы вместе: сравнить два договора, найти противоречия в спецификации, ответить по одной инструкции. Здесь RAG вредит: он нарежет документ на куски и потеряет связи между ними, а модель с целым документом ответит точнее.
Когда искать. Корпус большой и растёт, вопросов много и они частые, документы обновляются. Три причины против длинного контекста: цена (каждый вопрос оплачивает весь корпус заново, и сто тысяч токенов на вопрос при тысяче вопросов в день это сотни долларов в день), задержка (чтение сотен тысяч токенов занимает десятки секунд) и качество (то, что лежит в середине огромного контекста, модель учитывает хуже, и это измеримо). Кэш промпта смягчает цену, если корпус неизменный, но не задержку.
Граница и гибрид. Порядок величины: до ста тысяч токенов и редких вопросов положить всё, дальше искать. Часто лучший вариант совмещает оба: поиск выбирает нужные документы, а в контекст кладут их целиком, а не кусками, и модель видит связи внутри документа. Это дороже RAG по кускам и дешевле «всё сразу», и именно так устроены ассистенты по кодовой базе: найти файлы, прочитать их полностью.
Коротко
- Модель не знает ваших данных; RAG находит релевантные куски и кладёт их в промпт, чтобы она отвечала по фактам. RAG = заранее проиндексировать корпус (чанки → эмбеддинги), а на вопрос — найти релевантное и подмешать в промпт. Выдумок становится заметно меньше, но ссылки на источники и проверку ответа делают отдельно.
- Эмбеддинг превращает текст в вектор смысла: близкие по смыслу тексты — близкие векторы, даже при разных словах. Семантический поиск достаёт нужные куски по близости векторов, а не по совпадению слов; хранят их в векторной базе. Грабли: качество чанкинга, обновление индекса и всё та же проверка ответа.
- Промпт задаёт поведение, RAG даёт факты с источником, дообучение учит форме и не даёт свежих фактов; порядок проб именно такой, для базы знаний ответ RAG.
- Гибрид объединяет списки слиянием по рангам, кросс-энкодер переранжирует верхние двадцать-пятьдесят кандидатов; качество поиска меряют долей попаданий без генерации.
- До ста тысяч токенов и редких вопросов документы кладут целиком, дальше ищут; лучший гибрид это поиск документов и чтение их целиком.
- Главный отказ — поиск ничего не нашёл: ниже порога модель не вызывают вовсе, в промпте разрешают отвечать «не знаю», а вопросы без ответа в базе входят в набор для проверки.
- Модель эмбеддингов для русского корпуса должна быть многоязычной, выбирают её замером на своих вопросах; смена модели означает переиндексацию всего, а вопрос и документ считают одной моделью.
- Вся дальнейшая работа — обновление индекса: идентификатор источника в метаданных, правка как удалить-и-вставить, удаление документа удаляет куски, сверка сирот и наблюдаемость индекса.
- Ответ проверяют отдельно от поиска: правилами (источник существовал, схема, отказ на непокрытом вопросе), моделью-судьёй по одному признаку и эталонным набором; ошибки разделяют по слою.
- RAG не нужен, когда документов мало, когда вопрос про агрегаты или точные данные (нужен инструмент к базе), когда данные меняются каждую минуту и когда требуется полнота.
Дальше — векторные базы данных: где и как хранят эмбеддинги и ищут ближайшие векторы на масштабе.
Что пощупать
Всё, что описано выше, лежит рабочим кодом в практикуме курса — remodov/marketplace-system. Один и тот же помощник написан на четырёх языках. Ни ключа, ни интернета, ни зависимостей: векторы считаются локально, а модель в тестах подменена — проверяется не её ответ, а то, что ей положили в подсказку и когда её не позвали вовсе.
Код: Java — assistant; Python — chunks.py и assistant.py и knowledge.py; Go — chunks.go и assistant.go и knowledge.go; Node — chunks.mjs и assistant.mjs и knowledge.mjs.
Сделаем сами
Ветки с заданием на четырёх языках, где реализация вынута, а тесты красные; условие по ссылке в TASK.md: Java — side-b3-rag-assistant, Python — side-b3-rag-assistant-python, Go — side-b3-rag-assistant-go, Node — side-b3-rag-assistant-node.
Что почитать дальше
- Векторные базы данных — где хранят эмбеддинги и как ищут ближайшие на масштабе.
- Основы LLM-приложения — оценка, которой проверяют качество ответов по документам.
- Полнотекстовый поиск в PostgreSQL или Elasticsearch — вторая половина гибридного поиска.