4 мин чтения

Собираем RAG-пайплайн на .NET: практический разбор

Пошаговая сборка production-ready RAG на .NET 10 — от извлечения текста до evaluation-пайплайна. С реальными решениями, а не абстракциями.

За последний год мы собрали несколько RAG-пайплайнов на .NET для корпоративных систем. Ниже — схема, которая работает в проде, и грабли, на которые мы наступали.

Порядок сборки

Порядок важен: если начать с промпта, потом прикрутить retrieval и только затем задуматься про данные — потеряете недели на переделку.

  1. Извлечение и структурирование

    PDF, DOCX, HTML → единый внутренний формат с сохранением иерархии заголовков, таблиц и подписей. Без этого шага все следующие работают вслепую.

  2. Индексация

    Эмбеддинги в векторное хранилище плюс полнотекстовый индекс. Два индекса, один retrieval.

  3. Retrieval

    Гибридный поиск с RRF поверх двух выдач. Без ранжирования качество упрётся в потолок эмбеддингов.

  4. Генерация с цитированием

    Ответ только из переданного контекста, каждое утверждение привязано к источнику.

  5. Evaluation

    Набор реальных вопросов, метрики Recall@k и MRR, регрессия на каждом релизе.

Почему чанкинг решает больше, чем модель

За время этих проектов мы убедились: качество retrieval определяет ~80% итогового качества ответа. Выбор модели на оставшиеся 20% влияет в разы меньше, чем правильная нарезка документов.

Что делаем: режем по структуре. Для договоров — стороны, предмет, сроки, ответственность, приложения. Для технической документации — разделы и подразделы, блоки кода сохраняются целиком вместе с соседним пояснением.

public sealed record Section(
    string Title,
    int Level,
    string Text,
    string? ParentId);

public static IEnumerable<Section> Split(Document doc)
{
    // Режем по заголовкам, а не по длине. Длина — только
    // верхняя граница: слишком крупный раздел режем дальше,
    // но уже с сохранением соседнего контекста.
    var buffer = new List<string>();
    foreach (var block in doc.Blocks)
    {
        if (block.IsHeading && buffer.Count > 0)
        {
            yield return Flush(buffer);
            buffer.Clear();
        }
        buffer.Add(block.Text);
    }
    if (buffer.Count > 0) yield return Flush(buffer);
}

Гибридный поиск: вектор плюс FTS

Векторный поиск отлично ловит смысл и проваливает точные идентификаторы. «Контракт 44-А/2024» не найдётся, потому что эмбеддинг буквенно-цифрового кода почти не несёт смысла.

const double K = 60.0; // сглаживающий коэффициент RRF

public static IReadOnlyList<Chunk> Fuse(
    IReadOnlyList<Chunk> vectorHits,
    IReadOnlyList<Chunk> keywordHits,
    int limit)
{
    var scores = new Dictionary<string, double>();

    foreach (var (chunk, rank) in vectorHits.Select((c, i) => (c, i)))
        scores[chunk.Id] = scores.GetValueOrDefault(chunk.Id) + 1.0 / (K + rank + 1);

    foreach (var (chunk, rank) in keywordHits.Select((c, i) => (c, i)))
        scores[chunk.Id] = scores.GetValueOrDefault(chunk.Id) + 1.0 / (K + rank + 1);

    return scores
        .OrderByDescending(kv => kv.Value)
        .Take(limit)
        .Select(kv => store.Get(kv.Key))
        .OfType<Chunk>()
        .ToList();
}

Evaluation — этап, который все пропускают

Минимальный жизнеспособный вариант — 50–100 вопросов из реальной поддержки, покрывающих типовые сценарии:

Метрика Что показывает Порог для продакшна
Recall@5 Нашёл ли нужный документ в выдаче > 0.85
MRR Насколько высок ранжирован верный ответ > 0.75
Faithfulness Не выдумала ли LLM то, чего нет в контексте > 0.90
Answer relevancy Отвечает ли на вопрос, а не на смежную тему > 0.85

Faithfulness — метрика, которую чаще всего игнорируют, и именно она определяет, пойдёт ли система в прод. Модель, уверенно выдумывающая несуществующий пункт договора, хуже, чем честно сказавшая «не нашла».

Стоимость: два рычага

Кеш эмбеддингов. Документы меняются редко — пересчитываем только дельту. Это сразу снимает 70–80% повторных затрат на эмбеддинг.

Маршрутизация по сложности. Простые вопросы («где реквизиты») дешёвой моделью, сложные с рассуждением — дорогой. На типовом проекте это даёт снижение счёта в 3–4 раза без потери качества.

public sealed class Router(ModelCatalog catalog) : IModelRouter
{
    public IModel Select(Question q) => q switch
    {
        { RequiresReasoning: true } => catalog.Advanced,
        { EntityLookup: true }      => catalog.Fast,   // точные идентификаторы
        _                            => catalog.Fast,
    };
}

Что обязательно до продакшна

  • Набор из 50+ eval-вопросов из реальных обращений
  • Recall@5 выше 0.85 на этом наборе
  • Чанкинг по структуре, а не по фиксированному окну
  • Гибридный поиск, а не только векторный
  • Faithfulness замеряется, а не предполагается
  • Кеширование эмбеддингов настроено
  • Трейсинг каждого шага доступен в проде

Пункт 7 отсутствует чаще всего — и когда в проде что-то ломается, диагностировать нечем.

Если хотите разобрать свой пайплайн на конкретике — напишите нам.

Читайте также

Заявка

Обсудить задачу

Оставьте имя и email — отвечу в течение рабочего дня. Если задача срочная, проще написать напрямую наproject@anetlab.ru.