pdf-quiz-generator/backend/app/services/search_service.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

140 lines
5.6 KiB
Python

"""Hybrid question retrieval: Postgres full text fused with pgvector similarity.
Why not OpenSearch/Elasticsearch: the lexical half of this problem is ordinary
ranked text matching that Postgres already does with `tsvector`/`ts_rank_cd`,
and the semantic half already runs on pgvector with embeddings that are stored
and kept current. A search cluster would add a second datastore to keep in sync,
a JVM's memory footprint on this host, and a new failure mode, to replace an
index Postgres maintains for free inside the same transaction.
The two rankers are combined with Reciprocal Rank Fusion rather than a weighted
score, because a BM25-style rank and a cosine distance are not on comparable
scales; RRF only needs each ranker's ordering.
Retrieval is always hybrid. A keyword-only mode looks precise but silently drops
the question that asks the same thing in different words, which is exactly the
question a learner searching a concept wants.
"""
import hashlib
import json
import logging
from sqlalchemy import text as sa_text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
# Rank-fusion constant. 60 is the value from the original RRF paper; it damps
# the head of each list so one ranker cannot dominate on its top hit alone.
RRF_K = 60
# Per-ranker candidate pool. Wider than the page so fusion has room to reorder.
POOL_MULTIPLIER = 4
MIN_POOL = 60
SEMANTIC_FLOOR = 0.45
# Query embeddings are deterministic per model, so a day is safe and cheap.
QUERY_CACHE_TTL = 24 * 3600
def _is_postgres(db: Session) -> bool:
return db.bind is not None and db.bind.dialect.name == "postgresql"
def _lexical_ranked(db: Session, query_text: str, pool: int) -> list[int]:
"""Question ids by full-text relevance, best first."""
if not _is_postgres(db):
# SQLite (tests): substring matching keeps the fusion path exercised.
rows = db.execute(sa_text("""
SELECT id FROM questions
WHERE lower(question_text) LIKE :like OR lower(CAST(options AS TEXT)) LIKE :like
ORDER BY id LIMIT :pool
"""), {"like": f"%{query_text.lower()}%", "pool": pool}).fetchall()
return [row[0] for row in rows]
rows = db.execute(sa_text("""
SELECT id FROM questions
WHERE search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY ts_rank_cd(search_vector, websearch_to_tsquery('english', :q)) DESC, id
LIMIT :pool
"""), {"q": query_text, "pool": pool}).fetchall()
return [row[0] for row in rows]
def _query_embedding(query_text: str) -> list[float] | None:
"""Embed a search query, cached per model so typing is not a round-trip per keystroke.
Cached under the active model's name, so switching models cannot serve a
vector from the previous embedding space.
"""
from app.services.embedding_service import _get_embedding_model, generate_embedding
model = _get_embedding_model()
key = f"qsearch:emb:{model}:{hashlib.sha256(query_text.encode()).hexdigest()[:32]}"
cache = None
try:
import redis as redis_lib
from app.config import settings
cache = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
hit = cache.get(key)
if hit:
return json.loads(hit)
except Exception:
cache = None # Redis is a nicety here; the embedder still answers.
embedding = generate_embedding(query_text)
if embedding and cache is not None:
try:
cache.setex(key, QUERY_CACHE_TTL, json.dumps(embedding))
except Exception:
logger.debug("Could not cache query embedding", exc_info=True)
return embedding
def _semantic_ranked(db: Session, query_text: str, pool: int) -> list[int]:
"""Question ids by embedding similarity, nearest first."""
if not _is_postgres(db):
return []
embedding = _query_embedding(query_text)
if not embedding:
return []
literal = "[" + ",".join(str(float(value)) for value in embedding) + "]"
rows = db.execute(sa_text("""
SELECT id, 1 - (embedding <=> CAST(:vec AS vector)) AS similarity
FROM questions
WHERE embedding IS NOT NULL
ORDER BY embedding <=> CAST(:vec AS vector)
LIMIT :pool
"""), {"vec": literal, "pool": pool}).fetchall()
return [row.id for row in rows if float(row.similarity) >= SEMANTIC_FLOOR]
def hybrid_question_ids(db: Session, query_text: str, limit: int = 200) -> tuple[list[int], set[int]]:
"""Return (ids best-first, ids the semantic ranker contributed).
The result is the *union* of both rankers. An earlier implementation
intersected them, so a question that matched the meaning but not the literal
string could never be returned no matter how well it scored.
"""
query_text = (query_text or "").strip()
if not query_text:
return [], set()
pool = max(MIN_POOL, limit * POOL_MULTIPLIER)
try:
lexical = _lexical_ranked(db, query_text, pool)
except Exception:
logger.warning("Lexical search unavailable; falling back to semantic only", exc_info=True)
lexical = []
try:
semantic = _semantic_ranked(db, query_text, pool)
except Exception:
logger.warning("Semantic search unavailable; falling back to lexical only", exc_info=True)
semantic = []
scores: dict[int, float] = {}
for ranked in (lexical, semantic):
for position, question_id in enumerate(ranked):
scores[question_id] = scores.get(question_id, 0.0) + 1.0 / (RRF_K + position + 1)
ordered = sorted(scores, key=lambda qid: (-scores[qid], qid))
return ordered[:limit], set(semantic)