HardПрактика8 min

RAG архитектура

Retrieval-Augmented Generation: pipeline ingestion/retrieval/generation, chunking, hybrid search, reranking, борьба с галлюцинациями

Зачем RAG

LLM имеет два фундаментальных ограничения: знания ограничены датой обучения и модель "галлюцинирует" -- генерирует правдоподобные, но ложные факты. Дообучать модель на своих данных дорого и медленно. RAG (Retrieval-Augmented Generation) решает обе задачи: мы не обучаем модель, а подкладываем ей нужные факты прямо в промпт во время генерации.

Идея простая: перед вызовом LLM находим в своей базе знаний релевантные фрагменты и вставляем их в контекст. Модель отвечает, опираясь на эти фрагменты, а не на "память". Это даёт три вещи:

  • Свежие данные -- обновляем базу, а не модель
  • Проверяемость -- можно показать источники ответа
  • Приватность -- документы остаются в нашей инфраструктуре

На платформе IT CRIB RAG работает в продакшене: OllamaService делает embeddings через локальный Ollama (nomic-embed-text), RagService хранит чанки в PostgreSQL с pgvector и генерирует ответы через mistral. Этот текст -- выжимка практических наблюдений.

Pipeline целиком

                            INGESTION (offline)
┌──────────┐    ┌─────────┐    ┌──────────┐    ┌─────────┐    ┌──────────┐
│ Documents│───>│ Parser  │───>│ Chunker  │───>│ Embedder│───>│ Vector DB│
│ (md/pdf) │    │ (clean) │    │ (split)  │    │ (model) │    │(pgvector)│
└──────────┘    └─────────┘    └──────────┘    └─────────┘    └──────────┘

                            RETRIEVAL (online)
  User query
      │
      ▼
┌──────────┐    ┌─────────┐    ┌──────────┐    ┌─────────┐
│ Embedder │───>│ Vector  │───>│  BM25    │───>│ Reranker│
│ (query)  │    │ kNN     │    │ lexical  │    │ (cross) │
└──────────┘    └─────────┘    └──────────┘    └─────────┘
                     │              │               │
                     └──────────────┴───────────────┘
                                    ▼
                             top-K chunks
                                    │
                                    ▼
                            GENERATION (online)
                     ┌──────────────────────────┐
                     │ Prompt template:         │
                     │  - system instructions   │
                     │  - retrieved context     │
                     │  - user question         │
                     └──────────────────────────┘
                                    │
                                    ▼
                              ┌─────────┐
                              │   LLM   │─────> Answer + citations
                              └─────────┘

Ingestion: разбиение и эмбеддинг

Стратегии чанкинга

Наивный подход "1 документ = 1 эмбеддинг" работает только для коротких текстов. Реальные документы режут на фрагменты (chunks). Размер чанка -- компромисс: слишком маленький теряет контекст, слишком большой размывает семантику и ухудшает recall.

Стратегия Как режет Плюсы Минусы
Fixed-size По символам/токенам (например, 512 токенов) Просто, предсказуемо Режет по середине предложений
Sentence-based По предложениям Сохраняет грамматику Переменный размер
Recursive Иерархия разделителей (\n\n → \n → . → ) Уважает структуру Сложнее настроить
Semantic По смысловым границам (similarity drop) Высокое качество Дорого на ingestion
Document-aware По заголовкам markdown, секциям кода Идеально для структурированных данных Требует парсер

Overlap -- частичное перекрытие соседних чанков (10-20%). Снижает риск, что важная фраза окажется на границе и ни один чанк её не захватит полностью.

Практический совет: для русскоязычных технических текстов хорошо работает recursive с размером 400-800 символов и overlap 100. В IT CRIB используется 400 символов -- меньше потому, что nomic-embed-text плохо справляется с длинными кириллическими строками (>5000 символов вообще падает).

Индексация в pgvector

