Когда pgvector — правильный выбор

У вас уже есть PostgreSQL. Документы, пользователи, заказы — всё там. Добавить векторный поиск через Qdrant означает: новый сервис, новая синхронизация данных между двумя хранилищами, новые сбои. pgvector убирает этот операционный overhead: расширение устанавливается одной командой, вектор становится обычным столбцом таблицы.

✓ Выбирайте pgvector, если:
· Уже используете PostgreSQL
· Нужен JOIN векторов с бизнес-данными
· Важна транзакционность (ACID)
· Корпус до ~5M документов
· Команда знает SQL лучше, чем новые API
· Managed PG уже оплачен (RDS, Supabase, Neon)
· Нужны сложные метаданные с реляционными связями
✗ Возьмите Qdrant/Weaviate, если:
· Корпус > 10M документов
· Нужна квантизация (экономия RAM)
· Нужен built-in hybrid search (dense+sparse)
· Несколько векторов на документ
· Нужно шардирование и репликация векторов
· Основная нагрузка — только векторный поиск
· PostgreSQL нет в стеке вообще

Архитектура: расширение PostgreSQL

pgvector — это расширение (extension) PostgreSQL: набор C-кода, который регистрирует новый тип данных, операторы и методы доступа. Никакого отдельного процесса — всё работает внутри постгресового воркера.

При установке расширение добавляет:

Новый тип данных:   vector(N)       — массив float32 фиксированной длины N
Операторы:          <->  <=>  <#>  — вычисляют расстояние/сходство
Методы индекса:     ivfflat          — кластерный приближённый поиск
                    hnsw             — иерархический граф (pgvector ≥ 0.5)
Агрегаты:           avg(vector)      — среднее по векторам (усреднение)
Функции:            l2_norm()        — норма вектора
        

Запрос SELECT ... ORDER BY embedding <=> $1 LIMIT 10 выглядит для планировщика PostgreSQL как обычный запрос с индексным сканом. Планировщик сам решает: использовать ANN-индекс или seq-scan, в зависимости от статистики и параметров.

100%
колёсико — масштаб · зажать и тянуть — перемещение
Архитектура pgvector PostgreSQL CREATE EXTENSION vector TABLE chunks id uuid PRIMARY KEY content text embedding vector(1536) ← новый тип! metadata jsonb doc_id uuid REFERENCES documents HNSW Index ON chunks (embedding) USING hnsw (cosine) m=16, ef_construction=64 GIN Index (FTS) ON chunks to_tsvector(content) для hybrid search QUERY PLAN: 1. WHERE filter → Index Scan (B-tree / GIN) 2. ORDER BY embedding <=> $1 → HNSW scan 3. LIMIT 10 → return top results psycopg3 / asyncpg / SQLAlchemy / LangChain IVFFlat vs HNSW IVFFlat centroid query probes=1: ищет только в ближайшем кластере probes=4: ищет в 4 кластерах (точнее, медленнее) SET ivfflat.probes = 10 HNSW Layer 2 Layer 1 Layer 0 result ef_search: ширина обхода при каждом запросе Нет «сборки» — сразу качественный поиск, лучший recall при N<1M SET hnsw.ef_search = 100 СРАВНЕНИЕ: Построение: IVFFlat быстрее · HNSW медленнее (но потом не нужен rebuild) Recall: HNSW выше при тех же ресурсах RAM: IVFFlat меньше · HNSW хранит граф (~8 байт × M × N) Фильтрация: оба плохо масштабируются при высокой selectivity (<5%) Рекомендация: HNSW — для большинства задач; IVFFlat — при >5M и RAM-ограничениях

Установка

# ── Docker (самый простой способ) ────────────────────────────────
docker run -d \
  --name pgvector \
  -e POSTGRES_PASSWORD=secret \
  -e POSTGRES_DB=ragdb \
  -p 5432:5432 \
  -v pgdata:/var/lib/postgresql/data \
  pgvector/pgvector:pg16        # официальный образ с предустановленным расширением

# ── На существующем PostgreSQL ≥ 13 ─────────────────────────────
# Debian/Ubuntu:
sudo apt install postgresql-16-pgvector

# macOS через Homebrew:
brew install pgvector

# После установки — подключиться и активировать:
psql -U postgres -d ragdb -c "CREATE EXTENSION IF NOT EXISTS vector;"

# ── Проверить версию ─────────────────────────────────────────────
psql -c "SELECT extversion FROM pg_extension WHERE extname = 'vector';"
# → 0.7.x (рекомендуется ≥ 0.6.0 для HNSW)

Тип vector и операторы дистанции

Тип vector(N) хранит массив из N чисел float32. Для векторного поиска PostgreSQL добавляет три новых инфиксных оператора:

