"""Module B: Retrieval Utility — measures how well the document supports information retrieval.""" from __future__ import annotations import json import numpy as np import anthropic from kvl.ingestor import Document _QUERY_PROMPT = """Generate {n} realistic search queries that a user would type to find information in this document. Queries should be specific, not generic. Mix question and keyword styles. Return ONLY a JSON array of strings. Example: ["What is the impact of X on Y?", "methodology for measuring Z", ...] Document excerpt: {excerpt}""" def _call_claude(client: anthropic.Anthropic, prompt: str) -> str: msg = client.messages.create( model="claude-haiku-4-5-20251001", max_tokens=1024, messages=[{"role": "user", "content": prompt}], ) return msg.content[0].text.strip() def _generate_queries(client: anthropic.Anthropic, doc: Document, n: int = 8) -> list[str]: excerpt = " ".join(doc.raw.split()[:3000]) raw = _call_claude(client, _QUERY_PROMPT.format(n=n, excerpt=excerpt)) raw = raw.strip() if raw.startswith("```"): raw = "\n".join(raw.split("\n")[1:]) raw = raw.rsplit("```", 1)[0] try: queries = json.loads(raw) return [q for q in queries if isinstance(q, str)][:n] except json.JSONDecodeError: return [] def _build_index(chunks: list, embedder) -> tuple: """Embed all chunks and build a FAISS flat index.""" import faiss texts = [c.text for c in chunks] embeddings = embedder.encode(texts, normalize_embeddings=True, show_progress_bar=False) embeddings = np.array(embeddings, dtype="float32") dim = embeddings.shape[1] index = faiss.IndexFlatIP(dim) # inner product = cosine when normalized index.add(embeddings) return index, embeddings def _retrieve(query: str, index, embedder, k: int = 3) -> list[int]: """Return top-k chunk indices for a query.""" q_emb = embedder.encode([query], normalize_embeddings=True, show_progress_bar=False) q_emb = np.array(q_emb, dtype="float32") _, indices = index.search(q_emb, k) return indices[0].tolist() def _recall_at_k(retrieved: list[int], relevant: int) -> float: return 1.0 if relevant in retrieved else 0.0 def _reciprocal_rank(retrieved: list[int], relevant: int) -> float: for rank, idx in enumerate(retrieved, start=1): if idx == relevant: return 1.0 / rank return 0.0 def evaluate(client: anthropic.Anthropic, doc: Document, embedder, progress_cb=None) -> dict: """Return retrieval utility score (0-100) and detailed results.""" if not doc.chunks: return {"score": 0, "details": [], "summary": "No chunks to index."} if progress_cb: progress_cb("Building retrieval index...") index, _ = _build_index(doc.chunks, embedder) if progress_cb: progress_cb("Generating retrieval test queries...") queries = _generate_queries(client, doc) if not queries: return {"score": 50, "details": [], "summary": "Could not generate test queries."} results = [] recall_scores = [] mrr_scores = [] for i, query in enumerate(queries): if progress_cb: progress_cb(f"Evaluating retrieval query {i+1}/{len(queries)}...") retrieved_indices = _retrieve(query, index, embedder, k=3) # Find the most semantically relevant chunk by re-ranking with cosine sim q_emb = embedder.encode([query], normalize_embeddings=True, show_progress_bar=False) q_emb = np.array(q_emb, dtype="float32") chunk_texts = [c.text for c in doc.chunks] chunk_embs = embedder.encode(chunk_texts, normalize_embeddings=True, show_progress_bar=False) sims = np.dot(chunk_embs, q_emb.T).flatten() best_chunk = int(np.argmax(sims)) r_at_3 = _recall_at_k(retrieved_indices, best_chunk) rr = _reciprocal_rank(retrieved_indices, best_chunk) recall_scores.append(r_at_3) mrr_scores.append(rr) results.append({ "query": query, "retrieved_chunks": retrieved_indices, "best_chunk": best_chunk, "recall_at_3": r_at_3, "reciprocal_rank": rr, }) avg_recall = sum(recall_scores) / len(recall_scores) avg_mrr = sum(mrr_scores) / len(mrr_scores) # Weighted combination: recall@3 (60%) + MRR (40%), mapped to 0-100 raw_score = 0.6 * avg_recall + 0.4 * avg_mrr score = round(raw_score * 100) return { "score": score, "details": results, "summary": f"Recall@3: {avg_recall:.2f} | MRR: {avg_mrr:.2f} across {len(queries)} queries.", }