Модуль 10. pgvector и RAG для LLM (опциональный) 🧪

Цель модуля: добавить в арсенал третий мир данных из Модуля 1 — векторный поиск. Собрать RAG-пайплайн на PostgreSQL: от типов и индексов pgvector до гибридного поиска и честной оценки «когда PG перестаёт хватать».

Статус модуля: не входит в основную линию курса к MVP. Единственный модуль с Python-инструментарием (эмбеддинги). Проходите параллельно с Модулями 7–9 или после курса — на пайплайн и MVP он не влияет.

Перед началом: Модуль 2 (индексы, SQL-арсенал); для 10.5 — базовый Python. Инструменты: PG 16 + pgvector ≥ 0.7, Python 3.11+ (sentence-transformers / любой embeddings-API), Go 1.22+ для слоя выборки. Трудоёмкость: ~14–16 ч (теория 5 ч · практика 8–9 ч · квиз 0.5 ч).

🔥 Инцидент: бот, который уверенно выдумал ответ

OrderFlow вырос, и поддержка попросила внутреннего ассистента: к базе знаний курса прикрутили RAG на PostgreSQL. На вопрос «где заказ 48151?» бот ответил гладко, с правильной интонацией и без единой запинки: «Заказ 48151 доставлен клиенту 12 сентября». В базе заказ был отменён и возвращён ещё неделю назад — оператор нашёл расхождение случайно, когда клиент прислал скриншот.

Разбор выдачи показал странное: чистый векторный поиск честно вытащил десять «похожих по смыслу» чанков — про доставки, статусы, возвраты, — но ни в одном не встречался номер 48151. Эмбеддинги сглаживают цифры и коды: 48151 и 48152 для модели почти неразличимы, а слова весят куда больше. Recall на смысловых вопросах держался на 0.9, а на запросах с точными идентификаторами падал до 0.4 — и бот при этом не сомневался ни секунды.

Сначала подняли ef_search и подмешали больше документов: ответы стали длиннее и увереннее, но по-прежнему не про тот заказ. Не хватало второго канала поиска — по словам и числам — и правила, когда верить цитате, а не красивой формулировке. Модуль разбирает pgvector, индексы HNSW и IVFFlat, гибридный поиск с tsvector и RRF — и то, как собрать RAG, который отвечает с цитатами или честно признаётся, что не нашёл.

📖 10.1. Основы векторного поиска: тип vector и метрики

Что такое эмбеддинг

Эмбеддинг — вектор фиксированной размерности, в котором «похожесть» текстов (или картинок, аудио) стала геометрической близостью. Модель эмбеддингов (openai text-embedding-3-small — 1536, multilingual-e5 — 1024, bge-m3 — 1024) переводит чанк текста в точку пространства; поиск «похожих» — поиск ближайших соседей (kNN).

Тип vector и метрики расстояния

sql
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE kb_chunks (
  id        bigserial PRIMARY KEY,
  source    text NOT NULL,          -- 'modules/06-pipeline.md'
  heading   text,
  chunk     text NOT NULL,
  embedding vector(1536) NOT NULL   -- размерность = размерности модели
);

vector(1536) — фиксированная размерность: все строки обязаны иметь ровно 1536 компонент float4. Смените модель эмбеддингов — меняйте колонку и пересчитывайте всё: векторы разных моделей не сравнимы между собой.

МетрикаОператорФормулаКогда
L2 (евклидово)<->корень из суммы квадратов разностейненормированные векторы, «геометрическая» близость
Косинусное<=>1 − cos(a, b)текстовые эмбеддинги — выбор по умолчанию
Inner product<#>−(a·b)нормированные векторы (тогда эквивалентен косинусу с точностью до монотонности)

Для текстовых моделей, нормирующих вектор на единичную длину, косинус и inner product дают одинаковый порядок результатов; метрика должна совпадать с той, на которой модель обучалась — это написано в её карточке.

sql
SELECT chunk FROM kb_chunks
ORDER BY embedding <=> '[0.013, -0.042, ...]'::vector  -- косинусное расстояние
LIMIT 10;