-- Table for RAG chunks
CREATE TABLE rag_chunks (
    id           BIGSERIAL PRIMARY KEY,
    source_id    VARCHAR(255) NOT NULL,   -- link to original document
    source_type  VARCHAR(50)  NOT NULL,   -- article, qa, code
    chunk_index  INT          NOT NULL,
    content      TEXT         NOT NULL,
    tokens       INT          NOT NULL,
    embedding    vector(768)  NOT NULL,   -- nomic-embed-text = 768 dim
    metadata     JSONB        NOT NULL DEFAULT '{}',
    created_at   TIMESTAMPTZ  NOT NULL DEFAULT NOW()
);

-- HNSW index for fast approximate nearest neighbor search
CREATE INDEX rag_chunks_embedding_idx
    ON rag_chunks
    USING hnsw (embedding vector_cosine_ops)
    WITH (m = 16, ef_construction = 64);

-- BTree for metadata filtering
CREATE INDEX rag_chunks_source_idx ON rag_chunks (source_type, source_id);

-- GIN for JSONB metadata queries
CREATE INDEX rag_chunks_metadata_idx ON rag_chunks USING gin (metadata jsonb_path_ops);

Retrieval: vector + keyword + rerank

Pure vector search не достаточно

Векторный поиск хорош для семантических связей ("автомобиль" ≈ "машина"), но проваливается на точных терминах, кодах ошибок, именах API. HTTP 429 и HTTP 500 почти одинаковы косинусно, хотя смысл разный. Решение -- hybrid search: параллельно запускаем векторный поиск и BM25 (полнотекстовый), потом объединяем результаты через RRF (Reciprocal Rank Fusion).

score_rrf(d) = Σ 1 / (k + rank_i(d))

где rank_i(d) -- ранг документа в i-м источнике, k = 60 (стандарт).

Reranking

Первичный поиск возвращает top-50 кандидатов. Применяем cross-encoder (например, bge-reranker-base или ms-marco-MiniLM) -- маленькую модель, которая оценивает пары (query, chunk) и выдаёт более точную релевантность. Оставляем top-5. Это заметно улучшает качество ответа, но добавляет 50-200 мс латентности.

initial search (ANN)      rerank (cross-encoder)     LLM context
top-50 by cosine  ─────>  top-5 by relevance  ─────>  5 chunks
  ~5 ms                     ~100 ms                    in prompt

Реализация: Symfony + Ollama + pgvector

Типизированные DTO, thin handler, structured logging. Построено по паттернам IT CRIB (RagService).

<?php

declare(strict_types=1);

namespace App\Rag\Service;

use App\Rag\Dto\RetrievedChunk;
use App\Rag\Dto\RagAnswer;
use Doctrine\DBAL\Connection;
use Psr\Log\LoggerInterface;

/**
 * Retrieval-Augmented Generation service.
 *
 * Pipeline: query -> embed -> hybrid search -> rerank -> prompt -> LLM.
 * Embeddings via Ollama (nomic-embed-text), storage in PostgreSQL/pgvector,
 * generation via OpenAI-compatible API.
 */
final class RagService
{
    private const TOP_K_CANDIDATES = 50;
    private const TOP_K_CONTEXT    = 5;
    private const MAX_CONTEXT_CHARS = 3500;

    public function __construct(
        private readonly OllamaEmbedderInterface $embedder,
        private readonly ChatClientInterface $chat,
        private readonly RerankerInterface $reranker,
        private readonly Connection $db,
        private readonly LoggerInterface $logger,
    ) {}

    public function ask(string $question, ?string $sectionFilter = null): RagAnswer
    {
        $startedAt = microtime(true);

        // 1. Embed the query
        $queryEmbedding = $this->embedder->embed($question);

        // 2. Hybrid retrieval (vector + BM25 via to_tsvector)
        $candidates = $this->hybridSearch($queryEmbedding, $question, $sectionFilter);

        if ($candidates === []) {
            return RagAnswer::noContext($question);
        }

        // 3. Rerank with cross-encoder
        $reranked = $this->reranker->rerank($question, $candidates, self::TOP_K_CONTEXT);

        // 4. Build prompt with citations
        $prompt = $this->buildPrompt($question, $reranked);

        // 5. Call LLM
        $answer = $this->chat->complete($prompt, maxTokens: 800, temperature: 0.2);

        $this->logger->info('rag.answered', [
            'question_len'   => mb_strlen($question),
            'candidates'     => count($candidates),
            'context_chunks' => count($reranked),
            'latency_ms'     => (int) ((microtime(true) - $startedAt) * 1000),
        ]);

        return new RagAnswer(
            question: $question,
            answer: $answer,
            sources: array_map(fn (RetrievedChunk $c) => $c->sourceId, $reranked),
        );
    }

