Когда pgvector — правильный выбор
У вас уже есть PostgreSQL. Документы, пользователи, заказы — всё там. Добавить векторный поиск через Qdrant означает: новый сервис, новая синхронизация данных между двумя хранилищами, новые сбои. pgvector убирает этот операционный overhead: расширение устанавливается одной командой, вектор становится обычным столбцом таблицы.
· Нужен JOIN векторов с бизнес-данными
· Важна транзакционность (ACID)
· Корпус до ~5M документов
· Команда знает SQL лучше, чем новые API
· Managed PG уже оплачен (RDS, Supabase, Neon)
· Нужны сложные метаданные с реляционными связями
· Нужна квантизация (экономия 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,
в зависимости от статистики и параметров.
Установка
# ── 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 добавляет три новых инфиксных оператора:
<=>: 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.
probes ближайших кластерах.lists — кол-во кластеровprobes — кол-во кластеров для поискаm — рёбра на узел (default 16)ef_construction — точность при построении-- ── 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: ширина обхода при каждом запросе
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.
-- ── Полная схема для 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;
Типичные ошибки
WHERE source = 'wiki' ORDER BY embedding <=> $1
без индекса на source: PostgreSQL сканирует все строки
для фильтрации, потом сортирует по вектору. При 1M строк —
сотни миллисекунд только на фильтрацию.
f"SELECT ... <=> '{vec.tolist()}'" — SQL-инъекция
и в 10× медленнее из-за отсутствия prepared statement.
PostgreSQL не может кешировать план запроса со строкой.
cur.execute(sql, {"emb": vec}). pgvector автоматически конвертирует numpy array.LIMIT 10 + строгий WHERE — HNSW нашёл 10, фильтр отбросил 8,
вернулось 2. Пользователь получает неполный ответ.
Особенно критично при score_threshold.
hnsw.ef_search=40 даёт recall≈88%
при LIMIT=10. Для серьёзного RAG этого мало.
Ошибок нет — результаты просто хуже, незаметно.
ORDER BY (1 - (embedding <=> $1)) DESC — PostgreSQL
не может использовать HNSW-индекс для этого выражения.
Индекс работает только с ORDER BY embedding <=> $1 ASC.
Шпаргалка
УСТАНОВКА:
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" → индекс не используется
Практические задания
-
Сравнение IVFFlat и HNSW.
Создайте две одинаковые таблицы с 50k векторов (можно
np.random.randn(50000, 1536)). На первой — IVFFlat с lists=200, на второй — HNSW с m=16. Измерьте: время построения индекса, время 100 запросов, recall@10 относительно brute-force (SET enable_indexscan=off). При каком значенииprobesIVFFlat достигает recall HNSW? - Влияние ef_search на recall и скорость. Создайте HNSW-индекс, добавьте 100k векторов. Для ef_search = 10, 40, 100, 200, 400: измерьте время 50 запросов + recall@10 против exact поиска. Постройте кривую recall/latency. Где находится «точка перегиба»?
- Полноценный RAG с hybrid search. Используя свою документацию или любой текстовый корпус: загрузите через LangChain или psycopg, создайте FTS и HNSW индексы. Реализуйте hybrid search с RRF через SQL-CTE. Сравните результаты dense-only vs hybrid на 10 запросах, содержащих специфические термины. Для каких запросов hybrid выдаёт лучшие топ-1 результаты?