Files
kb/engine/kb/reranker.py
T
steve 6dfc13be1d Add reranking, RRF fusion, bench harness, tag contexts, and data ingestion
Implements five of the six enhancements from docs/kb-enhancements-proposal.htm,
closing the retrieval-quality gap identified in the qmd review.

- Cross-encoder reranking: new kb/reranker.py loads an optional reranking
  model at startup (KB_RERANK_ENABLED, KB_RERANKER_MODEL,
  KB_RERANK_CANDIDATES). Search degrades gracefully to plain hybrid
  retrieval when the model is absent. Exposed via a "rerank" block in
  /status, a rerank flag on search, and --no-rerank in the CLI.
- RRF rank fusion: FTS and vector lists now merge by reciprocal rank
  fusion with a top-rank bonus, replacing the old score blend. Scores are
  comparable across queries.
- Bench harness and explain traces: kb bench runs a query fixture against
  each backend and reports precision@k, recall and MRR. --explain returns a
  per-result score breakdown.
- Tag context descriptions: tags carry an optional one-line description
  (kb tag-describe), returned as tag_contexts with search results. Adds a
  tags.description column migration.
- Structured data ingestion: .json/.yaml/.toml files ingest as text via the
  new "data" doc type, pretty-printing minified JSON before chunking.

Query expansion (proposal item 5) is deliberately left out pending bench
results. Requires engine v3.3.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 09:51:33 +01:00

64 lines
2.0 KiB
Python

"""Cross-encoder reranker management.
Mirrors the embeddings module: one module-level model, loaded eagerly at
startup when reranking is enabled. Reranking is strictly optional — search
degrades gracefully to plain hybrid retrieval when the model is absent.
"""
import logging
from typing import Optional
logger = logging.getLogger("kb.reranker")
_reranker: Optional[object] = None
_model_name: Optional[str] = None
def load_reranker(model_name: str, device: str = "cpu") -> None:
"""Load a cross-encoder reranking model.
Args:
model_name: HuggingFace model name or local path. Must have a
sequence-classification head (e.g. BAAI/bge-reranker-v2-m3).
device: Target device — "cpu", "cuda", or "auto".
"""
global _reranker, _model_name
from sentence_transformers import CrossEncoder
from kb.embeddings import _resolve_device
resolved_device = _resolve_device(device)
logger.info("Loading reranker '%s' on device '%s'", model_name, resolved_device)
_reranker = CrossEncoder(model_name, device=resolved_device)
_model_name = model_name
logger.info("Reranker loaded: %s", model_name)
def is_available() -> bool:
"""Return True if a reranker model is loaded and usable."""
return _reranker is not None
def rerank_scores(query: str, texts: list[str]) -> list[float]:
"""Score (query, text) pairs with the cross-encoder.
Returns:
One relevance score per text, sigmoid-normalised to 0-1.
Raises:
RuntimeError: If no reranker has been loaded.
"""
if _reranker is None:
raise RuntimeError("Reranker not loaded. Call load_reranker() first.")
import numpy as np
scores = _reranker.predict([(query, t) for t in texts], convert_to_numpy=True)
# CrossEncoder heads may emit raw logits; squash to 0-1 so scores blend
# predictably with normalised retrieval scores. Sigmoid is monotonic, so
# ordering is unaffected for models that already output probabilities.
return (1.0 / (1.0 + np.exp(-np.asarray(scores, dtype="float64")))).tolist()