RAG на .NET: архитектура, которая переживает продакшн
Разбираем, почему наивный RAG ломается на реальных данных, и строим пайплайн на .NET: чанкинг, гибридный поиск, evaluation и cost-control.
Десятки команд пытаются внедрить 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 отсутствует, и когда в проде что-то ломается, диагностировать нечем.
Если хотите обсудить свой пайплайн — напишите, разберём на конкретике.
Оценка качества RAG на .NET: Recall@5, MRR и гейт релиза
Как измерять качество RAG на .NET: Golden Set из 50–100 вопросов, расчёт Recall@5 и MRR, измерение faithfulness и eval-как-гейт-релиза в CI.
.NET и Azure для AI-систем: референсная архитектура
Как собрать production-ready AI-систему на .NET и Azure: async-пайплайн, RAG, Polly, observability и контроль стоимости — с рабочим кодом.