RETRIEVAL BENCHMARK · RUSSIAN BUSINESS EMAIL CORPUS

OpenSearch vs pgvector

Кто находит нужное письмо и тред точнее и быстрее — BM25+kNN на OpenSearch или косинус+tsvector на Postgres. Одни и те же эмбеддинги (GigaEmbeddings, dim=2048), один и тот же чанкинг, честная матрица 2 бэкенда × 3 режима × 2 варианта.

Порог топ-K
действует на все блоки ниже, кроме latency
ИТОГ

Кто выигрывает

Авто-вывод по всей матрице бенчмарка, 220 golden-запросов × 3 повтора на конфигурацию.

КАЧЕСТВО ПОИСКА

EmailHit@K и ThreadHit@K по режимам

Доля запросов, где нужное письмо / нужный тред попал в топ-10.

EmailHit@10
попадание нужного письма в топ-10
ThreadHit@10
попадание нужного треда в топ-10
OpenSearch pgvector
ЖИВЫЕ ПРИМЕРЫ

Четыре кейса с настоящими цифрами

Конкретные golden-запросы из корпуса и то, что на них случилось в бенчмарке — не усреднённые проценты, а реальный запрос → реальный результат конкретной конфигурации.

ПОЛНАЯ МАТРИЦА

Все 12 конфигураций

2 бэкенда × 3 режима (dense / lexical / hybrid) × 2 варианта (без / с summary-векторами треда). ★ — лучший результат в столбце.

ПО ТИПАМ ЗАПРОСОВ

ThreadHit@10: где именно каждый режим силён

220 запросов поровну по 4 типам: смысловой перефраз (semantic), точная сущность вроде номера договора (lexical), вопрос про исход треда без явных сущностей (thread-level), запрос с SQL/DSL-фильтром по отправителю или дате (filtered).

СКОРОСТЬ

Latency без учёта эмбеддинга запроса

Медиана и p95 по 220 запросам × 3 повтора, логарифмическая шкала — диапазон от 0.4 мс до 50 мс.

p50 → p95 (диапазон) OpenSearch pgvector
СКЕЙЛ-ТЕСТ · РАЗОВЫЙ

Что будет на 247 225 чанках (×145)

Существующий корпус размножен (с шумом на векторах, чтобы не было буквальных дублей) в отдельный variant=scale, не пересекающийся с plain/sum. После теста удалён — здесь остаются только цифры. Один прогон, честный A/B (sum и scale измерены подряд, в одной сессии).

Latency: 1705 → 247 225 чанков, по конфигурациям
p50 (точка) → p95 (тонкая линия), лог. шкала. Полый маркер = baseline (sum), закрашенный = scale.
OpenSearch pgvector ○ baseline (1705) → ● scale (247k)
НАХОДКА №1 · ГЛАВНАЯ

pgvector dense на 1705 строках вообще не использовал HNSW

-- chunks_sum (1705 строк), 58.9 ms
Limit -> Sort -> Seq Scan on chunks_sum
  Buffers: shared hit=13978
  (HNSW-индекс в плане не участвует)

-- chunks_scale (247225 строк), 3.1 ms
Limit -> Index Scan using chunks_scale_hnsw
  Buffers: shared hit=1136

Планировщик на маленькой таблице честно выбрал точный перебор — он и правда дешевле. Значит весь прежний quality-бенчмарк pgvector/dense считался на exact-поиске, не на приближённом ANN — recall настоящего HNSW мы там не проверяли.

НАХОДКА №2

pgvector lexical деградирует в ×80, OpenSearch — линейно в ×2

pgvector lexical p50: 0.86 → 68.7 ms opensearch lexical p50: 15.2 → 30.9 ms

ts_rank_cd пересчитывает ранг по всем найденным строкам перед сортировкой — на масштабе это дорого. Инвертированный индекс OpenSearch спроектирован именно под этот сценарий. Если лексика важна как самостоятельный канал (не только внутри hybrid) на растущем объёме — здесь OpenSearch безопаснее.

