pdf-quiz-generator/backend/app/routers/search.py
Daniel 031de53034 feat: rerank what a learner is shown, with Cohere through the proxy
Retrieval fused a bi-encoder and BM25 by reciprocal rank. A bi-encoder embeds a
document long before the question exists, so the two never meet: it is good at
"same topic" and mediocre at "answers this". A cross-encoder reads the pair.

The proxy already serves three — `cohere-rerank-v4.0-pro` is the default and
measurably better than the fast variant. Query text goes exactly where the
embeddings already go, and nothing new was signed up for.

It found a defect nobody was looking for. In AI Mode each finder scored
`1/(1+rank)` *within its own corpus*, so the best article, section, question and
card all scored 1.0 and the shortlist was a meaningless round-robin. A
cross-encoder is the first thing in this system that can compare a question
with a section. Candidates per kind widened so it can select rather than merely
reorder.

Measured against labels neither ranker produced. Questions, 60 disease tags:
precision@3 0.394 → 0.483. Sections, 60 article titles: 0.772 → 0.833.
"Management of bronchiolitis" led with influenza transmission and a pregnancy
question; "when do you image a first febrile seizure" returned the definition
rather than the sentence saying imaging is unnecessary.

And the honest negative, in docs/reranking.md: board vignettes are written
*not* to name their diagnosis, so on "what causes croup" it prefers a question
that says the word in passing over the barking-cough vignette that never says
it. Some of the bi-encoder's strength is traded away.

Not on the typeahead. A page of results is a choice being made and worth a
third of a second; a typeahead is a word being finished, runs on every
keystroke, and has nothing to judge yet.

The three-state thresholds stay on cosine, argued at the constant: a reranker
only ever sees a shortlist and structurally cannot answer the corpus-wide
question those numbers ask, and whether an answer claims to come from the
library is a promise that must not depend on a network hop.

Every failure returns None and leaves the order alone — unconfigured, no proxy,
connect error, bare 502, timeout, non-JSON, a duplicate or out-of-range index,
a non-numeric score, a list the wrong length. Verified against the running site
with a bogus model name: same results, fused order, no error to the reader.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 18:23:06 +02:00

215 lines
9 KiB
Python