<->
L2 / Euclidean distance
√ Σ(Aᵢ − Bᵢ)²
Евклидово расстояние. Меньше = ближе. Чувствительно к масштабу.
Когда: кластеризация, k-means, геопространственные задачи.
<=>
Cosine distance
1 − cos(θ) = 1 − A·B / (‖A‖·‖B‖)
1 − cosine similarity. Меньше = ближе. Не зависит от длины вектора.
Когда: семантический поиск — основной выбор для RAG.
<#>
Negative inner product
−(A · B) = −Σ AᵢBᵢ
Отрицательное скалярное произведение. Меньше = ближе. Для нормализованных = cosine.
Когда: нормализованные векторы, быстрее <=> за счёт отсутствия нормировки.
Важно: все три оператора возвращают расстояние, а не сходство. Поэтому ORDER BY всегда ASC (меньше расстояние → ближе). Для получения cosine similarity из оператора <=>: 1 - (embedding <=> query).
-- Создание таблицы с вектором
CREATE TABLE chunks (
    id        uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    content   text NOT NULL,
    embedding vector(1536),          -- размерность должна совпадать с моделью
    source    text,
    year      int,
    verified  boolean DEFAULT false,
    metadata  jsonb DEFAULT '{}',
    created_at timestamptz DEFAULT now()
);

-- Базовый семантический поиск (топ-10)
SELECT
    id,
    content,
    1 - (embedding <=> '[0.21, -0.84, ...]'::vector) AS cosine_similarity
FROM chunks
ORDER BY embedding <=> '[0.21, -0.84, ...]'::vector
LIMIT 10;

-- С порогом сходства (только cosine_sim >= 0.7)
SELECT id, content, 1 - (embedding <=> $1) AS score
FROM chunks
WHERE 1 - (embedding <=> $1) >= 0.7    -- ← фильтр по score
ORDER BY embedding <=> $1
LIMIT 10;

-- С фильтром по метаданным
SELECT id, content, source,
       1 - (embedding <=> $1) AS score
FROM chunks
WHERE source = 'wiki'
  AND year >= 2023
  AND verified = true
ORDER BY embedding <=> $1
LIMIT 10;

Индексы: IVFFlat и HNSW

Без индекса каждый запрос — точный перебор всех строк (seq scan). При 100k строк это ~50 мс, при 1M — уже секунды. ANN-индексы дают <5 мс ценой небольшой потери recall.

IVFFlat pgvector с первых версий
Принцип
Кластеризация k-means. При запросе ищет только в probes ближайших кластерах.
Параметры
lists — кол-во кластеров
probes — кол-во кластеров для поиска
Построение
Быстро. Нужно min 3×lists записей перед созданием.
Recall
Средний. При probes=1 может быть <80% при неудачных кластерах.
RAM
Меньше — только центроиды кластеров.
Когда
Большой корпус (>1M), ограничена RAM, быстрое построение важнее точности.
HNSW pgvector ≥ 0.5.0
Принцип
Иерархический граф. Спуск по слоям: каждый шаг — ближе к запросу.
Параметры
m — рёбра на узел (default 16)
ef_construction — точность при построении
Построение
Медленнее IVFFlat, зато rebuild не нужен никогда.
Recall
Высокий. При ef_search=64 обычно >95%.
RAM
Больше — хранит граф: ~8 × m × N байт.
Когда
Большинство RAG-задач. До 5M документов. Recall важнее RAM.
-- ── HNSW индекс (рекомендуется) ─────────────────────────────────

-- Cosine distance (для RAG — основной вариант)
CREATE INDEX ON chunks
    USING hnsw (embedding vector_cosine_ops)
    WITH (m = 16, ef_construction = 64);

-- L2 distance
CREATE INDEX ON chunks
    USING hnsw (embedding vector_l2_ops)
    WITH (m = 16, ef_construction = 64);

-- Inner product (для нормализованных векторов — быстрее cosine)
CREATE INDEX ON chunks
    USING hnsw (embedding vector_ip_ops)
    WITH (m = 16, ef_construction = 64);

-- ── IVFFlat индекс ───────────────────────────────────────────────

-- Правило: lists ≈ sqrt(N), где N — кол-во строк
-- Например, для 500k строк: lists = 700
CREATE INDEX ON chunks
    USING ivfflat (embedding vector_cosine_ops)
    WITH (lists = 100);   -- для небольшого корпуса

-- ── Partial index: индексировать только нужные строки ────────────

-- Только верифицированные документы — меньше индекс, быстрее поиск
CREATE INDEX ON chunks
    USING hnsw (embedding vector_cosine_ops)
    WHERE verified = true;

-- Только свежие документы (например, за текущий год)
CREATE INDEX ON chunks
    USING hnsw (embedding vector_cosine_ops)
    WHERE year >= 2024;

-- ── Параметры m и ef_construction ────────────────────────────────

-- Стандарт (хороший баланс recall/скорость/RAM):
-- m=16, ef_construction=64

-- Высокая точность (важен recall > 98%):
-- m=32, ef_construction=128

-- Ограничена RAM, большой корпус:
-- m=8, ef_construction=32 (быстро, recall ~90%)

-- ── Проверить использование индекса ──────────────────────────────
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, content
FROM chunks
ORDER BY embedding <=> $1
LIMIT 10;
-- Ищите "Index Scan using chunks_embedding_idx" — индекс работает
-- "Seq Scan" — индекс не используется (проверьте SET enable_seqscan)