pgvector vs отдельная Vector DB: честная таблица

КритерийpgvectorQdrant / Pinecone / Weaviate
Инфраструктураextension в PG, которым уже владеетеотдельный кластер: бэкапы, мониторинг, версии — заново
Транзакции, FK, джойны с бизнес-даннымиполный SQL: фильтры по тенанту/правам/датам в том же запросесвои фильтры, честных транзакций нет
Безопасность, RLS, бэкапыто, что уже настроено (Модуль 8)свой стек с нуля
Объёмычестно до ~10–50M векторов (индекс живёт в RAM)миллиарды, шардинг из коробки
Частые обновления векторовдороже: перестройка сегментов индексаоптимизированы под это
Гибридный поисквекторы + FTS + SQL в одном запросесвои механизмы, часто слабее

Вердикт: пока векторов до десятков миллионов и они связаны с бизнес-данными (фильтры по тенанту, права, даты) — pgvector выигрывает суммой факторов, а не только экономией на кластере. Миллиарды векторов с жёсткими p99 — отдельная Vector DB, и это будет осознанный переезд (10.5), а не хайп.

📖 10.2. Индексирование: HNSW vs IVFFlat

Точное kNN (ORDER BY embedding <=> q LIMIT 10) — это скан всех векторов: на миллионах строк секунды. Индексы pgvector — приближённые (ANN): платим малой долей пропущенных соседей (recall) за миллисекунды.

HNSW: граф навигируемых малых миров

sql
CREATE INDEX ON kb_chunks
  USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 64);

-- на сессию/запрос: ширина поиска
SET hnsw.ef_search = 100;
  • m (8–48) — число связей узла графа: больше — точнее и тяжелее (память, вставки).
  • ef_construction (32–200) — ширина списка кандидатов при построении: выше — качественнее граф и дольше build.
  • ef_search — ширина поиска на запросе: главный регулятор recall ↔ latency в проде; его и крутят на инцидентах, не перестраивая индекс.

IVFFlat: инвертированные кластеры

sql
CREATE INDEX ON kb_chunks
  USING ivfflat (embedding vector_cosine_ops)
  WITH (lists = 500);
SET ivfflat.probes = 16;

IVFFlat делит пространство k-means-кластерами: ищем только в probes ближайших кластерах из lists. Правила: lists ≈ sqrt(N) или столько, чтобы в кластере было ~1–10k строк; probes — от 1 (быстро, теряете) до sqrt(lists). Важно: кластеры обучаются на данных — индекс создавайте уже на наполненной таблице (или пересоздавайте после большой загрузки).

HNSWIVFFlat
Recall на равной латентностивышениже, зависит от probes
Скорость построениямедленнеебыстрее
Вставки после buildдружелюбнеекластеры «стареют», нужен rebuild
Памятьбольшеменьше

Практический дефолт — HNSW (m=16, ef_construction=64), ef_search подбирается нагрузочным прогоном.

Как честно измерить recall

Приближённость без измерения — это «кажется». Берём 30–50 реальных запросов, для каждого считаем точный топ-10 и индексный топ-10, меряем пересечение:

sql
-- точный ответ: отключим использование индекса для этого запроса
SET enable_indexscan = off;
SELECT id FROM kb_chunks
ORDER BY embedding <=> $1::vector
LIMIT 10;

recall@10 = |точный ∩ приближённый| / 10, усредняем. Меньше 0.95 — поднимайте ef_search/probes, а не надежду.

📖 10.3. Гибридный поиск: pgvector + tsvector + RRF

Эмбеддинги ловят смысл («как настроить отслеживание лага» найдёт «мониторинг отставания consumer group»), но проваливаются на точных токенах: артикулы, имена собственные, аббревиатуры, номера версий. Полнотекстовый поиск — ровно наоборот. Гибрид берёт лучшее из обоих.

Подготовка: FTS-колонка рядом с вектором