"""One query across everything a learner can see.
Each corpus already has its own retrieval and its own rules about who may see
what. This endpoint runs them together and returns one answer, rather than
making somebody guess which of five pages holds the thing they are looking for.
Two decisions worth stating:
* Visibility is never re-implemented here. Questions go through the same bank
predicate and exam scope as the question bank, articles through the same draft
rule, cards through deck ownership, images through library grants. A search
page that had its own idea of who may see what is how private content leaks.
* A section match is reported under its article, not beside it. Ten sections of
one article are one result with ten places to start reading, not ten results
that bury everything else.
* The results page is reranked by a cross-encoder; the typeahead below it is
not. A page is a choice being made, and worth a third of a second to get
right. A typeahead is a word being finished, runs on every keystroke, and has
nothing to judge yet — it is prefix matching on titles, which is exactly what
finishing a word wants.
"""
import logging
import re
from fastapi import APIRouter, Depends, Query
from sqlalchemy import text as sa_text
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.article import Article
from app.models.flashcard import Flashcard, FlashcardDeck
from app.models.media import MediaAsset
from app.models.question import Question
from app.models.user import User
from app.routers.media import readable_libraries
from app.services.quiz_builder import bank_query, exam_scope_predicate
from app.services.search_service import article_ids_with_sections, hybrid_ids, rerank_ids
from app.utils.auth import get_current_user
router = APIRouter()
log = logging.getLogger(__name__)
KINDS = ("article", "question", "flashcard", "media")
# Per-corpus retrieval pool. Wider than what is shown, because visibility
# filtering happens after ranking and can empty a page that had hits.
POOL = 60
def _strip_markup(value: str | None) -> str:
text = re.sub(r"!\[[^\]]*\]\([^)]*\)", " ", value or "")
text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text)
text = re.sub(r"<[^>]+>", " ", text)
text = re.sub(r"^\s{0,3}#{1,6}\s*", "", text, flags=re.M)
text = re.sub(r"[*_`>|]", " ", text)
return re.sub(r"\s+", " ", text).strip()
def _snippet(value: str | None, query: str, width: int = 180) -> str:
"""A window of the text around the first query word that appears in it.
Showing the opening of every document makes results look identical; showing
where the match is tells you whether it is the one you meant.
"""
text = _strip_markup(value)
if not text:
return ""
for word in sorted((w for w in re.findall(r"\w{4,}", query.lower())), key=len, reverse=True):
found = text.lower().find(word)
if found >= 0:
start = max(0, found - width // 3)
piece = text[start:start + width]
return ("" if start else "") + piece.strip() + ("" if start + width < len(text) else "")
return text[:width] + ("" if len(text) > width else "")
def _ordered(rows, ranked: list[int]):
"""Rows back in the order retrieval put them, not the order the DB returned."""
position = {row_id: index for index, row_id in enumerate(ranked)}
return sorted(rows, key=lambda row: position.get(row.id, len(position)))
def _articles(db, user, q, limit):
# A section match belongs to its article, so both rankers feed one result.
wanted, by_article = article_ids_with_sections(db, q, limit=POOL)
if not wanted:
return []
rows = db.query(Article).filter(Article.id.in_(wanted)).all()
if not user.is_moderator:
rows = [a for a in rows if a.status == "published" or a.user_id == user.id]
results = []
for article in _ordered(rows, wanted)[:limit]:
results.append({
"id": article.id,
"slug": article.slug,
"title": article.title,
"snippet": _snippet(article.summary or article.content, q),
"status": article.status,
"section_count": len(article.sections or []),
"sections": [
{"section_id": s.section_id, "title": s.title, "snippet": _snippet(s.content, q)}
for s in by_article.get(article.id, [])[:4]
],
})
return results
def _questions(db, user, q, limit):
ranked, semantic = hybrid_ids(db, q, "question", limit=POOL)
if not ranked:
return []
query = bank_query(db, user).filter(Question.id.in_(ranked))
scope = exam_scope_predicate(db, user)
if scope is not None:
query = query.filter(scope)
# Reranked after the visibility rules, not before: spending a cross-encoder's
# candidate slots on rows this learner will never be shown is how a shortlist
# of fifty turns into a page of three.
visible = {row.id: row for row in query.all()}
order = rerank_ids(db, q, "question", [row_id for row_id in ranked if row_id in visible])
rows = [visible[row_id] for row_id in order][:limit]
return [{
"id": row.id,
"snippet": _snippet(row.question_text, q),
"difficulty": row.difficulty,
# Worth saying: a hit nobody's words predicted came from the meaning.
"match": "semantic" if row.id in semantic else "keyword",
} for row in rows]
def _flashcards(db, user, q, limit):
ranked, _ = hybrid_ids(db, q, "flashcard", limit=POOL)
if not ranked:
return []
own = [d.id for d in db.query(FlashcardDeck.id).filter(
FlashcardDeck.user_id == user.id, FlashcardDeck.deleted_at.is_(None)).all()]
if not own:
return []
rows = db.query(Flashcard).filter(
Flashcard.id.in_(ranked), Flashcard.deck_id.in_(own)).all()
return [{"id": row.id, "deck_id": row.deck_id, "front": row.front,
"snippet": _snippet(row.back, q)} for row in _ordered(rows, ranked)[:limit]]
def _media(db, user, q, limit):
ranked, _ = hybrid_ids(db, q, "media", limit=POOL)
if not ranked:
return []
query = db.query(MediaAsset).filter(MediaAsset.id.in_(ranked))
scope = readable_libraries(db, user)
if scope is not None:
query = query.filter(MediaAsset.library_id.in_(scope or {0}))
rows = _ordered(query.all(), ranked)[:limit]
return [{"id": row.id, "path": row.path, "title": row.title,
"snippet": _snippet(row.caption or row.alt_text, q)} for row in rows]
FINDERS = {"article": _articles, "question": _questions,
"flashcard": _flashcards, "media": _media}
@router.get("")
def search_everything(
q: str = Query("", max_length=500),
kinds: str | None = Query(None, description="Comma-separated subset of article,question,flashcard,media"),
limit: int = Query(10, ge=1, le=50),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Search every corpus at once, grouped by what was found."""
query_text = (q or "").strip()
wanted = [k for k in (kinds.split(",") if kinds else KINDS) if k in FINDERS]
empty = {"query": query_text, "results": {kind: [] for kind in wanted}, "total": 0}
if len(query_text) < 2:
return empty
results = {}
for kind in wanted:
try:
results[kind] = FINDERS[kind](db, current_user, query_text, limit)
except Exception:
# One corpus failing is a gap in the answer, not the end of it.
log.warning("Search failed for %s", kind, exc_info=True)
results[kind] = []
return {"query": query_text, "results": results,
"total": sum(len(rows) for rows in results.values())}
@router.get("/suggest")
def suggest(
q: str = Query("", max_length=200),
limit: int = Query(6, ge=1, le=12),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Titles for a typeahead — cheap enough to run on every keystroke.
Deliberately lexical and prefix-based: a typeahead is finishing the word you
are typing, and a semantic neighbour of half a word is noise. No embedding
and no reranker for the same reason — both would be asked to judge relevance
to half a word, at a round trip each, several times a second.
"""
query_text = (q or "").strip()
if len(query_text) < 2:
return {"suggestions": []}
pattern = f"%{query_text.lower()}%"
rows = db.execute(sa_text("""
SELECT id, slug, title, status, user_id FROM articles
WHERE lower(title) LIKE :pattern
ORDER BY CASE WHEN lower(title) LIKE :prefix THEN 0 ELSE 1 END, length(title), title
LIMIT :limit
"""), {"pattern": pattern, "prefix": f"{query_text.lower()}%", "limit": limit * 2}).fetchall()
visible = [r for r in rows
if r.status == "published" or current_user.is_moderator or r.user_id == current_user.id]
return {"suggestions": [{"kind": "article", "id": r.id, "slug": r.slug, "title": r.title}
for r in visible[:limit]]}