Тюнинг per-query: probes и ef_search

Уникальная особенность pgvector: параметры точности поиска можно менять на уровне сессии или транзакции. Это позволяет балансировать recall и скорость в зависимости от конкретного запроса.

hnsw.ef_search=10
recall≈70%
~0.5 мс
hnsw.ef_search=40
recall≈88%
~1.2 мс
hnsw.ef_search=100
recall≈95%
~2.8 мс
hnsw.ef_search=200
recall≈98%
~5.5 мс
hnsw.ef_search=400
recall≈99.5%
~11 мс
-- ── Настройка точности поиска ────────────────────────────────────

-- HNSW: ширина обхода при каждом запросе
SET hnsw.ef_search = 100;           -- сессионный уровень

-- IVFFlat: кол-во кластеров для поиска
SET ivfflat.probes = 10;            -- сессионный уровень

-- Параллельные воркеры (ускорение на multi-core)
SET max_parallel_workers_per_gather = 4;

-- ── Установить только для конкретного запроса ─────────────────
BEGIN;
SET LOCAL hnsw.ef_search = 200;     -- только в этой транзакции
SELECT id, 1-(embedding <=> $1) score
FROM chunks ORDER BY embedding <=> $1 LIMIT 10;
COMMIT;

-- ── Рекомендуемые настройки postgresql.conf ──────────────────────
-- Добавьте в /etc/postgresql/16/main/postgresql.conf:

-- Размер буфера (ускоряет чтение индекса):
-- shared_buffers = 25% от RAM
-- effective_cache_size = 75% от RAM

-- HNSW потребляет maintenance_work_mem при построении:
-- maintenance_work_mem = 1GB   -- для больших индексов

-- Параллельность:
-- max_worker_processes = 8
-- max_parallel_workers = 8
-- max_parallel_workers_per_gather = 4

-- Применить без перезапуска:
-- SELECT pg_reload_conf();

Фильтрация: WHERE + векторный поиск

Это самая тонкая тема в pgvector. Проблема: PostgreSQL не умеет одновременно использовать HNSW-индекс и фильтровать по обычным колонкам на уровне индекса. Это приводит к двум нежелательным сценариям:

Сценарий 1 — Post-filtering (плохо при высокой selectivity):
  1. HNSW находит top-K по вектору (например, K=10)
  2. Применяется WHERE source='wiki'
  3. Из 10 результатов остаётся 2 (8 отброшено)
  → Возвращает 2 вместо 10; при строгом фильтре — вернёт 0

Сценарий 2 — Sequential scan (медленно):
  Планировщик видит, что фильтр отсеивает много строк
  → Отказывается от индекса → делает seq scan → медленно

Решение: partial indexes + правильный порядок операций
        
-- ── Стратегия 1: Partial index (лучшее решение при стабильных фильтрах) ──

-- Отдельный индекс только для верифицированных из wiki
CREATE INDEX idx_chunks_wiki_verified
    ON chunks USING hnsw (embedding vector_cosine_ops)
    WHERE source = 'wiki' AND verified = true;

-- Запрос автоматически использует partial index:
SELECT id, content
FROM chunks
WHERE source = 'wiki' AND verified = true
ORDER BY embedding <=> $1
LIMIT 10;
-- → EXPLAIN покажет Index Scan using idx_chunks_wiki_verified

-- ── Стратегия 2: Oversampling + фильтрация в приложении ───────────────

-- Запрашиваем 10× больше, чем нужно — фильтруем сами
SELECT id, content, source, year,
       1 - (embedding <=> $1) AS score
FROM chunks
ORDER BY embedding <=> $1
LIMIT 200;    -- берём 200, фильтруем в Python

-- В Python:
-- results = [r for r in raw_results if r['source'] == 'wiki' and r['year'] >= 2023][:10]

-- ── Стратегия 3: CTE с pre-filter ────────────────────────────────────

-- Сначала отфильтровать по метаданным, потом искать по вектору
WITH filtered AS (
    SELECT id, content, embedding, source, year
    FROM chunks
    WHERE source IN ('wiki', 'docs')   -- pre-filter в индексе B-tree
      AND year >= 2023
      AND verified = true
)
SELECT id, content, 1 - (embedding <=> $1) AS score
FROM filtered
ORDER BY embedding <=> $1
LIMIT 10;
-- Работает хорошо когда filtered возвращает << 100k строк

-- ── Индексы для pre-filter фильтров ──────────────────────────────────

-- Обязательно создать обычные индексы на поля WHERE:
CREATE INDEX ON chunks (source);
CREATE INDEX ON chunks (year);
CREATE INDEX ON chunks (verified);
CREATE INDEX ON chunks (source, year);   -- составной для частых пар

-- GIN индекс для JSONB metadata:
CREATE INDEX ON chunks USING gin (metadata);
-- Поиск по JSON: WHERE metadata @> '{"category": "web"}'

Hybrid search: векторы + полнотекстовый поиск