    /**
     * @return list<RetrievedChunk>
     */
    private function hybridSearch(
        array $queryEmbedding,
        string $questionText,
        ?string $sectionFilter,
    ): array {
        // Cosine distance (pgvector: <=>) + BM25-like ts_rank
        $sql = <<<'SQL'
            WITH vector_hits AS (
                SELECT id, content, source_id, source_type, metadata,
                       1 - (embedding <=> :qvec) AS vec_score,
                       row_number() OVER (ORDER BY embedding <=> :qvec) AS vec_rank
                FROM rag_chunks
                WHERE (:section IS NULL OR metadata->>'section' = :section)
                ORDER BY embedding <=> :qvec
                LIMIT :k
            ),
            keyword_hits AS (
                SELECT id, content, source_id, source_type, metadata,
                       ts_rank(to_tsvector('russian', content),
                               plainto_tsquery('russian', :qtext)) AS kw_score,
                       row_number() OVER (ORDER BY ts_rank(to_tsvector('russian', content),
                               plainto_tsquery('russian', :qtext)) DESC) AS kw_rank
                FROM rag_chunks
                WHERE to_tsvector('russian', content) @@ plainto_tsquery('russian', :qtext)
                  AND (:section IS NULL OR metadata->>'section' = :section)
                LIMIT :k
            )
            SELECT DISTINCT ON (id) id, content, source_id, source_type, metadata,
                   COALESCE(1.0 / (60 + vec_rank), 0) +
                   COALESCE(1.0 / (60 + kw_rank), 0) AS rrf_score
            FROM vector_hits
            FULL OUTER JOIN keyword_hits USING (id, content, source_id, source_type, metadata)
            ORDER BY id, rrf_score DESC
            LIMIT :k
        SQL;

        $rows = $this->db->fetchAllAssociative($sql, [
            'qvec'    => $this->formatVector($queryEmbedding),
            'qtext'   => $questionText,
            'section' => $sectionFilter,
            'k'       => self::TOP_K_CANDIDATES,
        ]);

        return array_map(
            fn (array $r) => new RetrievedChunk(
                id: (int) $r['id'],
                content: $r['content'],
                sourceId: $r['source_id'],
                sourceType: $r['source_type'],
                score: (float) $r['rrf_score'],
                metadata: json_decode($r['metadata'], true) ?? [],
            ),
            $rows,
        );
    }