sql
ALTER TABLE kb_chunks ADD COLUMN tsv tsvector
  GENERATED ALWAYS AS (
    to_tsvector('russian', coalesce(heading, '') || ' ' || chunk)
  ) STORED;
CREATE INDEX idx_kb_tsv ON kb_chunks USING gin (tsv);

RRF: Reciprocal Rank Fusion

Сливаем два ранжированных списка без калибровки шкал (векторные расстояния и ts_rank — несравнимы; складывать их — категориальная ошибка). RRF оперирует только позициями: score = Σ 1 / (k + rank), классический k = 60:

sql
WITH semantic AS (
    SELECT id, chunk,
           ROW_NUMBER() OVER (ORDER BY embedding <=> $1::vector) AS rank
    FROM kb_chunks
    ORDER BY embedding <=> $1::vector
    LIMIT 50
),
lexical AS (
    SELECT id, chunk,
           ROW_NUMBER() OVER (
             ORDER BY ts_rank(tsv, websearch_to_tsquery('russian', $2)) DESC
           ) AS rank
    FROM kb_chunks
    WHERE tsv @@ websearch_to_tsquery('russian', $2)
    LIMIT 50
)
SELECT s.id,
       s.chunk,
       COALESCE(1.0 / (60 + s.rank), 0)
         + COALESCE(1.0 / (60 + l.rank), 0) AS rrf_score
FROM semantic s
FULL OUTER JOIN lexical l ON s.id = l.id
ORDER BY rrf_score DESC
LIMIT 10;

Что здесь важно: оба подзапроса ограничены (LIMIT 50) — fusion работает по «коротким спискам», поэтому дёшев; документ, найденный обеими системами, получает сумму и всплывает. k=60 гасит разрыв между рангом 1 и рангом 2 — слишком маленький k делает топ-1 всемогущим.

Этот же запрос — мостик к Модулю 2.4: tsvector из SQL-арсенала работает не только в полнотекстовых фичах, но и в AI-пайплайнах.

📖 10.4. Масштабирование: halfvec, бит-квантование, партиции

Классическая арифметика: вектор 1536 float4 ≈ 6 KB; 10 млн векторов — 60 GB только данных, плюс индекс. Дальше три честных рычага.

halfvec: fp16

sql
ALTER TABLE kb_chunks
  ADD COLUMN embedding_h halfvec(1536)
  GENERATED ALWAYS AS (embedding::halfvec(1536)) STORED;

CREATE INDEX ON kb_chunks USING hnsw (embedding_h halfvec_cosine_ops);

Половина памяти (fp16 против fp32) при почти неизменном качестве поиска для текстовых эмбеддингов. Индекс строится по halfvec — хранить оба представления или конвертировать на лету — ваш выбор по бюджету RAM.

Бит-квантование: грубый отсев + переранжирование

sql
ALTER TABLE kb_chunks
  ADD COLUMN embedding_b bit(1536)
  GENERATED ALWAYS AS (binary_quantize(embedding)::bit(1536)) STORED;
CREATE INDEX ON kb_chunks USING hnsw (embedding_b bit_hamming_ops);

-- двухэтапный запрос: топ-200 по битам → переранжировать полными векторами
WITH coarse AS (
    SELECT id, embedding
    FROM kb_chunks
    ORDER BY embedding_b <~> binary_quantize($1::vector)  -- hamming
    LIMIT 200
)
SELECT id, chunk
FROM coarse
ORDER BY embedding <=> $1::vector
LIMIT 10;

1 бит на компоненту — в 32 раза меньше данных на этапе отсева; финальный порядок восстанавливает переранжирование полными векторами. Схема «грубый ANN → точный rerank по малому подмножеству» — общий паттерн индустрии, не только pgvector.

Партиционирование — с открытыми глазами

sql
CREATE TABLE kb_chunks (
  ... ,
  created_at timestamptz NOT NULL DEFAULT now()
) PARTITION BY RANGE (created_at);