PostgreSQL имеет встроенный полнотекстовый поиск (tsvector/tsquery). Объединение векторного и текстового поиска через Reciprocal Rank Fusion (RRF) — стандартный паттерн для hybrid search без дополнительных сервисов.

-- ── Настройка FTS ─────────────────────────────────────────────────

-- Добавить FTS-колонку (вычисляемая, автоматически обновляется)
ALTER TABLE chunks ADD COLUMN fts_vector tsvector
    GENERATED ALWAYS AS (to_tsvector('russian', content)) STORED;

-- GIN-индекс для FTS
CREATE INDEX ON chunks USING gin (fts_vector);

-- ── Простой hybrid search (CTE + RRF) ─────────────────────────────

-- RRF score = 1/(k + rank) для каждого результата
-- Объединяем ранги из двух списков

WITH
-- Семантический поиск: топ-50 по вектору
semantic AS (
    SELECT
        id,
        ROW_NUMBER() OVER (ORDER BY embedding <=> $1) AS rank
    FROM chunks
    WHERE ($3::text IS NULL OR source = $3)   -- опциональный фильтр
    ORDER BY embedding <=> $1
    LIMIT 50
),
-- Полнотекстовый поиск: топ-50 по FTS
lexical AS (
    SELECT
        id,
        ROW_NUMBER() OVER (ORDER BY ts_rank(fts_vector, query) DESC) AS rank
    FROM chunks,
         to_tsquery('russian', $2) AS query   -- $2 = 'установка & fastapi'
    WHERE fts_vector @@ query
      AND ($3::text IS NULL OR source = $3)
    ORDER BY ts_rank(fts_vector, query) DESC
    LIMIT 50
),
-- RRF fusion
fused AS (
    SELECT
        COALESCE(s.id, l.id) AS id,
        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
)
SELECT
    c.id, c.content, c.source,
    f.rrf_score,
    1 - (c.embedding <=> $1) AS cosine_sim
FROM fused f
JOIN chunks c ON c.id = f.id
ORDER BY f.rrf_score DESC
LIMIT $4;    -- $4 = n_results

-- ── Weighted hybrid: настройка веса ──────────────────────────────

-- Если семантика важнее лексики — увеличить α
-- α=1.0 → только семантика; α=0.0 → только лексика
WITH
semantic AS (
    SELECT id,
           (1 - (embedding <=> $1)) AS vec_score,  -- cosine similarity
           ROW_NUMBER() OVER (ORDER BY embedding <=> $1) AS rank
    FROM chunks LIMIT 50
),
lexical AS (
    SELECT id,
           ts_rank(fts_vector, to_tsquery('russian', $2)) AS text_score,
           ROW_NUMBER() OVER (ORDER BY ts_rank(fts_vector, to_tsquery('russian', $2)) DESC) AS rank
    FROM chunks
    WHERE fts_vector @@ to_tsquery('russian', $2)
    LIMIT 50
),
combined AS (
    SELECT
        COALESCE(s.id, l.id) AS id,
        COALESCE(s.vec_score,  0) * 0.7 +   -- α=0.7 вес семантики
        COALESCE(l.text_score, 0) * 0.3 AS  -- 1-α=0.3 вес текста
        hybrid_score
    FROM semantic s
    FULL OUTER JOIN lexical l ON s.id = l.id
)
SELECT c.id, c.content, co.hybrid_score
FROM combined co
JOIN chunks c ON c.id = co.id
ORDER BY co.hybrid_score DESC
LIMIT 10;

Схема для RAG: documents + chunks

Правильная схема — ключ к эффективной работе. Отделяем исходные документы от чанков, храним связь через FK. Это позволяет реализовать parent-child retrieval через SQL JOIN.

documents исходные файлы
id uuid PK
title text
source_url text
source_type text
raw_content text
metadata jsonb
indexed_at timestamptz
chunks чанки для поиска
id uuid PK
doc_id uuid FK→doc
content text
embedding vector(1536)
chunk_index int
token_count int
metadata jsonb
-- ── Полная схема для production RAG ─────────────────────────────

CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

-- Исходные документы
CREATE TABLE documents (
    id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    title       text NOT NULL,
    source_url  text,
    source_type text NOT NULL,            -- 'pdf', 'web', 'notion', ...
    raw_content text,
    metadata    jsonb NOT NULL DEFAULT '{}',
    indexed_at  timestamptz NOT NULL DEFAULT now(),
    updated_at  timestamptz NOT NULL DEFAULT now()
);

-- Чанки (с embedding)
CREATE TABLE chunks (
    id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    doc_id      uuid NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
    content     text NOT NULL,
    embedding   vector(1536),
    fts_vector  tsvector GENERATED ALWAYS AS (to_tsvector('russian', content)) STORED,
    chunk_index int NOT NULL,             -- порядковый номер в документе
    token_count int,
    metadata    jsonb NOT NULL DEFAULT '{}',
    created_at  timestamptz NOT NULL DEFAULT now()
);

-- ── Индексы ────────────────────────────────────────────────────────

-- Основной векторный индекс
CREATE INDEX idx_chunks_embedding
    ON chunks USING hnsw (embedding vector_cosine_ops)
    WITH (m = 16, ef_construction = 64);

