3 мин чтения

RAG на .NET: архитектура, которая переживает продакшн

Разбираем, почему наивный RAG ломается на реальных данных, и строим пайплайн на .NET: чанкинг, гибридный поиск, evaluation и cost-control.

Виктор Бузин · Founder & Principal Engineer, AnetLab
16+ лет в enterprise-разработке: .NET, AI/LLM-системы, RAG, MCP. Автор материала и практик в студии.

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

Наивный RAG и почему он не работает

Классическая схема: разбить документы на чанки → эмбеддинги → векторный поиск → склеить в промпт → отдать LLM. На презентации это выглядит отлично. В проде вы получаете три класса проблем.

Проблема первый: чанкинг. Разрезали PDF на фиксированные 512 токенов — и половина ответов потеряла контекст. Вопрос «а какой у него SLA?» не находит ничего, потому что SLA упомянут в начале документа, а чанк с ответом — в середине.

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

Проблема третья: качество неизмеримо. Команда не может ответить на вопрос «стало ли лучше после нового чанкера». Есть ощущение, что да, но это ощущение.

Что мы делаем по-другому

Чанкинг по структуре, а не по длине

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

В .NET это удобно делать на этапе извлечения текста: мы знаем, где заголовок, где таблица, где подпись, и режем по этим границам, а не по с512 токенов.

Гибридный поиск

Векторный поиск плюс полнотекстовый. Вектор ловит смысл, FTS — точные совпадения. Объединяем через Reciprocal Rank Fusion:

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

    const double K = 60.0; // стандартный коэффициент RRF
    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 => kv.Value == null ? null : Lookup(kv.Key))
        .Where(c => c is not null)
        .Cast<Chunk>()
        .ToList();
}

Запрос «контракт 44-А/2024» теперь находит и по вектору, и по FTS — и RRF объединяет выдачу.

Evaluation как обязательный шаг

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

Мы считаем четыре метрики:

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

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

Стоимость

Два рычага: кеш на уровне эмбеддингов (документы меняются редко — переэмбеддинг только дельты) и маршрутизация по сложности (простые вопросы — дешёвая модель, сложные с рассуждением — дорогая).

На типовом проекте это даёт снижение счёта в 3–4 раза без потери качества.

Стек

  • .NET 10 — извлечение, чанкинг, пайплайн
  • Semantic Kernel — оркестрация, подключение моделей
  • Qdrant или pgvector — хранилища векторов
  • OpenTelemetry — трейсинг каждого шага

Чеклист перед продом

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

Самое частое, что мы видим на аудите: пункт 7 отсутствует, и когда в проде что-то ломается, диагностировать нечем.

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

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

Заявка

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

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