Подводный камень: индекс HNSW строится на каждую партицию, глобального индекса нет. Запрос без фильтра по ключу партиционирования обходит все партиции и все их индексы. Партиционирование помогает, когда фильтр по ключу (тенант, период) есть в каждом запросе — тогда это и prune, и дешевле retention. Иначе — отдельные таблицы per tenant или, честно, отдельная Vector DB.

Ещё рычаг для фильтруемых запросов (pgvector ≥ 0.8): итеративные сканы — SET hnsw.iterative_scan = relaxed_order; — индекс продолжает «докручивать» поиск, если фильтр выкинул слишком много соседей, и в выборке снова есть LIMIT строк.

📖 10.5. RAG-пайплайн: от чанков до ответа с цитатами

Пайплайн

[корпус документов] → chunking → embeddings (батчами) → PostgreSQL+pgvector
                                                              │
[вопрос пользователя] → embedding → гибридный поиск (10.3) ←──┘
        → top-k чанков + источники → prompt → LLM → ответ с цитатами

Chunking — половина качества RAG

  • 300–800 токенов на чанк; резать по заголовкам/абзацам (структуре!), а не по символам.
  • Overlap 10–20%: мысль, разрезанная границей, не теряется — контекст дублируется между соседями.
  • Метаданные (источник, заголовок, раздел) — в колонки: они нужны фильтрам, цитатам и отладке. source + heading из нашей схемы — пример.

Эмбеддинги: Python-скрипт заливки

Единственное место курса с Python — эмбеддинги исторически живут в Python-инфраструктуре; serving-слой остаётся на Go.

python
# ingest.py — режем модули курса на чанки и заливаем в pgvector
import pathlib
import psycopg
from sentence_transformers import SentenceTransformer

# NB: размерность модели = vector(N) в схеме!
# multilingual-e5-large -> 1024; для vector(1536) возьмите 1536-мерную модель
model = SentenceTransformer("intfloat/multilingual-e5-large")

def chunk_by_headings(text: str, max_chars: int = 1200, overlap: int = 150):
    # упрощённо: рез по заголовкам markdown, затем по длине с перекрытием
    ...

rows = []
for md in pathlib.Path("modules").glob("*.md"):
    for ch in chunk_by_headings(md.read_text()):
        emb = model.encode("passage: " + ch["text"], normalize_embeddings=True)
        rows.append((md.name, ch["heading"], ch["text"], emb.tolist()))

with psycopg.connect("host=localhost dbname=rag user=app") as conn:
    with conn.cursor() as cur:
        cur.executemany(
            "INSERT INTO kb_chunks (source, heading, chunk, embedding) "
            "VALUES (%s, %s, %s, %s)", rows)
    conn.commit()

Правила гигиены: батч эмбеддингов (по 64–256 чанков), prefix согласно карточке модели (passage:/query: у e5), и одна модель на индексацию и поиск — навсегда.

Выборка из Go

go
// retrieval: гибридный запрос из 10.3 тем же SQL
rows, err := pool.Query(ctx, hybridRRFSQL,
    pgvector.NewVector(queryVec), question)

LLM-интеграция

  • Top-k (обычно 5–10) чанков попадают в промпт с явными ссылками на source + heading — модель отвечает с цитатами, а вы проверяете факты.
  • Чем длиннее контекст — тем дороже и «рассеяннее» модель; rerank (cross-encoder) поверх top-50 → top-5 часто повышает качество ответа дешевле, чем рост k.
  • Метрики качества — retrieval-метрики (recall@k, MRR на 30–50 эталонных вопросах), а не «померилось по ощущениям». RAG без eval-набора — гадание.

Когда PG тесен: признаки переезда

  • Векторы меряются миллиардами, индекс не влезает в RAM, build — часы.
  • Постоянные горячие обновления векторов на больших объёмах (персонализация в реалтайме).
  • Жёсткий p99 < 20ms на сотнях QPS чисто векторного поиска.
  • SQL-фильтры всё ещё нужны? Тогда не спешите: гибрид с бизнес-данными — сильнейшая сторона pgvector; уходя в Vector DB, вы теряете джойны.

Переезд (export → import в Qdrant и т.п.) — отдельный проект со своим backfill; закладывайте его как эволюцию, а не переписывание.