-- FTS для hybrid search
CREATE INDEX idx_chunks_fts ON chunks USING gin (fts_vector);

-- Обычные индексы для фильтрации по метаданным
CREATE INDEX idx_chunks_doc_id ON chunks (doc_id);
CREATE INDEX idx_chunks_metadata ON chunks USING gin (metadata);
CREATE INDEX idx_docs_source_type ON documents (source_type);

-- ── Parent-child retrieval: поиск по чанкам, возврат документов ──

-- 1. Поиск топ-K чанков
-- 2. Получение родительских документов с дедупликацией
WITH top_chunks AS (
    SELECT
        c.doc_id,
        MIN(c.embedding <=> $1) AS best_distance,   -- лучший чанк документа
        array_agg(c.content ORDER BY c.embedding <=> $1) AS chunks
    FROM chunks c
    WHERE c.embedding <=> $1 < 0.4                  -- порог по distance
    GROUP BY c.doc_id
    ORDER BY best_distance
    LIMIT 5                                          -- топ-5 уникальных документов
)
SELECT
    d.id,
    d.title,
    d.source_url,
    tc.best_distance,
    1 - tc.best_distance AS cosine_similarity,
    tc.chunks[1] AS best_chunk,          -- лучший чанк для контекста
    d.raw_content                        -- полный текст если нужен
FROM top_chunks tc
JOIN documents d ON d.id = tc.doc_id
ORDER BY tc.best_distance;

Python: psycopg + SQLAlchemy

pip install psycopg[binary,pool] pgvector sqlalchemy[asyncio] asyncpg
"""
Прямые запросы через psycopg3 (рекомендуется для production).
"""

import psycopg
from psycopg.rows import dict_row
from pgvector.psycopg import register_vector
import numpy as np
from contextlib import asynccontextmanager


# ── Подключение ───────────────────────────────────────────────────
DSN = "postgresql://postgres:secret@localhost:5432/ragdb"

async def get_pool():
    pool = psycopg.AsyncConnectionPool(DSN, min_size=2, max_size=10)
    await pool.wait()
    return pool

pool = None   # глобальный пул (инициализируется при старте)


async def setup_connection(conn):
    """Настройка соединения: регистрация типа vector."""
    await register_vector(conn)
    # Установить точность поиска для всей сессии
    await conn.execute("SET hnsw.ef_search = 100")
    await conn.execute("SET max_parallel_workers_per_gather = 4")


# ── Upsert чанков ────────────────────────────────────────────────
async def upsert_chunks(chunks: list[dict]) -> None:
    """
    chunks: список словарей {'id', 'doc_id', 'content', 'embedding', 'metadata'}
    """
    async with pool.connection() as conn:
        await setup_connection(conn)
        await conn.executemany(
            """
            INSERT INTO chunks (id, doc_id, content, embedding, metadata)
            VALUES (%(id)s, %(doc_id)s, %(content)s, %(embedding)s, %(metadata)s)
            ON CONFLICT (id) DO UPDATE SET
                content   = EXCLUDED.content,
                embedding = EXCLUDED.embedding,
                metadata  = EXCLUDED.metadata
            """,
            [
                {
                    **c,
                    "embedding": c["embedding"].astype(np.float32),  # убедиться float32
                }
                for c in chunks
            ],
        )
        await conn.commit()


# ── Семантический поиск ──────────────────────────────────────────
async def semantic_search(
    query_embedding: np.ndarray,
    n_results: int = 10,
    min_similarity: float = 0.65,
    source_filter: str | None = None,
    year_gte: int | None = None,
) -> list[dict]:
    conditions = ["1 - (c.embedding <=> %(emb)s) >= %(min_sim)s"]
    params: dict = {
        "emb": query_embedding.astype(np.float32),
        "min_sim": min_similarity,
        "limit": n_results,
    }
    if source_filter:
        conditions.append("c.metadata->>'source' = %(source)s")
        params["source"] = source_filter
    if year_gte:
        conditions.append("(c.metadata->>'year')::int >= %(year)s")
        params["year"] = year_gte

    where = " AND ".join(conditions)
    sql = f"""
        SELECT
            c.id,
            c.content,
            c.metadata,
            1 - (c.embedding <=> %(emb)s) AS similarity
        FROM chunks c
        WHERE {where}
        ORDER BY c.embedding <=> %(emb)s
        LIMIT %(limit)s
    """

    async with pool.connection() as conn:
        await setup_connection(conn)
        async with conn.cursor(row_factory=dict_row) as cur:
            await cur.execute(sql, params)
            return await cur.fetchall()


