| """Offline experiment: does query transformation lift retrieval recall? |
| |
| Compares three ways of turning a question into the text we embed, measured with |
| the deterministic retrieval probe (no judge): |
| |
| raw - embed the question as-is (current production behaviour on Gemini) |
| rewrite - rephrase with official terminology (the existing _REWRITE_PROMPT) |
| hyde - generate a short hypothetical ANSWER and embed THAT (HyDE) |
| |
| The oracle ceiling test (hand-written ideal HyDE passages) showed eval-007 |
| 15->4, eval-008 14->6, eval-002 None->15. This measures how close an automatic |
| LLM rewrite gets to that ceiling, and crucially whether it REGRESSES questions |
| that already retrieve well. |
| |
| Usage (from backend/): |
| python -m scripts.rewrite_experiment |
| |
| Requires: DATABASE_URL + corpus, and an openai_compat LLM (LLM_BASE_URL/ |
| LLM_API_KEY/LLM_MODEL). Does NOT use the judge. |
| """ |
| import openai |
| from dotenv import load_dotenv |
|
|
| load_dotenv() |
|
|
| from app.config import Settings |
| from app.db import close_pool, get_conn, init_pool |
| from app.rag.embedder import Embedder |
| from app.rag.retrieval import Chunk, _authority_boost, hybrid_search |
| from scripts.eval_judge import _parse_refs |
| from scripts.retrieval_probe import ( |
| TOP_K, |
| TOP_K_FETCH, |
| _load_evaluable, |
| first_covering_rank, |
| recall_at_k, |
| ) |
|
|
| _HYDE_PROMPT = """\ |
| You answer rules questions about the Riftbound trading card game. |
| Write a short, confident hypothetical answer (2-3 sentences) to the question |
| below, using official rulebook terminology. It does not need to be perfectly |
| correct — it will be used to retrieve the real rule by semantic similarity. |
| Output only the answer. |
| |
| Question: {question} |
| Answer:""" |
|
|
|
|
| def _hyde(question: str, *, base_url: str, api_key: str, model: str) -> str: |
| """Generate a hypothetical answer (HyDE). Falls back to the question on error.""" |
| try: |
| client = openai.OpenAI(base_url=base_url, api_key=api_key) |
| resp = client.chat.completions.create( |
| model=model, |
| messages=[{"role": "user", "content": _HYDE_PROMPT.format(question=question)}], |
| temperature=0.0, |
| max_tokens=160, |
| timeout=15.0, |
| ) |
| out = resp.choices[0].message.content |
| if out: |
| return out.strip() |
| except Exception as e: |
| print(f" [hyde error] {str(e)[:80]}") |
| return question |
|
|
|
|
| def _fuse(arm_a: list[Chunk], arm_b: list[Chunk], weight_b: float, rrf_k: int = 60, |
| top_k: int = TOP_K) -> list[Chunk]: |
| """RRF-fuse two retrieval result lists. Arm A is primary (tie-break winner); |
| arm B is scaled by *weight_b* so it can ADD signal but, when down-weighted, |
| never displace a strong arm-A hit. Authority boost applies to both arms so |
| errata/patch keep priority (mirrors production _rrf_fuse).""" |
| scores: dict[str, float] = {} |
| by_id: dict[str, Chunk] = {} |
| in_a: set[str] = set() |
| for rank0, ch in enumerate(arm_a): |
| scores[ch.id] = scores.get(ch.id, 0.0) + _authority_boost(ch.source_type) / (rrf_k + rank0 + 1) |
| by_id[ch.id] = ch |
| in_a.add(ch.id) |
| for rank0, ch in enumerate(arm_b): |
| scores[ch.id] = scores.get(ch.id, 0.0) + weight_b * _authority_boost(ch.source_type) / (rrf_k + rank0 + 1) |
| by_id.setdefault(ch.id, ch) |
| order = sorted(scores, key=lambda cid: (-scores[cid], 0 if cid in in_a else 1)) |
| return [by_id[cid] for cid in order[:top_k]] |
|
|
|
|
| def _retrieve(text, embedder, pool, cv) -> list[Chunk]: |
| emb = embedder.encode(text) |
| return hybrid_search(pool, emb, text, cv, top_k=TOP_K, top_k_fetch=TOP_K_FETCH) |
|
|
|
|
| def main() -> None: |
| s = Settings() |
| base_url, api_key, model = s.llm_base_url, s.llm_api_key, s.llm_model |
| if not (base_url and api_key and model): |
| raise SystemExit("Set LLM_BASE_URL / LLM_API_KEY / LLM_MODEL for the rewrite experiment.") |
|
|
| questions = _load_evaluable() |
| pool = init_pool(s.database_url, 1, 3) |
| with get_conn(pool) as c: |
| with c.cursor() as cur: |
| cur.execute("SELECT MAX(corpus_version) FROM corpus_chunks") |
| cv = cur.fetchone()[0] |
| embedder = Embedder.load(s.model_name) |
| print(f"corpus={cv} model={model} {len(questions)} evaluable questions\n") |
|
|
| |
| |
| strategies = ["raw", "hyde", "fuse_eq", "fuse_dw"] |
| ranks = {name: [] for name in strategies} |
| ids = [] |
| try: |
| for q in questions: |
| refs = _parse_refs(q["rule_reference"]) |
| raw_chunks = _retrieve(q["question"], embedder, pool, cv) |
| hyde_text = _hyde(q["question"], base_url=base_url, api_key=api_key, model=model) |
| hyde_chunks = _retrieve(hyde_text, embedder, pool, cv) |
|
|
| per = { |
| "raw": raw_chunks, |
| "hyde": hyde_chunks, |
| "fuse_eq": _fuse(raw_chunks, hyde_chunks, weight_b=1.0), |
| "fuse_dw": _fuse(raw_chunks, hyde_chunks, weight_b=0.3), |
| } |
| for name in strategies: |
| ranks[name].append(first_covering_rank(refs, per[name])) |
| ids.append(q["id"]) |
| print(f" {q['id']} done") |
| finally: |
| close_pool(pool) |
|
|
| print(f"\n{'strategy':8s} @5 @10 @15 (raw is the production baseline)") |
| for name in strategies: |
| print(f"{name:8s} {recall_at_k(ranks[name], 5):>4.0%} " |
| f"{recall_at_k(ranks[name], 10):>4.0%} {recall_at_k(ranks[name], 15):>4.0%}") |
|
|
| print(f"\n{'id':10s} {'raw':>4s} {'hyde':>5s} {'fuse_eq':>8s} {'fuse_dw':>8s}") |
| for i, qid in enumerate(ids): |
| cell = lambda n: str(ranks[n][i]) |
| print(f"{qid:10s} {cell('raw'):>4s} {cell('hyde'):>5s} {cell('fuse_eq'):>8s} {cell('fuse_dw'):>8s}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|