← назад к разделу

Модель обучена на общих данных из интернета и ничего не знает про вашу компанию: ни базы знаний, ни документации, ни свежих новостей после её обучения. Спросишь про внутренний регламент — она либо признается, что не знает, либо выдумает правдоподобный ответ. RAG (retrieval-augmented generation, «генерация, дополненная поиском») решает эту проблему: он находит нужные куски ваших данных и подмешивает их в запрос, чтобы модель отвечала по фактам, а не по памяти.

модель отвечает только по тем кускам, которые принёс поиск 1 · вопроскак вернуть товар? эмбеддинг2 · вектор вопроса[0.07 -0.41 0.88 … 0.12] 3 · поиск: ближайшие кускиблизость смысла1.00оформление возврата покупки0.91срок возврата — 14 дней0.86возврат денег на карту0.79 условия доставки0.42рецепт борща0.07 промах на этом шаге промпт не исправит 4 · те же куски — в запросвопрос: как вернуть товаркусок 1 · оформление возвратакусок 2 · срок возврата 14 днейкусок 3 · возврат денег на картуотвечай только по этим кускам модельответ14 дней,на картуновых фактовне добавляет

Модель видит не базу знаний, а несколько кусков, которые принёс поиск. Что осталось ниже отсечки — для неё просто не существует: ни аккуратная формулировка запроса, ни модель посильнее этого не вернут. Поэтому качество ответа решается на шаге поиска: как порезаны документы и сколько кусков берём — на схеме их три, в живой базе знаний обычно больше. Числа близости здесь условные: они показывают порядок, а не шкалу.

Обязательно

Почему нельзя просто «загрузить всё»

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

Эмбеддинги: текст как вектор смысла

Обычный поиск по ключевым словам плохо справляется: пользователь спрашивает «как вернуть товар», а в документе написано «оформление возврата покупки» — слова разные, смысл один. Здесь помогают эмбеддинги.

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

Какую модель эмбеддингов брать (и почему для русского это важно)

«Текст переводят в эмбеддинг» — но чем именно? Это отдельная модель, не та, что отвечает, и выбор её влияет на качество поиска сильнее, чем всё остальное в этой статье.

Главное для русскоязычного корпуса: модель должна быть многоязычной. Многие популярные модели эмбеддингов обучались в основном на английском, и на русском они заметно проседают: близость считается хуже, синонимы не сближаются, а иногда русские тексты вообще ложатся в пространстве плотной кучей, где всё похоже на всё. Признак этой беды узнаваем: поиск возвращает случайные куски, и близость у них у всех примерно одинаковая. Поэтому первым делом смотрят, объявлена ли у модели поддержка русского, и проверяют на своих данных.

Как проверить за час, без теории. Соберите тридцать-пятьдесят настоящих вопросов, для каждого отметьте, какой кусок документа должен найтись, и посчитайте долю попаданий в верхние 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)

Смотрите на порядок вызовов: сначала эмбеддинги, и только потом удаление старого и вставка. Поменяете местами - упавший запрос к модели оставит документ вообще без кусков, и он выпадет из поиска до следующей индексации.

документ регламент, 12 страниц куски по 500 слов, внахлёст эмбеддинги вектор на каждый кусок векторная база вектор и doc_id

Первый этап RAG делают один раз до всех вопросов: документ режут на куски внахлёст, каждый кусок переводят в вектор и кладут в базу вместе с идентификатором документа, по которому потом удаляют и переиндексируют.

Ответ (на каждый вопрос):

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

Результат — ответы по вашим актуальным данным и заметно меньше выдумок: модели дали факты и попросили не выходить за их пределы.

Второй этап живёт на каждом вопросе, и кода в нём столько же. Вопрос переводят в вектор той же моделью, что и куски: векторы разных моделей несравнимы, и подмена модели здесь ничего не сломает на сборке, а тихо испортит выдачу. Дальше берут десяток ближайших кусков, нумеруют их в промпте и отдают модели вместе с правилом отвечать только по ним - это правило и лежит в 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. Опирается ли ответ на найденное (не выдумал ли модель сверх кусков). Проверяется утверждение за утверждением: для каждого — есть ли оно в приведённых фрагментах.
  2. Отвечает ли ответ на вопрос (а не рассказывает рядом стоящее).
  3. Полон ли ответ — не потерялась ли часть, которая в фрагментах была.

Чем проверять. Три способа, от дешёвого к дорогому, и на практике их сочетают.

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

Моделью-судьёй — то, что требует чтения. Отдельный вызов модели, которому дают вопрос, фрагменты и ответ, и просят оценить по одному признаку за раз: «все ли утверждения ответа подтверждаются фрагментами — да/нет, и какие нет». По одному признаку за вызов работает заметно лучше, чем общая оценка «качество от 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.

Что почитать дальше