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
140 lines
5.6 KiB
Python
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)
|