Зачем 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 сам по себе не гарантирует правды. Модель всё равно может домыслить. Снижаем риски:
- Strict prompt -- явное "отвечай только на основе контекста, иначе скажи 'не знаю'"
- Low temperature (0.1-0.3) -- меньше креатива
- Citations -- требуем ссылки на
[#N]в ответе, парсим и валидируем - Groundedness check -- вторым вызовом спрашиваем: "содержится ли это утверждение в контексте?"
- Self-consistency -- генерируем 3 ответа, выбираем наиболее согласованный
- 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-Krag_generation_latency_ms(histogram) -- вызов LLMrag_tokens_input/output(counter) -- для биллингаrag_retrieved_chunks(gauge) -- сколько пришло после rerankrag_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.