# ── Hybrid search ─────────────────────────────────────────────────
async def hybrid_search(
    query_embedding: np.ndarray,
    query_text: str,
    n_results: int = 10,
    semantic_weight: float = 0.7,
) -> list[dict]:
    # Преобразуем текст в ts_query формат
    tsquery = " & ".join(query_text.split())

    sql = """
    WITH semantic AS (
        SELECT id,
               ROW_NUMBER() OVER (ORDER BY embedding <=> %(emb)s) AS rank
        FROM chunks
        ORDER BY embedding <=> %(emb)s
        LIMIT 60
    ),
    lexical AS (
        SELECT id,
               ROW_NUMBER() OVER (
                   ORDER BY ts_rank(fts_vector, to_tsquery('russian', %(tsq)s)) DESC
               ) AS rank
        FROM chunks
        WHERE fts_vector @@ to_tsquery('russian', %(tsq)s)
        ORDER BY ts_rank(fts_vector, to_tsquery('russian', %(tsq)s)) DESC
        LIMIT 60
    ),
    fused AS (
        SELECT
            COALESCE(s.id, l.id) AS id,
            COALESCE(%(w)s / (60.0 + s.rank), 0) +
            COALESCE((1 - %(w)s) / (60.0 + l.rank), 0) AS rrf_score
        FROM semantic s
        FULL OUTER JOIN lexical l ON s.id = l.id
    )
    SELECT c.id, c.content, c.metadata, f.rrf_score,
           1 - (c.embedding <=> %(emb)s) AS cosine_sim
    FROM fused f
    JOIN chunks c ON c.id = f.id
    ORDER BY f.rrf_score DESC
    LIMIT %(limit)s
    """

    async with pool.connection() as conn:
        await setup_connection(conn)
        async with conn.cursor(row_factory=dict_row) as cur:
            await cur.execute(sql, {
                "emb": query_embedding.astype(np.float32),
                "tsq": tsquery,
                "w": semantic_weight,
                "limit": n_results,
            })
            return await cur.fetchall()

SQLAlchemy ORM

"""
pip install sqlalchemy[asyncio] asyncpg pgvector
"""

from sqlalchemy import Column, Text, Integer, ForeignKey, DateTime, func
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import DeclarativeBase, relationship
from pgvector.sqlalchemy import Vector
import uuid


class Base(DeclarativeBase):
    pass


class Document(Base):
    __tablename__ = "documents"

    id          = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    title       = Column(Text, nullable=False)
    source_url  = Column(Text)
    source_type = Column(Text, nullable=False)
    metadata    = Column(JSONB, default={})
    indexed_at  = Column(DateTime(timezone=True), server_default=func.now())
    chunks      = relationship("Chunk", back_populates="document", cascade="all, delete")


class Chunk(Base):
    __tablename__ = "chunks"

    id          = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    doc_id      = Column(UUID(as_uuid=True), ForeignKey("documents.id", ondelete="CASCADE"))
    content     = Column(Text, nullable=False)
    embedding   = Column(Vector(1536))            # ← pgvector тип
    chunk_index = Column(Integer)
    token_count = Column(Integer)
    metadata    = Column(JSONB, default={})
    document    = relationship("Document", back_populates="chunks")


# ── Async engine ──────────────────────────────────────────────────
engine = create_async_engine(
    "postgresql+asyncpg://postgres:secret@localhost/ragdb",
    pool_size=10, max_overflow=20,
)

# ── ORM запросы ───────────────────────────────────────────────────
from sqlalchemy import select, text
from sqlalchemy.orm import selectinload

async def search_orm(
    session: AsyncSession,
    query_embedding: list[float],
    n_results: int = 10,
) -> list[Chunk]:
    """ORM-запрос с векторным поиском."""
    stmt = (
        select(Chunk)
        .order_by(Chunk.embedding.cosine_distance(query_embedding))  # pgvector метод
        .limit(n_results)
    )
    result = await session.execute(stmt)
    return result.scalars().all()


# Альтернатива: через text() для сложных SQL-запросов
async def search_raw_sql(session: AsyncSession, query_vec: list[float]):
    result = await session.execute(
        text("""
            SELECT id, content, 1 - (embedding <=> :vec) AS score
            FROM chunks
            ORDER BY embedding <=> :vec
            LIMIT 10
        """),
        {"vec": str(query_vec)},
    )
    return result.mappings().all()

LangChain интеграция

"""
pip install langchain-postgres langchain-openai psycopg[binary]
"""

from langchain_postgres import PGVector
from langchain_openai import OpenAIEmbeddings
from langchain.schema import Document

embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

CONNECTION_STRING = "postgresql+psycopg://postgres:secret@localhost:5432/ragdb"

# ── Создание / подключение к коллекции ───────────────────────────
vectorstore = PGVector(
    embeddings=embeddings,
    collection_name="docs",
    connection=CONNECTION_STRING,
    use_jsonb=True,           # метаданные в JSONB (быстрее стандартного)
)

# ── Добавить документы ────────────────────────────────────────────
docs = [
    Document(page_content="FastAPI async framework", metadata={"source": "docs", "year": 2024}),
    Document(page_content="Django ORM tutorial",     metadata={"source": "wiki", "year": 2023}),
]
vectorstore.add_documents(docs)

# ── Поиск ─────────────────────────────────────────────────────────
# Similarity search
results = vectorstore.similarity_search("async web framework", k=5)

# С оценками (возвращает (Document, score) — score = distance)
results = vectorstore.similarity_search_with_score("fastapi install", k=5)
for doc, score in results:
    print(f"distance={score:.4f}: {doc.page_content[:60]}")

