Модуль 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 и метрики расстояния
sqlCREATE 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 дают одинаковый порядок результатов; метрика должна совпадать с той, на которой модель обучалась — это написано в её карточке.
sqlSELECT chunk FROM kb_chunks ORDER BY embedding <=> '[0.013, -0.042, ...]'::vector -- косинусное расстояние LIMIT 10;
pgvector vs отдельная Vector DB: честная таблица
| Критерий | pgvector | Qdrant / 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: граф навигируемых малых миров
sqlCREATE 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: инвертированные кластеры
sqlCREATE 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). Важно: кластеры обучаются на данных — индекс создавайте уже
на наполненной таблице (или пересоздавайте после большой загрузки).
| HNSW | IVFFlat | |
|---|---|---|
| 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-колонка рядом с вектором
sqlALTER 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:
sqlWITH 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
sqlALTER 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.
Бит-квантование: грубый отсев + переранжирование
sqlALTER 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.
Партиционирование — с открытыми глазами
sqlCREATE 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. Итог — консольный «спроси курс»,
который отвечает с цитатами на модули.
- Корпус и чанки. Скрипт режет все модули по заголовкам на чанки 300–800 токенов с overlap 15%; в таблицу — source, heading, text.
- Эмбеддинги. Python-скрипт из 10.5 заливает векторы
(модель согласована с
vector(N)); замерьте время заливки. - Индексы и честный recall. HNSW (
m=16, ef_construction=64); build-время, размер индекса, recall@10 против точного поиска на 30 вопросах; подобратьef_searchна recall ≥ 0.95. - Гибрид. Подключите tsv + RRF-запрос из 10.3. Найдите вопрос, который чисто семантический поиск промахивает, а гибрид ловит (артикул, термин, «Модуль 6») — и сохраните его в eval-набор.
- CLI-чат (опционально). Вопрос → гибридный retrieval → промпт с топ-5 чанками → ответ LLM с цитатами. Проверьте 10 вопросов из eval-набора и запишите, где ответ неверен из-за retrieval, а где из-за модели.
🧩 Quiz "Проверь себя"
✅ Итоги модуля
- Объяснить, что такое эмбеддинг и почему метрика поиска должна совпадать с моделью.
- Выбирать метрику (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 полными векторами |
| Партиции векторов | индекс на партицию, глобального нет — работает только с фильтром по ключу |
| Chunking | 300–800 токенов, overlap 10–20%, рез по структуре, метаданные в колонки |
| pgvector vs Vector DB | до ~10–50M + SQL-фильтры — pgvector; миллиарды/hot-updates/p99 — отдельная DB |
| eval | retrieval-метрики (recall@k, MRR) на эталонных вопросах — до настройки промптов |
🔗 Мостик к Модулю 11
Векторный мир изучен — возвращаемся на основную линию. Модуль 11 — MVP-проект «Аналитика OrderFlow»: шесть спринтов, за которые PostgreSQL (уже с HA и партициями), CDC-пайплайн и кластер ClickHouse 2×2 собираются в один защищаемый стенд. Если RAG стал частью вашей работы — этот модуль вами уже пройден вовремя; MVP он не касается.