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). Аналогов нет.

Обсуждались два пути:

  1. Elasticsearch — родная задача (function_score, k-NN на dense_vector, fuzzy text). Требует выделенного search-indexer воркера, outbox-проектора, dual-write consistency, отдельной бэкап-стратегии, +1 stateful компонент в prod.
  2. 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 весЧто отражает
sem0.6cosine similarity name_embedding ↔ target
txt0.2ts_rank(name_tsvector, plainto_tsquery(target.name))
struct0.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.
  • ivfflat lists=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).

Рассмотренные альтернативы

Отвергнут (см. 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.go
  • backend/internal/core/catalog/canonical/api/http/analogs_handler.go