Skip to content

feat(search): hybrid retrieval + FTS/vector indexes — removes the search scaling cliff#934

Closed
seonghobae wants to merge 1 commit into
developfrom
feat/search-hybrid-retrieval
Closed

feat(search): hybrid retrieval + FTS/vector indexes — removes the search scaling cliff#934
seonghobae wants to merge 1 commit into
developfrom
feat/search-hybrid-retrieval

Conversation

@seonghobae

Copy link
Copy Markdown
Contributor

What (implements #922's plan)

/api/search seq-scanned every owner-scoped row per query — proven index-blind (EXPLAIN ignored GIN+HNSW even when present) because of query shape: no @@ predicate and FTS+vector fused in one ORDER BY of incomparable scales.

Reshape

  • 4 index-eligible candidate arms: lexical email/attachment (@@-gated → GIN, ts_rank_cd ordered, LIMIT) + vector email/attachment (pure <=> ORDER BY + LIMIT → HNSW). Embedding-failure fallback preserved (lexical-only).
  • RRF fusion (k=60) in the app — replaces the unnormalized ts_rank_cd − cosine_distance subtraction.
  • Bounded metadata second query (fused ids only) so reply-count joins never disqualify the ANN index.
  • Owner scoping preserved on every arm + metadata.

Migration 0010

GIN expression indexes (email body, attachment content) + hnsw vector_cosine_ops (ivfflat < pgvector 0.5), CONCURRENTLY in an autocommit block, postgres-guarded. DDL is a single source (db/search_indexes.py) shared by migration and tests.

Verification

  • Real-Postgres proof: tests/test_search_pg_indexes.py ran against pgvector:pg16 — EXPLAIN shows the lexical arm on ix_email_records_body_fts and the vector arm on ix_email_records_embedding_vec. ✅
  • tests/test_search.py rewritten for the new shape — 16 passed (endpoint, owner scoping, fallback, session split, RRF, arm shapes).
  • Regressions: alembic/bootstrap 37, emails_api/db_session 66, ruff clean.

Behavior notes for review

Result ranking and membership change by design (that's the fix): lexical candidates now require an actual FTS match; semantic-only hits still surface via the vector arms. Scores are RRF values (rank-based), not raw score arithmetic.

🤖 Generated with Claude Code

…ts the scalability plan)

/api/search previously computed ts_rank_cd minus cosine_distance over every
owner-scoped row on every query — a full sequential scan that no index could
serve (verified by EXPLAIN: the planner ignored GIN/HNSW even when present)
and an unnormalized fusion of incomparable scales.

Reshape (docs/plans/2026-07-05-search-scalability-hybrid-retrieval.md):
- Candidate generation is now up to four index-eligible arms: lexical email/
  attachment arms gated by @@ (GIN-served, ts_rank_cd ordered, LIMIT) and
  vector email/attachment arms as pure cosine-distance ORDER BY + LIMIT
  (HNSW-served). The embedding-failure fallback keeps working (lexical arms
  only).
- Fusion moves to the app as reciprocal-rank fusion (RRF, k=60), replacing
  the score subtraction. Metadata + reply counts are fetched in a second
  bounded query for the fused candidate ids only, so joins never disqualify
  the ANN index.
- Owner (user/organization) scoping preserved on every arm and the metadata
  query.

Migration 0010 (postgresql-guarded, CONCURRENTLY in an autocommit block):
GIN expression indexes on email/attachment text and hnsw (ivfflat below
pgvector 0.5) vector_cosine_ops indexes, with the DDL shared between the
migration and tests via db/search_indexes.py.

Verification:
- tests/test_search_pg_indexes.py (postgres marker) ran against a real
  pgvector 16 instance: EXPLAIN shows the lexical arm using
  ix_email_records_body_fts and the vector arm using
  ix_email_records_embedding_vec.
- tests/test_search.py rewritten for the new shape (16 passed): endpoint
  behavior, owner scoping, fallback, session split, RRF fusion, arm shapes.
- Regressions green: alembic/bootstrap (37), emails_api/db_session (66),
  ruff clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RkKdtHRLG4wSLh6PVsp8J
@seonghobae

Copy link
Copy Markdown
Contributor Author

Folded into #938, which is now retargeted to develop as one independent PR (this was a stacked PR that could not auto-merge). Closing to avoid duplicate/tangled merge order.

@seonghobae seonghobae closed this Jul 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant