RAG внедрение с Ollama в C#: практический кейс для системы отчетности | AiManual
AiManual Logo Ai / Manual.
16 Янв 2026 Гайд

Реальный кейс: как внедрить RAG с Ollama в C# бэкенд для системы отчетности (и что пошло не так)

Подробный разбор реального внедрения RAG с Ollama в C# бэкенд для госзаказчика. Проблемы масштабирования, web api интеграция и уроки из production.

Задача: сделать AI-аналитика из кучи PDF-отчетов

Госзаказчик. Ежегодно 500+ PDF-отчетов по строительным объектам. Каждый отчет - 50-200 страниц технических спецификаций, финансовых таблиц и заключений экспертов.

Пользователи (аналитики министерства) тратят недели на поиск информации. Нужно было создать систему, где можно спросить: "Покажи все объекты с превышением сметы в Московской области за 2023 год" и получить структурированный ответ с ссылками на документы.

Техническое ограничение №1: все должно работать в закрытом контуре. Никаких OpenAI API. Все локально. Бюджет - скромный. Сервера - существующие виртуалки с 32 ГБ RAM.

Архитектура: просто, как молоток

Выбрали классический RAG:

  • Векторная БД: Qdrant (легковесная, на C++, отличная C# совместимость)
  • LLM: Mistral 7B через Ollama (самая адекватная модель для наших ресурсов)
  • Бэкенд: ASP.NET Core 8 Web API
  • Эмбеддинги: all-MiniLM-L6-v2 (достаточно для русского технического текста)

Звучит разумно? На бумаге - да. В реальности - готовьтесь к сюрпризам.

1 Индексация документов: где мы ошиблись в самом начале

Первая ошибка - разбивать PDF на страницы. Кажется логичным: одна страница = один чанк. Но технические отчеты содержат таблицы, которые занимают несколько страниц. Разбивка постранично рвет таблицы пополам.

// КАК НЕ НАДО ДЕЛАТЬ
public async Task IndexDocument(string pdfPath)
{
    var pages = PdfReader.ExtractPages(pdfPath); // Разбивка по страницам
    
    foreach (var page in pages)
    {
        var embedding = await _embeddingService.GetEmbeddingAsync(page.Text);
        await _qdrantClient.UpsertAsync(collectionName, embedding, page.Metadata);
    }
}

Урок: разбивайте по семантическим границам. Таблицы - отдельно. Разделы документа - отдельно. Используйте заголовки как маркеры разбиения.

2 Интеграция Ollama с C#: проще, чем кажется (и сложнее)

Ollama предоставляет REST API. Казалось бы, просто HttpClient и готово. Но есть нюансы:

public class OllamaService
{
    private readonly HttpClient _httpClient;
    private readonly ILogger _logger;
    
    public async Task GenerateAsync(string prompt, List context)
    {
        // Контекст - это найденные релевантные чанки из Qdrant
        var fullPrompt = BuildPromptWithContext(prompt, context);
        
        var request = new
        {
            model = "mistral",
            prompt = fullPrompt,
            stream = false,
            options = new { temperature = 0.1 } // Низкая температура для фактов
        };
        
        var response = await _httpClient.PostAsJsonAsync(
            "http://localhost:11434/api/generate", 
            request);
            
        if (!response.IsSuccessStatusCode)
        {
            // Тут начинается веселье
            var errorContent = await response.Content.ReadAsStringAsync();
            _logger.LogError("Ollama error: {Error}", errorContent);
            
            // Ollama может вернуть 400, если модель не загружена
            // Или 500, если не хватает памяти
            // Или 503, если контейнер перезагружается
        }
        
        var result = await response.Content.ReadFromJsonAsync();
        return result?.Response ?? "Ошибка получения ответа";
    }
}

Проблема №1: таймауты. Mistral 7B на CPU думает 10-30 секунд на сложный запрос. Стандартный HttpClient.Timeout = 100 секунд. В продакшене с 10+ параллельными запросами это создает очередь.

Проблема №2: память. Ollama в контейнере по умолчанию жрет всю доступную RAM. Нужно явно ограничивать:

# Запуск Ollama с ограничением памяти
docker run -d \
  --name ollama \
  -p 11434:11434 \
  --memory="8g" \
  --memory-swap="8g" \
  ollama/ollama

# Загрузка модели с указанием количества слоев для GPU/CPU
ollama pull mistral:7b-instruct-q4_K_M

3 Промпт-инженерия для бюрократических документов

Государственные отчеты пишут по шаблону. Используйте это. Наш промпт выглядел так:

Ты - аналитик государственной отчетности. Отвечай строго на основе предоставленных документов.

Контекст:
{context}

Инструкции:
1. Если информации нет в контексте - скажи "В предоставленных документах нет данных"
2. Не придумывай цифры
3. Для финансовых данных указывай единицы измерения (тыс. руб., млн руб.)
4. Ссылайся на номера документов в квадратных скобках, например [Док-12]
5. Форматируй списки с использованием маркеров

Вопрос: {question}

Ответ:

Кажется, все учтено? А вот и нет. Модель все равно галлюцинировала. Особенно с цифрами. Почему? Потому что в технических отчетах числа пишут по-разному: "1,5 млн", "1500000", "1 500 000". Модель путалась.

💡
Решение: добавили пост-обработку ответов. Регулярные выражения для поиска чисел и их нормализация. Плюс - валидация: если в ответе есть число, которого нет в контексте - помечаем ответ как "требует проверки".

Что пошло не так: три главных провала

Провал 1: масштабирование индексации

500 PDF * 100 страниц = 50 000 документов для индексации. Эмбеддинг каждого чанка занимает ~300мс. Математика простая: 4 часа непрерывной работы.

Но в реальности:

  • Сервис эмбеддингов падал при параллельной обработке
  • Qdrant начинал тормозить после 20 000 векторов
  • Обновление индекса (добавление новых отчетов) блокировало поиск

Пришлось переписать архитектуру индексации:

// Новая архитектура с очередями и батчингом
public class IndexingService
{
    private readonly Channel _indexingChannel;
    private readonly List _batchBuffer = new();
    private const int BatchSize = 50;
    
    public async Task StartIndexingAsync(CancellationToken ct)
    {
        await foreach (var chunk in _indexingChannel.Reader.ReadAllAsync(ct))
        {
            _batchBuffer.Add(chunk);
            
            if (_batchBuffer.Count >= BatchSize)
            {
                await ProcessBatchAsync(_batchBuffer.ToArray());
                _batchBuffer.Clear();
            }
        }
    }
    
    private async Task ProcessBatchAsync(DocumentChunk[] chunks)
    {
        // Параллельное создание эмбеддингов
        var embeddingTasks = chunks
            .Select(chunk => _embeddingService.GetEmbeddingAsync(chunk.Text))
            .ToArray();
            
        var embeddings = await Task.WhenAll(embeddingTasks);
        
        // Батч-вставка в Qdrant
        await _qdrantClient.UpsertBatchAsync(collectionName, embeddings, chunks);
    }
}

Провал 2: качество поиска

Векторный поиск по техническим терминам работал плохо. "Сметная стоимость" и "расчетная стоимость" - синонимы в контексте отчетов, но эмбеддинги разные.

Решили гибридным поиском: векторный + лексический. Добавили Elasticsearch для keyword search и комбинировали результаты. Если вам интересна тема гибридного RAG, посмотрите статью про RAG 2026: roadmap, который работает - там подробно разбирают подобные кейсы.

Провал 3: мониторинг и диагностика

Пользователи жалуются: "Система дает неправильные ответы". Как это отладить?

Не хватало:

  • Логов, какие чанки были найдены для запроса
  • Скоринга релевантности каждого чанка
  • Истории промптов и ответов для анализа ошибок

Добавили полноценную телеметрию:

public class RagServiceWithTelemetry
{
    public async Task QueryAsync(string question)
    {
        var searchResult = await _vectorSearch.SearchAsync(question);
        
        // Логируем контекст для отладки
        _logger.LogInformation("Query: {Question}", question);
        _logger.LogInformation("Found {Count} chunks", searchResult.Chunks.Count);
        
        foreach (var chunk in searchResult.Chunks)
        {
            _logger.LogDebug("Chunk ID: {Id}, Score: {Score}, Source: {Source}", 
                chunk.Id, chunk.Score, chunk.SourceDocument);
        }
        
        var answer = await _ollama.GenerateAsync(question, searchResult.Chunks);
        
        // Сохраняем в БД для анализа качества
        await _analyticsRepository.SaveQueryAsync(new QueryAnalytics
        {
            Question = question,
            Answer = answer,
            RetrievedChunks = searchResult.Chunks,
            Timestamp = DateTime.UtcNow,
            UserId = userId
        });
        
        return new RagResponse { Answer = answer, Sources = searchResult.Chunks };
    }
}

Финал: что работает сейчас

После 3 месяцев доработок система работает. Не идеально, но пользователи экономят 70% времени на поиск информации.

Метрика Было Стало
Время поиска информации 2-3 часа 5-10 минут
Точность ответов ~60% (первые версии) ~85% (после доработок)
Стоимость инфраструктуры План: 50к руб/мес Факт: 120к руб/мес

Чеклист для вашего RAG на C#

  1. Тестируйте чанкинг на реальных данных - PDF, Word, Excel рвутся по-разному
  2. Ограничивайте память Ollama с первого дня - иначе она сожрет все ресурсы
  3. Добавляйте таймауты и retry логику - LLM API нестабильны по определению
  4. Внедряйте телеметрию сразу - без логов вы слепы
  5. Планируйте гибридный поиск - чистый векторный поиск редко работает в продакшене
  6. Бюджетируйте в 2-3 раза больше - особенно на инфраструктуру и доработки

Самая большая ошибка - считать RAG простой технологией. Это не "подключил API - получил умный поиск". Это сложная система, где каждая часть может сломаться. Особенно в C# экосистеме, где тулзов меньше, чем в Python.

Если столкнетесь с похожими проблемами анализа структурированных данных, посмотрите как мы строили RAG для анализа таблиц - там много пересекающихся проблем и решений.

Прогноз на 2025: RAG станет стандартом для корпоративных систем, но перестанет быть отдельной технологией. Встроится в СУБД, в поисковые движки, в системы документооборота. Сейчас вы пишете интеграции - через год будете выбирать из готовых решений.

Главный урок этого проекта: RAG - это не про искусственный интеллект. Это про инженерию. Про пайплайны, про мониторинг, про обработку ошибок. AI - всего лишь один компонент в цепочке. И часто - не самый проблемный.