    /**
     * @param list<RetrievedChunk> $chunks
     */
    private function buildPrompt(string $question, array $chunks): string
    {
        $context = '';
        $used = 0;

        foreach ($chunks as $i => $chunk) {
            $piece = sprintf("[#%d] %s\n", $i + 1, $chunk->content);

            if ($used + mb_strlen($piece) > self::MAX_CONTEXT_CHARS) {
                break;
            }

            $context .= $piece;
            $used += mb_strlen($piece);
        }

        return <<<PROMPT
            You are IT CRIB learning assistant. Answer the question using ONLY
            the provided context. If the answer is not in the context, say
            "в контексте нет ответа". Cite sources as [#1], [#2].

            CONTEXT:
            {$context}

            QUESTION: {$question}

            ANSWER (in Russian):
            PROMPT;
    }

    /**
     * @param list<float> $vec
     */
    private function formatVector(array $vec): string
    {
        return '[' . implode(',', $vec) . ']';
    }
}

Эквивалент на Go

Структура та же, но с отдельным embedder через HTTP, pgx и линейным pipeline.

package rag

import (
	"context"
	"encoding/json"
	"fmt"
	"log/slog"
	"strings"
	"time"

	"github.com/jackc/pgx/v5/pgxpool"
	"github.com/pgvector/pgvector-go"
)

// Chunk represents a retrieved document piece with relevance score.
type Chunk struct {
	ID         int64
	Content    string
	SourceID   string
	SourceType string
	Score      float32
	Metadata   map[string]any
}

// Answer is the RAG response with citations.
type Answer struct {
	Question string
	Text     string
	Sources  []string
}

// Service orchestrates the RAG pipeline.
type Service struct {
	embedder Embedder
	chat     ChatClient
	reranker Reranker
	db       *pgxpool.Pool
	log      *slog.Logger
}

func NewService(e Embedder, c ChatClient, r Reranker, db *pgxpool.Pool, log *slog.Logger) *Service {
	return &Service{embedder: e, chat: c, reranker: r, db: db, log: log}
}

const (
	topKCandidates  = 50
	topKContext     = 5
	maxContextChars = 3500
)

// Ask runs the full RAG pipeline for a user question.
func (s *Service) Ask(ctx context.Context, question, section string) (*Answer, error) {
	started := time.Now()

	qvec, err := s.embedder.Embed(ctx, question)
	if err != nil {
		return nil, fmt.Errorf("embed query: %w", err)
	}

	candidates, err := s.hybridSearch(ctx, qvec, question, section)
	if err != nil {
		return nil, fmt.Errorf("hybrid search: %w", err)
	}
	if len(candidates) == 0 {
		return &Answer{Question: question, Text: "в контексте нет ответа"}, nil
	}

	top, err := s.reranker.Rerank(ctx, question, candidates, topKContext)
	if err != nil {
		return nil, fmt.Errorf("rerank: %w", err)
	}

	prompt := buildPrompt(question, top)
	text, err := s.chat.Complete(ctx, prompt, 800, 0.2)
	if err != nil {
		return nil, fmt.Errorf("chat: %w", err)
	}

	sources := make([]string, 0, len(top))
	for _, c := range top {
		sources = append(sources, c.SourceID)
	}

	s.log.Info("rag.answered",
		"latency_ms", time.Since(started).Milliseconds(),
		"candidates", len(candidates),
		"context_chunks", len(top),
	)
	return &Answer{Question: question, Text: text, Sources: sources}, nil
}

func (s *Service) hybridSearch(ctx context.Context, qvec []float32, q, section string) ([]Chunk, error) {
	// Same hybrid SQL as PHP version -- vector + BM25 fused via RRF.
	const sql = `
        WITH vhit AS (
            SELECT id, content, source_id, source_type, metadata,
                   row_number() OVER (ORDER BY embedding <=> $1) AS r
            FROM rag_chunks
            WHERE ($2 = '' OR metadata->>'section' = $2)
            ORDER BY embedding <=> $1 LIMIT $3),
        khit AS (
            SELECT id, content, source_id, source_type, metadata,
                   row_number() OVER (ORDER BY ts_rank(
                       to_tsvector('russian', content),
                       plainto_tsquery('russian', $4)) DESC) AS r
            FROM rag_chunks
            WHERE to_tsvector('russian', content) @@ plainto_tsquery('russian', $4)
              AND ($2 = '' OR metadata->>'section' = $2)
            LIMIT $3)
        SELECT DISTINCT ON (id) id, content, source_id, source_type, metadata,
               COALESCE(1.0/(60 + vhit.r), 0) + COALESCE(1.0/(60 + khit.r), 0) AS score
        FROM vhit FULL OUTER JOIN khit USING (id, content, source_id, source_type, metadata)
        ORDER BY id, score DESC LIMIT $3`

	rows, err := s.db.Query(ctx, sql, pgvector.NewVector(qvec), section, topKCandidates, q)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var out []Chunk
	for rows.Next() {
		var c Chunk
		var metaBytes []byte
		if err := rows.Scan(&c.ID, &c.Content, &c.SourceID, &c.SourceType, &metaBytes, &c.Score); err != nil {
			return nil, err
		}
		_ = json.Unmarshal(metaBytes, &c.Metadata)
		out = append(out, c)
	}
	return out, rows.Err()
}

func buildPrompt(question string, chunks []Chunk) string {
	var b strings.Builder
	used := 0
	for i, c := range chunks {
		piece := fmt.Sprintf("[#%d] %s\n", i+1, c.Content)
		if used+len(piece) > maxContextChars {
			break
		}
		b.WriteString(piece)
		used += len(piece)
	}
	return fmt.Sprintf(`You are IT CRIB learning assistant. Answer using ONLY the provided context.
If the answer is not in the context, say "в контексте нет ответа". Cite as [#1], [#2].

CONTEXT:
%s
QUESTION: %s
ANSWER (in Russian):`, b.String(), question)
}

Context window management

LLM имеют ограниченное окно. mistral-7b -- 8k/32k токенов, GPT-4o -- 128k. Но effective окно меньше: ближе к середине длинного контекста модель начинает "терять" информацию ("lost in the middle" эффект).

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

