pdf-quiz-generator/backend/alembic/versions/q9c0d1e2f364_question_fulltext.py
Daniel 519f2e572a feat: hybrid search on BGE-M3, with embedding provenance and a retry job
Search
- Retrieval was hybrid in name only: the keyword filter was applied to the SQL
  query, so results were the *intersection* of the two rankers. A question that
  matched the meaning but not the literal string could never be returned. It is
  now a union, fused with Reciprocal Rank Fusion (a text rank and a cosine
  distance are not on comparable scales, so RRF uses only their orderings).
- Added a generated `search_vector` tsvector + GIN index, so the lexical half is
  ranked full text rather than ILIKE substring matching.
- Chose Postgres + pgvector over OpenSearch/Elasticsearch: a search cluster
  would add a second datastore to keep in sync and a JVM on this host, to
  replace an index Postgres maintains inside the same transaction.
- Removed the keyword-only mode. It looks precise but silently drops the
  question that asks the same thing in different words.

Embeddings — measured on 500 real questions, using each question's own
explanation as a paraphrase query (known answer, no hand labelling):
  bge-small (local CPU, 384d)  R@1 0.840  R@5 0.953  186ms/query
  bge-m3 (LiteLLM proxy, 1024d) R@1 0.847  R@5 0.973   93ms/query
BGE-M3 wins on both quality and latency and needs no extra credential, since
llm.danvics.com already serves `openrouter-bge-m3`.

Three gaps this exposed, all fixed:
- Nothing recorded which model produced a stored vector, so changing models
  silently mixed incomparable spaces. `embedding_model` / `embedded_at` now
  stamp every vector, `GET /admin/embedding/health` reports current vs stale vs
  missing, and regeneration defaults to stale-only.
- The generator read the model from env while the stamp read a Redis override,
  so a vector could be labelled with a model that did not produce it. Both now
  resolve through one function, with a regression test.
- Embedding at creation is best effort, and a failure left a question invisible
  to semantic search forever. `retry_missing_embeddings` runs every 15 minutes
  via Celery beat and backfills missing or stale rows.
- Query embeddings are cached in Redis per model, so typing is not a network
  round-trip per keystroke.

`dimensions` is only sent to OpenAI's embedding-3 family; BGE-M3 rejects it.

Tests: 8 new backend tests (union not intersection, fusion ordering, per-ranker
failure degradation, provenance stamping, stale/missing accounting, generator
and stamp agreement). Full suites green: 95 backend, 127 frontend, build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yhHB8Pc7oQqyqn2Vo9DXA
2026-09-09 23:45:33 +02:00

29 lines
992 B
Python

"""Full-text search vector on questions, for hybrid lexical + vector retrieval.
Revision ID: q9c0d1e2f364
Revises: p8b9c0d1e253
"""
from alembic import op
revision = "q9c0d1e2f364"
down_revision = "p8b9c0d1e253"
branch_labels = None
depends_on = None
def upgrade():
# Generated column keeps the index in step with edits without a trigger.
op.execute("""
ALTER TABLE questions ADD COLUMN IF NOT EXISTS search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(question_text, '')), 'A') ||
setweight(to_tsvector('english', coalesce(options::text, '')), 'B') ||
setweight(to_tsvector('english', coalesce(explanation, '')), 'C')
) STORED
""")
op.execute("CREATE INDEX IF NOT EXISTS ix_questions_search_vector ON questions USING GIN (search_vector)")
def downgrade():
op.execute("DROP INDEX IF EXISTS ix_questions_search_vector")
op.execute("ALTER TABLE questions DROP COLUMN IF EXISTS search_vector")