# С фильтром метаданных
results = vectorstore.similarity_search(
    "web framework tutorial",
    k=5,
    filter={"source": "wiki", "year": {"$gte": 2023}},
)

# ── as_retriever ──────────────────────────────────────────────────
retriever = vectorstore.as_retriever(
    search_type="similarity_score_threshold",
    search_kwargs={"score_threshold": 0.35, "k": 5},  # distance <= 0.35
)

Миграции через Alembic

# alembic/versions/001_pgvector_setup.py

from alembic import op
import sqlalchemy as sa
from pgvector.sqlalchemy import Vector


def upgrade() -> None:
    # 1. Активировать расширение
    op.execute("CREATE EXTENSION IF NOT EXISTS vector")

    # 2. Создать таблицы
    op.create_table(
        "documents",
        sa.Column("id", sa.UUID(), primary_key=True),
        sa.Column("title", sa.Text(), nullable=False),
        sa.Column("source_type", sa.Text(), nullable=False),
        sa.Column("metadata", sa.dialects.postgresql.JSONB(), server_default="{}"),
        sa.Column("indexed_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
    )

    op.create_table(
        "chunks",
        sa.Column("id", sa.UUID(), primary_key=True),
        sa.Column("doc_id", sa.UUID(), sa.ForeignKey("documents.id", ondelete="CASCADE")),
        sa.Column("content", sa.Text(), nullable=False),
        sa.Column("embedding", Vector(1536)),
        sa.Column("chunk_index", sa.Integer()),
        sa.Column("token_count", sa.Integer()),
        sa.Column("metadata", sa.dialects.postgresql.JSONB(), server_default="{}"),
    )

    # 3. Создать векторный индекс
    op.execute("""
        CREATE INDEX idx_chunks_embedding
        ON chunks USING hnsw (embedding vector_cosine_ops)
        WITH (m = 16, ef_construction = 64)
    """)

    # 4. FTS индекс
    op.execute("""
        CREATE INDEX idx_chunks_fts
        ON chunks USING gin (to_tsvector('russian', content))
    """)


def downgrade() -> None:
    op.drop_table("chunks")
    op.drop_table("documents")
    op.execute("DROP EXTENSION IF EXISTS vector")

Обслуживание и мониторинг

-- ── Статистика по индексам ──────────────────────────────────────
SELECT
    indexname,
    pg_size_pretty(pg_relation_size(indexrelid)) AS size,
    idx_scan AS searches,
    idx_tup_fetch AS rows_fetched
FROM pg_stat_user_indexes
WHERE tablename = 'chunks';

-- ── Размер коллекции ─────────────────────────────────────────────
SELECT
    pg_size_pretty(pg_relation_size('chunks'))       AS table_size,
    pg_size_pretty(pg_indexes_size('chunks'))        AS indexes_size,
    pg_size_pretty(pg_total_relation_size('chunks')) AS total_size,
    count(*) AS rows
FROM chunks;

-- ── EXPLAIN ANALYZE: анализ производительности запроса ───────────
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT id, 1-(embedding <=> '[...]'::vector) score
FROM chunks
WHERE metadata->>'source' = 'wiki'
ORDER BY embedding <=> '[...]'::vector
LIMIT 10;

-- Ищите в выводе:
-- "Index Scan using idx_chunks_embedding" — хорошо
-- "Seq Scan" — индекс не используется (проверьте min_rows_for_plan)

-- ── VACUUM ANALYZE: обновить статистику ──────────────────────────
VACUUM ANALYZE chunks;

-- ── Перестроить HNSW-индекс (при падении recall) ─────────────────
-- После массовых DELETE recall может упасть — перестроить:
REINDEX INDEX CONCURRENTLY idx_chunks_embedding;
-- CONCURRENTLY — без блокировки таблицы

-- ── Мониторинг медленных запросов ─────────────────────────────────
-- Включить pg_stat_statements в postgresql.conf:
-- shared_preload_libraries = 'pg_stat_statements'
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

SELECT
    query,
    calls,
    mean_exec_time::int AS avg_ms,
    max_exec_time::int  AS max_ms,
    total_exec_time::int / 1000 AS total_sec
FROM pg_stat_statements
WHERE query ILIKE '%<=>%'          -- только векторные запросы
ORDER BY mean_exec_time DESC
LIMIT 20;

Типичные ошибки

Создать HNSW-индекс до добавления данных
Если создать индекс на пустую таблицу, а потом добавлять строки — каждая вставка обновляет граф инкрементально. Это в 3–5× медленнее, чем создать индекс после загрузки данных.
✓ Правильный порядок: 1) INSERT всех данных, 2) CREATE INDEX. Для частых инкрементальных добавлений индекс нужен — но знайте об этом компромиссе.
Не создать обычные индексы для WHERE-полей
Запрос WHERE source = 'wiki' ORDER BY embedding <=> $1 без индекса на source: PostgreSQL сканирует все строки для фильтрации, потом сортирует по вектору. При 1M строк — сотни миллисекунд только на фильтрацию.
✓ CREATE INDEX ON chunks(source); CREATE INDEX ON chunks(year); — для каждого поля, используемого в WHERE.
Передавать вектор как строку вместо параметра
f"SELECT ... <=> '{vec.tolist()}'" — SQL-инъекция и в 10× медленнее из-за отсутствия prepared statement. PostgreSQL не может кешировать план запроса со строкой.
✓ Всегда используйте параметризованные запросы: cur.execute(sql, {"emb": vec}). pgvector автоматически конвертирует numpy array.
Не знать про post-filtering и возвращать меньше n_results
LIMIT 10 + строгий WHERE — HNSW нашёл 10, фильтр отбросил 8, вернулось 2. Пользователь получает неполный ответ. Особенно критично при score_threshold.
✓ Используйте LIMIT n_results * 5 (oversampling) или partial indexes для стабильных фильтров. Проверяйте количество результатов.
Забыть SET hnsw.ef_search — дефолт=40, recall плохой
Дефолтное значение hnsw.ef_search=40 даёт recall≈88% при LIMIT=10. Для серьёзного RAG этого мало. Ошибок нет — результаты просто хуже, незаметно.
✓ Устанавливайте в setup_connection() при открытии соединения: SET hnsw.ef_search = 100. Для высоко-recall задач: 200.
ORDER BY score DESC вместо ORDER BY distance ASC
ORDER BY (1 - (embedding <=> $1)) DESC — PostgreSQL не может использовать HNSW-индекс для этого выражения. Индекс работает только с ORDER BY embedding <=> $1 ASC.
✓ Всегда: ORDER BY embedding <=> $1 (ASC по умолчанию). Score вычисляйте как 1-distance в SELECT, не в ORDER BY.

Шпаргалка

УСТАНОВКА:
  docker run -d -p 5432:5432 pgvector/pgvector:pg16
  CREATE EXTENSION IF NOT EXISTS vector;

ТИП И ОПЕРАТОРЫ:
  embedding   vector(1536)   — тип столбца
  <->          L2 distance    — ORDER BY ASC
  <=>          cosine distance — ORDER BY ASC (основной для RAG)
  <#>          −inner product  — ORDER BY ASC (для нормализованных = cosine)

  cosine_similarity = 1 - (embedding <=> query)

ИНДЕКСЫ:
  -- HNSW (рекомендуется):
  CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops)
      WITH (m=16, ef_construction=64);

  -- IVFFlat (при RAM-ограничениях, >5M строк):
  CREATE INDEX ON chunks USING ivfflat (embedding vector_cosine_ops)
      WITH (lists=sqrt(N));

  -- Partial index (быстрее при стабильных фильтрах):
  CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops)
      WHERE verified = true;

ТЮНИНГ PER-SESSION:
  SET hnsw.ef_search = 100;          -- recall vs latency
  SET ivfflat.probes = 10;           -- для IVFFlat
  SET max_parallel_workers_per_gather = 4;

ПОРЯДОК ORDER BY:
  ✓  ORDER BY embedding <=> $1         → индекс работает
  ✗  ORDER BY 1-(embedding <=> $1) DESC → seq scan!

ФИЛЬТРАЦИЯ:
  При >10% selectivity — partial index
  При <10% selectivity — oversampling LIMIT n*5, фильтруй в Python

HYBRID SEARCH = FTS (GIN) + vector (HNSW) + RRF в CTE

ОБЯЗАТЕЛЬНЫЕ ИНДЕКСЫ ПОМИМО HNSW:
  CREATE INDEX ON chunks (source);      -- для WHERE source=
  CREATE INDEX ON chunks (year);        -- для WHERE year>=
  CREATE INDEX ON chunks USING gin (metadata);  -- для WHERE metadata@>

МОНИТОРИНГ:
  EXPLAIN (ANALYZE, BUFFERS) запрос;  -- проверить план
  "Index Scan using ..." → индекс работает
  "Seq Scan" → индекс не используется
        

Практические задания

  1. Сравнение IVFFlat и HNSW. Создайте две одинаковые таблицы с 50k векторов (можно np.random.randn(50000, 1536)). На первой — IVFFlat с lists=200, на второй — HNSW с m=16. Измерьте: время построения индекса, время 100 запросов, recall@10 относительно brute-force (SET enable_indexscan=off). При каком значении probes IVFFlat достигает recall HNSW?
  2. Влияние ef_search на recall и скорость. Создайте HNSW-индекс, добавьте 100k векторов. Для ef_search = 10, 40, 100, 200, 400: измерьте время 50 запросов + recall@10 против exact поиска. Постройте кривую recall/latency. Где находится «точка перегиба»?
  3. Полноценный RAG с hybrid search. Используя свою документацию или любой текстовый корпус: загрузите через LangChain или psycopg, создайте FTS и HNSW индексы. Реализуйте hybrid search с RRF через SQL-CTE. Сравните результаты dense-only vs hybrid на 10 запросах, содержащих специфические термины. Для каких запросов hybrid выдаёт лучшие топ-1 результаты?