💻 Практика: RAG по базе знаний курса

Корпус — наши же модули modules/*.md. Итог — консольный «спроси курс», который отвечает с цитатами на модули.

  1. Корпус и чанки. Скрипт режет все модули по заголовкам на чанки 300–800 токенов с overlap 15%; в таблицу — source, heading, text.
  2. Эмбеддинги. Python-скрипт из 10.5 заливает векторы (модель согласована с vector(N)); замерьте время заливки.
  3. Индексы и честный recall. HNSW (m=16, ef_construction=64); build-время, размер индекса, recall@10 против точного поиска на 30 вопросах; подобрать ef_search на recall ≥ 0.95.
  4. Гибрид. Подключите tsv + RRF-запрос из 10.3. Найдите вопрос, который чисто семантический поиск промахивает, а гибрид ловит (артикул, термин, «Модуль 6») — и сохраните его в eval-набор.
  5. CLI-чат (опционально). Вопрос → гибридный retrieval → промпт с топ-5 чанками → ответ LLM с цитатами. Проверьте 10 вопросов из eval-набора и запишите, где ответ неверен из-за retrieval, а где из-за модели.

🧩 Quiz "Проверь себя"

1. Когда честно пора уезжать из pgvector в отдельную Vector DB? — выберите все подходящие
2. Какие запросы гибридный поиск (вектор + FTS) ловит лучше чисто семантического? — выберите все подходящие
3. Что делает RRF (Reciprocal Rank Fusion)?
4. Для нормированных текстовых эмбеддингов какая метрика и оператор — выбор по умолчанию?
5. Зачем overlap при chunking документов?
6. Что даёт halfvec(1536) относительно vector(1536)?
7. HNSW против IVFFlat — верная характеристика?
8. Что означает тип vector(1536) в pgvector?
9. Партиционировали таблицу с HNSW-индексом по дате. В чём подводный камень?
10. Что делает параметр hnsw.ef_search?

✅ Итоги модуля

  • Объяснить, что такое эмбеддинг и почему метрика поиска должна совпадать с моделью.
  • Выбирать метрику (L2/cosine/inner product) и обосновывать pgvector vs Vector DB цифрами, а не хайпом.
  • Строить HNSW/IVFFlat, тюнить m/ef_construction/ef_search и измерять recall@k против точного поиска.
  • Собирать гибридный поиск: tsvector + вектор + RRF без калибровки шкал.
  • Применять halfvec/бит-квантование с переранжированием и знать цену партиционирования векторов.
  • Собрать RAG-пайплайн с eval-набором и ответами с цитатами.

Шпаргалка модуля

ВопросОтвет
Метрика для текстовcosine (<=>); нормированные векторы — inner product эквивалентен
Дефолт-индексHNSW m=16, ef_construction=64; крутить ef_search, а не перестраивать
Recall честныйточный топ-10 (без индекса) vs индексный на 30–50 запросах; ≥ 0.95
Гибридsemantic top-50 + FTS top-50 → RRF Σ 1/(60+rank)
Сжатиеhalfvec = ½ памяти; bit = грубый отсев + rerank полными векторами
Партиции векторовиндекс на партицию, глобального нет — работает только с фильтром по ключу
Chunking300–800 токенов, overlap 10–20%, рез по структуре, метаданные в колонки
pgvector vs Vector DBдо ~10–50M + SQL-фильтры — pgvector; миллиарды/hot-updates/p99 — отдельная DB
evalretrieval-метрики (recall@k, MRR) на эталонных вопросах — до настройки промптов

🔗 Мостик к Модулю 11

Векторный мир изучен — возвращаемся на основную линию. Модуль 11 — MVP-проект «Аналитика OrderFlow»: шесть спринтов, за которые PostgreSQL (уже с HA и партициями), CDC-пайплайн и кластер ClickHouse 2×2 собираются в один защищаемый стенд. Если RAG стал частью вашей работы — этот модуль вами уже пройден вовремя; MVP он не касается.