НАХОДКА №3

Filtered-запросы: pre-filter у pgvector подтверждён и на масштабе

Limit -> Sort (точный cosine)
  -> Bitmap Heap Scan  Rows: 1885 из 247225
     -> BitmapAnd
        -> Bitmap Index Scan chunks_scale_sender
        -> Bitmap Index Scan chunks_scale_date

55/55 filtered golden-запросов — на любом масштабе pgvector сначала пересекает btree-индексы (BitmapAnd), затем точно пересчитывает косинус по узкому набору. HNSW не используется ни разу — переключение зависит не от размера таблицы, а от селективности фильтра.

ГРАНУЛЯРНОСТЬ ЧАНКИНГА · ГИПОТЕЗА НЕ ПОДТВЕРДИЛАСЬ

Вектор на переписку, а не на письмо?

Проверили: что если резать чанки не внутри границ письма (plain), а окном поверх всей переписки треда (convo) — окно может покрывать несколько писем сразу. Тот же токен-бюджет (800, overlap 120), меняется только граница чанка. email_id чанка convo — anchor (первое письмо в окне), поэтому EmailHit@k для convo — консервативная оценка; ThreadHit@k этому смещению не подвержен (дедуп по треду) и служит главным честным срезом.

ThreadHit@10: plain vs convo, по конфигурациям
Полый маркер = plain (по письму), закрашенный = convo (по переписке).
OpenSearch pgvector ○ plain → ● convo
НАХОДКА №1 · ГЛАВНАЯ

convo хуже plain по ThreadHit@10 на 5 из 6 конфигураций

ThreadHit@10 avg Δ: −0.031 ThreadHit@3 avg Δ: −0.039 (худший срез) Кроме pgvector/lexical: +0.014 (шум, обе цифры низкие)

Проверено на всех K: 1/3/5/10 — деградация системная, не артефакт отсечки. Единственное исключение — pgvector lexical, где ts_rank_cd и так слаб (0.33–0.36) и разница на грани точности измерения.

НАХОДКА №2

Механизм: склейка писем размывает эмбеддинг

запрос: "Д-2026/4626"

plain  топ-1 (score 0.761): thread=t0016
  верный тред найден сразу

convo  топ-1 (score 0.767): thread=t0165
  НЕ тот тред — склеенный чанк размыл вектор
convo  топ-2 (score 0.756): thread=t0016
  верный тред — но только 2-м местом

Чанк convo в среднем содержит 2–5 склеенных писем (636 токенов из 800 — почти всегда «под завязку»). Эмбеддинг усредняет смысл сразу нескольких сообщений и теряет фокус на конкретной детали, которую ищет точечный запрос.

НАХОДКА №3

Компромисс: convo компактнее и быстрее, но платит точностью

×3.6 меньше векторов: 1525 → 469 pgvector p95: 43.7 → 15.1 ms ценой ThreadHit@10 и EmailHit@10

106 из 180 тредов (короткие/шумовые) целиком уместились в 1 чанк, 74 длинных треда дали 2–8 чанков. Меньше векторов и ниже latency — но recall для точечных запросов ощутимо просел. Рекомендация: оставить plain (чанк на письмо), а доступ к контексту всей переписки давать через summary-векторы (variant=sum) — они добавляются поверх, не заменяя точность.

SUMMARY-ВЕКТОРЫ ТРЕДА

Помогает ли отдельный вектор саммари треда?

В среднем — почти нейтрально. В одном режиме — решающе.

На dense и hybrid у обоих бэкендов ThreadHit@K и так близок к 1.0 — добавить нечего. Но у pgvector lexical summary-вектор чинит главную слабость: короткие треды без общей лексики между письмами и запросом.

Среднее по всем режимам и бэкендам: — summary-векторы чуть помогают и нигде не вредят.

ПРИМЕРЫ

Как выглядят golden-запросы

По 3 случайных примера на тип из 220 сгенерированных запросов, каждый привязан к конкретному письму или треду в корпусе.