ADR-0054: pgvector для поиска аналогов в canonical_products
Status: proposed Date: 2026-05-12 Deciders: belkanov, agent-claude
Контекст
Бизнес-постановка: клиент должен получать наряду с точными
кандидатами «похожие» товары — частичное совпадение характеристик,
fuzzy-name, эквивалентность класса. Это влияет на
/v1/search/proposals и косвенно на CandidateSelector в pricing-pipeline.
До этого матчинг работал только на точные характеристики
(offer_characteristic_facts overlap по canonical_key). Аналогов нет.
Обсуждались два пути:
- Elasticsearch — родная задача (function_score, k-NN на
dense_vector, fuzzy text). Требует выделенного
search-indexerворкера, outbox-проектора, dual-write consistency, отдельной бэкап-стратегии, +1 stateful компонент в prod. pgvector+tsvectorв существующей PG — k-NN и full-text внутри source-of-truth, index живёт в той же транзакции, нет dual-write, нет отдельного бэкапа. Потолок ~5–10 M документов и ограниченное query-time scoring.
Текущий масштаб: 1.2 M canonical_products, 4.4 M offer_characteristic_raw строк. Запас по потолку pgvector — на годы.
Решение
Идём pgvector MVP. Конкретно:
Source of truth и проекция
- Embeddings — derived data на
canonical_products. Колонки:name_embedding vector(1536),name_tsvector tsvector,embedding_inputs_hash bytea,embedding_generated_at timestamptz,embedding_model text. - Индексы:
ivfflat (name_embedding vector_cosine_ops) WITH (lists=100),gin (name_tsvector). После backfill — REINDEX с tuned lists (rule of thumb:sqrt(N)). - Dirty-queue
canonical_embedding_dirtyподдерживается триггерами наcanonical_products.name UPDATEиcanonical_assignments INSERT/UPDATE/DELETE. - Backfill отложен до получения cost-budget на embedding-провайдер.
Модель embedding’а
Выбираем text-embedding-3-small (1536 dim) как safe default:
- стоимость ≈ 0.70;
- 1536-dim укладывается в
vector(1536)без хитростей; - качество достаточное для русско-английского технического каталога (electrical specs);
- единый провайдер с charnorm/matcher LLM-gateway (CLIProxy) —
переиспользуем
cfg.LLM.BaseURL+LLM_API_KEY.
Альтернативы держим открытыми:
text-embedding-3-large(3072 dim) — если recall@20 на проде < 0.7. Стоимость ×6.5, миграция =DROP COLUMN name_embedding; ADD vector(3072)- полный backfill.
- Self-hosted
bge-m3через тот же CLIProxy endpoint — даёт control над данными и стоимостью, но требует GPU-инстанс.
Composite ranking
Веса композитного score’а в config.Matcher.Analogs{Sem,Text,Struct}Weight:
| Компонент | Default вес | Что отражает |
|---|---|---|
sem | 0.6 | cosine similarity name_embedding ↔ target |
txt | 0.2 | ts_rank(name_tsvector, plainto_tsquery(target.name)) |
struct | 0.2 | доля общих (canonical_key, raw_value_hash) пар через offer_characteristic_facts, с rarity guard |
Веса меняются через env (ANALOGS_SEM_WEIGHT / _TEXT_WEIGHT /
_STRUCT_WEIGHT) без redeploy. Сумма не обязана быть = 1.0; ranking
устойчив к scale.
Поведение при отсутствии embedding’а target’а
GET /v1/canonical/{id}/analogs возвращает 503 Service Unavailable
с Retry-After: 60 когда target.name_embedding IS NULL. Не
fallback на text-only — это исказило бы метрики качества и спрятало
бы факт «worker не догнал». Клиент перезапрашивает.
Identity policy
Embeddings — deterministic derivation. Если изменилась модель
(embedding_model) или входной payload (embedding_inputs_hash),
worker пересчитывает. Никаких ручных правок embedding’а нет — все
изменения идут через canonical_products.name или
canonical_assignments через триггеры.
Последствия
Плюсы
- Один stateful компонент (PG) — нет отдельной бэкап-стратегии для search-index’а.
- Источник истины и search-index атомарно консистентны (одна tx).
- Backfill идемпотентен (через
inputs_hash). text-embedding-3-smallдешёвый — экспериментировать недорого.- Composite ranking прозрачен — каждый слагаемый readable и tunable.
Минусы
- Потолок ~5–10 M документов и query-time scoring. Если упрёмся — миграция на Elasticsearch.
ivfflatlists=100 — приблизительный k-NN. Recall зависит от data distribution; контролируется через REINDEX с tuned lists.- Зависимость от LLM-провайдера для backfill (cost + rate limit).
- Триггеры на
canonical_assignmentsсрабатывают на каждый ассайнмент-write — overhead на ingest (один INSERT в dirty queue per row). Mitigation: queue dedups черезON CONFLICT.
Нейтральные последствия
- Дополнительный worker
embedding-workerв compose (:9097), но он soft-disabled приLLM_MODEL_EMBEDDING=""— безопасный default. - Прод-image поменялся с
postgres:16-alpineнаpgvector/pgvector:pg16(тот же PG 16, + extension preinstalled).
Рассмотренные альтернативы
A. Elasticsearch как primary search
Отвергнут (см. docs/plans/2026-05-12-17-00-pgvector-analogs-mvp.md):
требует outbox-проектора, dual-write consistency, отдельной
бэкап-стратегии. Окупается на 10M+ документов или при обязательном
fuzzy/semantic-recall’е. На текущем масштабе overkill.
B. Только tsvector full-text (без embeddings)
Покрывает text-similarity, но не семантическую близость (synonyms, переводы, конструкции типа «выключатель» vs «автомат»). Качество recall’а на проде < 0.5 на типичных запросах аналогов.
C. pg_trgm GIN index
Хорош для typo-tolerant text-match. Не покрывает k-NN. Можно добавить позже как четвёртое слагаемое composite ranking’а, если понадобится.
D. Self-hosted bge-m3
Снимает зависимость от внешнего провайдера и cost. Требует GPU-инстанс
- inference-сервер. Откладываем до тех пор, пока внешний embedding- spending не станет нетривиальным.
Ссылки
docs/plans/2026-05-12-17-00-pgvector-analogs-mvp.md(parent spec)docs/plans/2026-05-12-17-25-offer-characteristic-facts.md(структурное слагаемое composite ranking’а)- Миграции
0109_canonical_pgvector_columns.sql,0110_canonical_embedding_dirty_triggers.sql backend/cmd/embedding-worker/main.gobackend/internal/core/catalog/canonical/api/http/analogs_handler.go