  • Ставьте самый релевантный чанк в начало или конец контекста, не в середину
  • Лимит чанков -- обычно 3-8 штук, дальше шум
  • Дедупликация -- несколько чанков из одного документа объединяйте
  • Squeeze -- перед отправкой пропустите чанки через суммаризатор, если они длинные

Борьба с галлюцинациями

RAG сам по себе не гарантирует правды. Модель всё равно может домыслить. Снижаем риски:

  1. Strict prompt -- явное "отвечай только на основе контекста, иначе скажи 'не знаю'"
  2. Low temperature (0.1-0.3) -- меньше креатива
  3. Citations -- требуем ссылки на [#N] в ответе, парсим и валидируем
  4. Groundedness check -- вторым вызовом спрашиваем: "содержится ли это утверждение в контексте?"
  5. Self-consistency -- генерируем 3 ответа, выбираем наиболее согласованный
  6. Guardrails -- блокируем ответы, где уверенность < порога
// Post-processing: verify citations
private function verifyCitations(string $answer, array $chunks): bool
{
    preg_match_all('/\[#(\d+)\]/', $answer, $m);
    $cited = array_unique(array_map('intval', $m[1]));

    foreach ($cited as $n) {
        if (!isset($chunks[$n - 1])) {
            $this->logger->warning('rag.invalid_citation', ['ref' => $n]);
            return false;
        }
    }
    return $cited !== [];
}

Observability

Метрики, без которых RAG не отлаживается в проде:

  • rag_retrieval_latency_ms (histogram) -- от запроса до получения top-K
  • rag_generation_latency_ms (histogram) -- вызов LLM
  • rag_tokens_input/output (counter) -- для биллинга
  • rag_retrieved_chunks (gauge) -- сколько пришло после rerank
  • rag_empty_context (counter) -- сколько запросов не нашли контекста
  • rag_invalid_citations (counter) -- сколько ответов без валидных ссылок

Структурированные логи: question_hash, latency_ms, context_chunks, llm_model, user_id.

Типичные провалы и их причины

Симптом Причина Фикс
Модель отвечает "не знаю" на очевидное Порог similarity слишком высокий Снизить threshold, увеличить top-K
Модель галлюцинирует Слабый prompt или слишком много нерелевантных чанков Ужесточить prompt, добавить reranker
Ответы не соответствуют свежим данным Индекс не переиндексирован Настроить incremental re-embed по updated_at
Запрос "HTTP 429" находит статьи про 500 Чистый vector search без BM25 Добавить hybrid
Длинный чанк обрывается в середине фразы Fixed-size без sentence boundary Recursive chunker
Кириллица плохо эмбеддится Model не поддерживает язык paraphrase-multilingual-mpnet или nomic-embed-text с ограничением на длину

Выводы

  • RAG -- это retrieve + generate, а не "спроси у LLM". Качество ответа определяется качеством retrieval.
  • Chunking -- главный knob. Recursive + overlap 100 символов -- хороший дефолт для технических текстов.
  • Pure vector search недостаточен: hybrid (BM25 + vector) через RRF даёт заметный выигрыш на терминах и кодах.
  • Reranker (cross-encoder) -- +100 мс, но кардинально чище top-5.
  • Галлюцинации бьём комбинацией: strict prompt + citations + groundedness check + low temperature.
  • На IT CRIB RAG работает на связке Ollama (nomic-embed-text + mistral) + pgvector с HNSW -- всё локально, без внешних API.