Every client has the same complaint: "the answer is somewhere in our documents". This module shows how to choose between pasting documents into Claude and building retrieval, and then how to build a Hebrew, Russian and English search-and-answer system that cites its sources and can be measured.
Do not build retrieval by reflex. If the whole document set fits in Claude's context with room to spare, pasting it in is simpler, has fewer moving parts, and lets Claude reason across documents. Retrieval earns its complexity when the set is too big, changes often, or must respect who may see what.
| Option | Good when | Watch out for |
|---|---|---|
| Projects knowledge (Claude app) | A team of 5 to 50 people asks questions about a stable set of files, and nobody wants to maintain code | No custom permissions, no custom eval harness, and no use from your own app. Check the current Projects behaviour in the Help Center before promising anything |
| Long context + prompt caching (API) | You need the answer inside your own app or workflow, and the full set fits in the context window. Cross-document questions ("which policies conflict?") work well | Every question pays to read the full set, even with caching. Quality can drop when the prompt is very long. Cache expiry makes latency uneven |
| Retrieval (RAG) | The set is large, grows, changes daily, or has per-user access rules. You want citations to exact pages | More parts to build and monitor. Bad retrieval means a confident answer from the wrong page. You must measure it |
| Hybrid of the two | Retrieve whole documents or large sections, then give Claude those in full | Needs good document-level metadata. Larger prompts per question |
Context window sizes and prompt caching rules change. Look up the current numbers in the Claude docs (models overview and prompt caching pages) before you quote any size to a client.
A RAG system is two loops. The offline loop turns files into an index and runs when documents change. The online loop runs for every question: find candidates, tighten them, and let Claude answer from them with citations.
| Strategy | How it works | Use it when |
|---|---|---|
| Fixed size + overlap | Cut every N characters, repeat a tail | Quick baseline only. It cuts through sentences and tables |
| Structure-aware | Split on headings and paragraphs, merge small pieces up to a target size | Default for policies and procedures. The lab uses this |
| Semantic | Split where the embedding similarity between sentences drops | Long unstructured text such as meeting notes. Costs more, gains are uneven |
| Parent-child | Search small chunks, but send Claude the whole section they belong to | Answers need surrounding context, for example exceptions listed below a rule |
| Whole-document | One entry per short document (a form, an FAQ item) | Many tiny documents |
Chunk size is a trade: small chunks match precisely but lose context; large chunks keep context but blur the match and use more of the prompt. Start with a few paragraphs, then tune the number with your eval from 5.8. Do not argue about it from theory.
Store, for each chunk: source file, page or section heading, language, last-modified date, document version or status (current, superseded), department or owner, and an access group. These fields give you citations ("handbook, page 14"), filters ("only current policies", "only documents this user may see") and freshness control later.
An embedding model turns text into a vector so that similar meanings land close together. Anthropic does not ship its own embedding model. The embeddings page in the Claude docs discusses the options and points to a provider, Voyage AI. You will still pick a model yourself, and the choice matters most for Hebrew.
| Option | Hosting | Notes for Hebrew and Russian |
|---|---|---|
| Voyage AI models | Hosted API | Strong retrieval quality and the one the Claude docs point to. Check which models list multilingual support, and test Hebrew on your own questions |
| BAAI bge-m3 | Open weights, run it yourself | Multilingual, handles cross-language queries (Hebrew question, English document). Runs on your machine or client server, so text never leaves. The lab uses it |
| multilingual-e5 family | Open weights, run it yourself | Multilingual and widely used. Needs the "query: " and "passage: " prefixes in front of text, or quality drops |
| Other cloud providers' embedding APIs | Hosted API | Fine if the client already uses that provider. Check data terms and test Hebrew first |
Two practical rules. First, hosted versus local is a client decision about where HR text may go; ask early, and check Israeli privacy requirements with the client's counsel for employee data. Second, a model that handles the languages separately is not enough for a bilingual company: you want Hebrew and English questions to find the same document.
Vector search finds meaning: "how many days off do I get" matches a paragraph titled "annual leave entitlement". It is weaker on exact strings: form numbers, product names, names of systems, acronyms. BM25 is the opposite: it scores exact word overlap and ignores meaning. Use both.
| Step | What it does | In the lab |
|---|---|---|
| Vector search | Nearest chunks by embedding similarity | Chroma, cosine distance |
| BM25 | Keyword ranking over the same chunks | rank_bm25 with a Hebrew-aware tokenizer |
| Fusion | Merge the two ranked lists. Reciprocal rank fusion uses only ranks, so you never calibrate scores | rrf() in retrieve.py |
| Rerank (optional) | A second model reads the question and each candidate together and reorders them | Small Claude model as a listwise reranker. A dedicated reranker model is the usual alternative |
Rerank when your eval shows the right chunk is often in the candidates but not in the top few. It adds latency and cost per question, so it is a measured upgrade, not a default.
A chunk taken out of its document often loses what it is about. "Unused days expire in March" does not say whether it means vacation, sick leave or training days. Anthropic's contextual retrieval fixes this at ingestion time: for each chunk, Claude reads the whole document and writes one or two sentences placing the chunk in it. That note is prepended to the chunk before embedding and before BM25 indexing.
cache_control and later chunks of the same file reuse it instead of paying full price. Process one file's chunks in a row, so the cache stays warm. For a large backlog, the Batch API is another cost lever; the caching and batch docs describe how the two interact.An answer without a source is a claim the employee cannot check. Two approaches work.
You pass the retrieved passages as document blocks with citations enabled. Claude's reply then carries structured citation objects that point to the exact passage used for each claim, so you do not have to trust a source name the model typed. The lab uses this. The block types, citation types and current limits are in the citations documentation; read it rather than relying on a summary, because the details evolve.
Label each passage with an id, ask Claude to put a short verbatim quote and the id after each claim, and verify in code that the quote really appears in that passage. This works with any model and any output format, and the verification is cheap:
def quote_is_real(quote: str, passage: str) -> bool:
norm = lambda s: " ".join(s.split())
return norm(quote) in norm(passage)
Reject or flag any answer whose quotes fail the check. Hebrew needs the same normalisation here as in search, or niqqud and quote-mark differences will cause false alarms.
| Issue | What goes wrong | What to do |
|---|---|---|
| Right-to-left | Extraction order from PDFs can reverse Hebrew; digits and Latin words inside Hebrew text jump around in the UI | Store text in logical order. Check extraction by eye. In HTML output use dir="auto" on each answer block so Hebrew, English and Russian each align correctly |
| Hebrew prefixes | One-letter prefixes (ו, ה, ב, כ, ל, מ, ש) are glued to the word. A query for "חופשה" will not match "בחופשה" or "והחופשה" in BM25, because they are different tokens | Generate both the original token and prefix-stripped variants, as tokenize() does. This over-generates (a real first letter gets stripped sometimes), which is acceptable because the original token is still indexed. A Hebrew morphological analyser is the heavier option; measure first |
| Spelling variants | The same word appears with and without added vowel letters, and abbreviations use different quote marks: דוח, דו"ח, דו״ח | Normalise gershayim, geresh and curly quotes, then remove quotes inside words. Strip niqqud. Apply the same function to documents and queries |
| Russian inflection | отпуск, отпуска, отпуске are three tokens to BM25 | Vectors cover most of it. For BM25, add a stemmer or lemmatiser for Russian if misses show up in the eval |
| Mixed language | Hebrew sentences contain English system names (Workday, Jira) and numbers | This is where BM25 helps: exact Latin tokens match perfectly. Keep them, lowercased |
| Cross-language questions | An employee asks in Hebrew about a policy that exists only in English | Use a multilingual embedding model, and have Claude answer in the question's language while citing the English source |
"It seems to work" is not a deliverable. Build a small labelled set before you tune anything, and rerun it after every change.
Write about 30 questions from the real documents, with a reference answer and the source file and page that contains it. Mix them on purpose:
| Metric | Question it answers | How to compute |
|---|---|---|
| recall@k | Was the right passage among the top k retrieved? | Share of answerable questions where any top-k chunk matches the gold source (and page) |
| Faithfulness | Is every claim in the answer supported by the passages given? | Claude as judge, given the passages and the answer |
| Correctness | Does the answer agree with the reference? | Claude as judge, given the reference |
| Citation rate | Did answerable questions get at least one citation? | Count answers with a non-empty citation list |
| Refusal quality | Does it admit when the documents are silent? | On unanswerable questions, check that the answer says so |
Read recall and faithfulness together. Low recall means search is the problem: look at chunks, tokenizer, embedding model. High recall with low faithfulness means the prompt or the model is the problem. A faithful but wrong answer usually means the right passage was never retrieved, or the documents conflict.
A knowledge assistant that quotes last year's policy is worse than none. Plan freshness before go-live.
Goal: build, run and measure a Hebrew and English assistant over about 200 pages of HR policies and procedures, with hybrid retrieval, cited answers and an eval script. Four files, each runnable on its own.
Corpus. Use a friendly client's non-sensitive procedures, or make a synthetic set: ask Claude to write 12 to 15 documents (leave, sick days, expenses, remote work, onboarding, safety, equipment, travel) in Hebrew and English, with a few deliberate conflicts and one superseded version. Save them as a mix of PDF, DOCX and HTML in docs/. Do not put real employee data in your lab.
Setup
mkdir hr-rag && cd hr-rag
python -m venv .venv && source .venv/bin/activate
pip install anthropic chromadb sentence-transformers rank-bm25 pypdf python-docx beautifulsoup4 python-dotenv
echo "ANTHROPIC_API_KEY=sk-ant-..." > .env
mkdir docs # put your 200 pages here
The first run downloads the embedding model, which is large. Run pip and the first ingest on a good connection.
ingest.py
"""ingest.py - read PDF/DOCX/HTML, chunk, optionally add Claude-written context, embed, store in Chroma.
Usage:
python ingest.py ./docs
python ingest.py ./docs --contextualize
python ingest.py ./docs --rebuild
"""
import argparse
import hashlib
import json
import re
from datetime import datetime, timezone
from pathlib import Path
import anthropic
import chromadb
from dotenv import load_dotenv
from sentence_transformers import SentenceTransformer
load_dotenv()
EMBED_MODEL = "BAAI/bge-m3" # multilingual: Hebrew, Russian, English
COLLECTION = "hr_docs"
INDEX_DIR = "index"
MANIFEST = Path(INDEX_DIR) / "manifest.json"
CONTEXT_MODEL = "claude-haiku-5-5" # check current model IDs at docs.claude.com
TARGET_CHARS = 1400 # tune with your eval, not by feel
OVERLAP_CHARS = 200
MAX_DOC_CHARS = 300_000 # keep the whole-document prompt inside the model context
# ---------- 1. readers: file -> list of {page, heading, text} ----------
def read_pdf(path):
from pypdf import PdfReader
units = []
for i, page in enumerate(PdfReader(str(path)).pages, start=1):
# Check Hebrew output by eye: some PDFs extract reversed or with broken digits.
units.append({"page": i, "heading": "", "text": page.extract_text() or ""})
return units
def read_docx(path):
import docx
d = docx.Document(str(path))
units, heading, buf = [], "", []
for p in d.paragraphs:
txt = p.text.strip()
if not txt:
continue
if p.style.name.lower().startswith("heading"):
if buf:
units.append({"page": 0, "heading": heading, "text": "\n\n".join(buf)})
buf = []
heading = txt
else:
buf.append(txt)
if buf:
units.append({"page": 0, "heading": heading, "text": "\n\n".join(buf)})
for t in d.tables: # keep table rows as "cell | cell | cell" lines
rows = [" | ".join(c.text.strip() for c in r.cells) for r in t.rows]
units.append({"page": 0, "heading": heading, "text": "\n".join(rows)})
return units
def read_html(path):
from bs4 import BeautifulSoup
soup = BeautifulSoup(path.read_text(encoding="utf-8", errors="ignore"), "html.parser")
for tag in soup(["script", "style", "nav", "footer", "header"]):
tag.decompose()
units, heading, buf = [], "", []
for el in soup.find_all(["h1", "h2", "h3", "p", "li"]):
txt = el.get_text(" ", strip=True)
if not txt:
continue
if el.name in ("h1", "h2", "h3"):
if buf:
units.append({"page": 0, "heading": heading, "text": "\n\n".join(buf)})
buf = []
heading = txt
else:
buf.append(txt)
if buf:
units.append({"page": 0, "heading": heading, "text": "\n\n".join(buf)})
return units
READERS = {".pdf": read_pdf, ".docx": read_docx, ".html": read_html, ".htm": read_html}
# ---------- 2. chunking: paragraphs first, sentences if a paragraph is too long ----------
def split_text(text, target=TARGET_CHARS, overlap=OVERLAP_CHARS):
text = re.sub(r"[ \t]+", " ", text)
pieces = []
for para in (p.strip() for p in re.split(r"\n\s*\n", text)):
if not para:
continue
if len(para) <= target:
pieces.append(para)
else:
pieces.extend(s.strip() for s in re.split(r"(?<=[.!?])\s+", para) if s.strip())
chunks, cur = [], ""
for piece in pieces:
if cur and len(cur) + len(piece) + 1 > target:
chunks.append(cur)
tail = cur[-overlap:].split(" ", 1)[-1] # drop a cut-off first word
cur = tail + " " + piece
else:
cur = (cur + "\n" + piece) if cur else piece
if cur.strip():
chunks.append(cur)
return chunks
def detect_lang(text):
he = len(re.findall(r"[-]", text))
ru = len(re.findall(r"[Ѐ-ӿ]", text))
en = len(re.findall(r"[A-Za-z]", text))
return max((("he", he), ("ru", ru), ("en", en)), key=lambda x: x[1])[0]
# ---------- 3. contextual retrieval: Claude writes a situating note per chunk ----------
def make_context(client, doc_text, chunk_text):
try:
resp = client.messages.create(
model=CONTEXT_MODEL,
max_tokens=150,
messages=[{"role": "user", "content": [
# Same document block for every chunk of this file: cache it.
{"type": "text", "text": f"<document>\n{doc_text}\n</document>",
"cache_control": {"type": "ephemeral"}},
{"type": "text", "text": (
"Here is a chunk from the document above:\n<chunk>\n" + chunk_text + "\n</chunk>\n"
"Write one or two short sentences that place this chunk in the whole document "
"(which policy, which section, which topic) so a search engine can find it. "
"Write in the same language as the chunk. Output only those sentences.")},
]}],
)
return resp.content[0].text.strip()
except anthropic.APIError as e:
print(" context failed, continuing without it:", e)
return ""
# ---------- 4. main: only changed files are re-processed ----------
def sha256(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
def main():
ap = argparse.ArgumentParser()
ap.add_argument("folder")
ap.add_argument("--contextualize", action="store_true")
ap.add_argument("--rebuild", action="store_true")
args = ap.parse_args()
root = Path(args.folder)
Path(INDEX_DIR).mkdir(exist_ok=True)
db = chromadb.PersistentClient(path=INDEX_DIR)
if args.rebuild:
try:
db.delete_collection(COLLECTION)
except Exception:
pass
manifest = {}
else:
manifest = json.loads(MANIFEST.read_text()) if MANIFEST.exists() else {}
col = db.get_or_create_collection(
COLLECTION, metadata={"hnsw:space": "cosine", "embed_model": EMBED_MODEL})
client = anthropic.Anthropic() if args.contextualize else None
model = None
seen = set()
for path in sorted(p for p in root.rglob("*") if p.suffix.lower() in READERS):
key = str(path.relative_to(root))
seen.add(key)
digest = sha256(path)
if manifest.get(key) == digest:
continue
print("indexing", key)
col.delete(where={"source": key}) # drop the old version of this file
units = [u for u in READERS[path.suffix.lower()](path) if u["text"].strip()]
doc_text = "\n\n".join(u["text"] for u in units)[:MAX_DOC_CHARS]
mtime = datetime.fromtimestamp(path.stat().st_mtime, timezone.utc).date().isoformat()
rows = []
for u in units:
for piece in split_text(u["text"]):
ctx = make_context(client, doc_text, piece) if client else ""
rows.append({"text": piece, "context": ctx, "page": u["page"], "heading": u["heading"]})
if not rows:
manifest[key] = digest
continue
if model is None:
model = SentenceTransformer(EMBED_MODEL)
to_embed = [(r["context"] + "\n" + r["text"]).strip() for r in rows]
vecs = model.encode(to_embed, normalize_embeddings=True, batch_size=16).tolist()
col.add(
ids=[f"{key}#{n}" for n in range(len(rows))],
embeddings=vecs,
documents=[r["text"] for r in rows],
metadatas=[{"source": key, "page": r["page"], "heading": r["heading"],
"lang": detect_lang(r["text"]), "context": r["context"],
"mtime": mtime, "sha": digest[:12]} for r in rows],
)
manifest[key] = digest
for gone in set(manifest) - seen: # file was deleted from the folder
print("removing", gone)
col.delete(where={"source": gone})
del manifest[gone]
MANIFEST.write_text(json.dumps(manifest, indent=2))
print("chunks in index:", col.count())
if __name__ == "__main__":
main()
retrieve.py
"""retrieve.py - hybrid retrieval: vectors (Chroma) + BM25, fused with reciprocal rank fusion.
Usage:
python retrieve.py "כמה ימי מחלה מותר לצבור?"
"""
import json
import re
import sys
import unicodedata
import anthropic
import chromadb
from dotenv import load_dotenv
from rank_bm25 import BM25Okapi
from sentence_transformers import SentenceTransformer
load_dotenv()
EMBED_MODEL = "BAAI/bge-m3"
COLLECTION = "hr_docs"
INDEX_DIR = "index"
RERANK_MODEL = "claude-haiku-5-5" # check current model IDs at docs.claude.com
NIQQUD = re.compile(r"[֑-ׇ]")
TOKEN = re.compile(r"\w+", re.UNICODE)
HE_PREFIXES = "והבכלמש" # vav, he, bet, kaf, lamed, mem, shin
def normalize(text):
t = unicodedata.normalize("NFKC", text)
t = NIQQUD.sub("", t) # vowel marks
t = t.replace("״", '"').replace("“", '"').replace("”", '"') # gershayim, curly quotes
t = t.replace("׳", "'").replace("’", "'")
t = re.sub(r"(?<=\w)[\"'](?=\w)", "", t) # בע"מ -> בעמ, דו"ח -> דוח
return t.lower()
def tokenize(text):
"""Word tokens plus Hebrew prefix-stripped variants (over-generates on purpose)."""
out = []
for w in TOKEN.findall(normalize(text)):
out.append(w)
stem = w
for _ in range(2): # allow two stacked prefixes
if len(stem) > 3 and stem[0] in HE_PREFIXES:
stem = stem[1:]
out.append(stem)
else:
break
return out
def rrf(rankings, k=60):
"""Reciprocal rank fusion: robust, no score calibration needed."""
score = {}
for ranking in rankings:
for rank, doc_id in enumerate(ranking):
score[doc_id] = score.get(doc_id, 0.0) + 1.0 / (k + rank + 1)
return sorted(score, key=score.get, reverse=True), score
def chroma_where(where):
if not where:
return None
items = [{k: v} for k, v in where.items()]
return items[0] if len(items) == 1 else {"$and": items}
def rerank_with_claude(query, hits):
"""Optional listwise rerank by a small Claude model. A dedicated reranker model is the alternative."""
client = anthropic.Anthropic()
listing = "\n\n".join(f"[{n}] {h['text'][:600]}" for n, h in enumerate(hits))
msg = client.messages.create(
model=RERANK_MODEL, max_tokens=200,
messages=[{"role": "user", "content": (
f"Question: {query}\n\nPassages:\n{listing}\n\n"
"Return a JSON list of passage numbers, most useful for answering the question first. "
"Output only the JSON list.")}])
m = re.search(r"\[[\d,\s]*\]", msg.content[0].text)
if not m:
return hits
order = [i for i in json.loads(m.group(0)) if isinstance(i, int) and 0 <= i < len(hits)]
order += [i for i in range(len(hits)) if i not in order]
seen, result = set(), []
for i in order:
if i not in seen:
seen.add(i)
result.append(hits[i])
return result
class Retriever:
def __init__(self):
db = chromadb.PersistentClient(path=INDEX_DIR)
self.col = db.get_collection(COLLECTION)
if (self.col.metadata or {}).get("embed_model") != EMBED_MODEL:
raise SystemExit("Index was built with a different embedding model. Re-run ingest.py --rebuild.")
self.model = SentenceTransformer(EMBED_MODEL)
data = self.col.get(include=["documents", "metadatas"])
self.ids, self.docs, self.metas = data["ids"], data["documents"], data["metadatas"]
self.pos = {i: n for n, i in enumerate(self.ids)}
# BM25 sees the Claude-written context too, when ingest was run with --contextualize
corpus = [tokenize(m.get("context", "") + " " + d) for d, m in zip(self.docs, self.metas)]
self.bm25 = BM25Okapi(corpus)
def _hit(self, doc_id, score):
n = self.pos[doc_id]
return {"id": doc_id, "text": self.docs[n], "meta": self.metas[n], "score": score}
def search(self, query, k=5, candidates=30, mode="hybrid", where=None, rerank=False):
"""mode: 'hybrid' | 'vector' | 'bm25'. where: equality filter such as {'lang': 'he'}."""
rankings = []
if mode in ("hybrid", "vector"):
qv = self.model.encode([query], normalize_embeddings=True).tolist()
res = self.col.query(query_embeddings=qv, n_results=min(candidates, len(self.ids)),
where=chroma_where(where))
rankings.append(res["ids"][0])
if mode in ("hybrid", "bm25"):
scores = self.bm25.get_scores(tokenize(query))
order = sorted(range(len(scores)), key=lambda n: -scores[n])
ids = [self.ids[n] for n in order if scores[n] > 0
and all(self.metas[n].get(a) == b for a, b in (where or {}).items())]
rankings.append(ids[:candidates])
fused, score = rrf(rankings)
pool = fused[: max(k, 20)] if rerank else fused[:k]
hits = [self._hit(i, score[i]) for i in pool]
if rerank:
hits = rerank_with_claude(query, hits)
return hits[:k]
if __name__ == "__main__":
r = Retriever()
for h in r.search(" ".join(sys.argv[1:]) or "annual leave"):
m = h["meta"]
print(f"{h['score']:.4f} {m['source']} p.{m['page']} {h['text'][:100]!r}")
answer.py
"""answer.py - retrieve, then let Claude answer with citations that point at the retrieved passages.
Usage:
python answer.py "How do I request remote work days?"
"""
import sys
import anthropic
from dotenv import load_dotenv
from retrieve import Retriever
load_dotenv()
MODEL = "claude-sonnet-5-5" # check current model IDs at docs.claude.com
client = anthropic.Anthropic()
SYSTEM = """You answer employee questions about HR policy and company procedures.
Rules:
- Use only the provided documents. If they do not contain the answer, say so plainly and suggest asking HR. Do not guess.
- Reply in the language of the question (Hebrew, English or Russian). Be short and concrete.
- If documents disagree or look outdated, say so and name both."""
def doc_blocks(hits):
blocks = []
for h in hits:
m = h["meta"]
title = m["source"] + (f" p.{m['page']}" if m.get("page") else "")
blocks.append({
"type": "document",
"source": {"type": "text", "media_type": "text/plain", "data": h["text"]},
"title": title,
"context": f"Section: {m.get('heading') or '-'}. Last modified: {m.get('mtime', '?')}.",
"citations": {"enabled": True},
})
return blocks
def ask(question, retriever, k=5, rerank=False, hits=None):
hits = hits if hits is not None else retriever.search(question, k=k, rerank=rerank)
resp = client.messages.create(
model=MODEL, max_tokens=800, system=SYSTEM,
messages=[{"role": "user", "content": doc_blocks(hits) + [{"type": "text", "text": question}]}],
)
parts, numbering, citations = [], {}, []
for block in resp.content:
if block.type != "text":
continue
parts.append(block.text)
for c in (block.citations or []):
idx = c.document_index
if idx not in numbering:
numbering[idx] = len(numbering) + 1
m = hits[idx]["meta"]
citations.append({"n": numbering[idx], "source": m["source"], "page": m.get("page"),
"quote": c.cited_text, "chunk_id": hits[idx]["id"]})
parts.append(f" [{numbering[idx]}]")
return {"answer": "".join(parts).strip(), "citations": citations, "hits": hits}
if __name__ == "__main__":
result = ask(" ".join(sys.argv[1:]), Retriever())
print(result["answer"], "\n")
for c in result["citations"]:
page = f" p.{c['page']}" if c["page"] else ""
print(f"[{c['n']}] {c['source']}{page}: {c['quote'][:120]!r}")
eval.py
"""eval.py - recall@k for retrieval, plus faithfulness and correctness judged by Claude.
qa.jsonl, one JSON object per line:
{"q": "...", "ref": "reference answer", "source": "file.pdf", "page": 14, "lang": "he"}
For DOCX/HTML leave out "page". For a question the documents cannot answer, use "source": null.
Usage:
python eval.py --qa qa.jsonl --k 5 --mode hybrid
python eval.py --qa qa.jsonl --no-judge # retrieval only, no API cost
"""
import argparse
import json
import re
from collections import defaultdict
import anthropic
from dotenv import load_dotenv
from answer import ask
from retrieve import Retriever
load_dotenv()
JUDGE_MODEL = "claude-sonnet-5-5" # check current model IDs; ideally not the weakest model you ship
JUDGE_PROMPT = """You grade a document Q&A system. Be strict.
QUESTION: {q}
REFERENCE ANSWER (written by a human from the documents; may say the documents have no answer):
{ref}
PASSAGES THE SYSTEM WAS GIVEN:
{passages}
SYSTEM ANSWER:
{ans}
Return JSON only: {{"faithful": true|false, "correct": true|false, "unsupported_claims": ["..."]}}
faithful: every factual claim in the system answer is supported by the passages above.
correct: the system answer agrees with the reference answer in substance.
If the system says the documents do not contain the answer, faithful is true, and correct is true only if the reference also says there is no answer."""
def is_hit(hit, gold):
m = hit["meta"]
return m["source"] == gold["source"] and (gold.get("page") is None or m.get("page") == gold["page"])
def parse_json(text):
m = re.search(r"\{.*\}", text, re.S)
try:
return json.loads(m.group(0)) if m else None
except json.JSONDecodeError:
return None
def judge(client, row, result):
passages = "\n\n".join(f"[{n + 1}] {h['meta']['source']}: {h['text']}" for n, h in enumerate(result["hits"]))
msg = client.messages.create(
model=JUDGE_MODEL, max_tokens=400,
messages=[{"role": "user", "content": JUDGE_PROMPT.format(
q=row["q"], ref=row["ref"], passages=passages, ans=result["answer"])}])
return parse_json(msg.content[0].text) or {"faithful": False, "correct": False,
"unsupported_claims": ["judge returned invalid JSON"]}
def pct(values):
return f"{100 * sum(values) / len(values):.0f}%" if values else "n/a"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--qa", default="qa.jsonl")
ap.add_argument("--k", type=int, default=5)
ap.add_argument("--mode", default="hybrid", choices=["hybrid", "vector", "bm25"])
ap.add_argument("--rerank", action="store_true")
ap.add_argument("--no-judge", action="store_true")
args = ap.parse_args()
rows = [json.loads(line) for line in open(args.qa, encoding="utf-8") if line.strip()]
retriever, client = Retriever(), anthropic.Anthropic()
stats = defaultdict(lambda: defaultdict(list))
details = []
for row in rows:
lang = row.get("lang", "?")
hits = retriever.search(row["q"], k=args.k, mode=args.mode, rerank=args.rerank)
rec = {"q": row["q"], "lang": lang, "top": [f"{h['meta']['source']} p.{h['meta']['page']}" for h in hits]}
if row.get("source"):
rec["hit"] = any(is_hit(h, row) for h in hits)
for g in ("all", lang):
stats[g]["recall"].append(rec["hit"])
if not args.no_judge:
result = ask(row["q"], retriever, hits=hits)
verdict = judge(client, row, result)
rec.update(answer=result["answer"], faithful=bool(verdict.get("faithful")),
correct=bool(verdict.get("correct")), unsupported=verdict.get("unsupported_claims", []))
for g in ("all", lang):
stats[g]["faithful"].append(rec["faithful"])
stats[g]["correct"].append(rec["correct"])
if row.get("source"):
stats[g]["cited"].append(bool(result["citations"]))
details.append(rec)
print(f"\nmode={args.mode} k={args.k} rerank={args.rerank} questions={len(rows)}")
print(f"{'group':8} {'recall@k':>9} {'faithful':>9} {'correct':>9} {'cited':>7}")
for g in ["all"] + sorted(k for k in stats if k != "all"):
s = stats[g]
print(f"{g:8} {pct(s['recall']):>9} {pct(s['faithful']):>9} {pct(s['correct']):>9} {pct(s['cited']):>7}")
print("\nRetrieval misses:")
for d in details:
if d.get("hit") is False:
print(" -", d["q"], "->", d["top"][:3])
print("\nUnfaithful answers:")
for d in details:
if d.get("faithful") is False:
print(" -", d["q"], "->", d["unsupported"])
with open("eval_report.json", "w", encoding="utf-8") as f:
json.dump(details, f, ensure_ascii=False, indent=2)
if __name__ == "__main__":
main()
qa.jsonl (30 lines in the real file; three shown, answers to be filled from your documents)
{"q": "כמה ימי חופשה שנתית מגיעים לעובד אחרי שלוש שנים?", "ref": "<answer from your leave policy>", "source": "leave_policy_he.pdf", "page": 3, "lang": "he"}
{"q": "How do I claim travel expenses after a client visit?", "ref": "<answer from your expenses procedure>", "source": "expenses_en.docx", "lang": "en"}
{"q": "מה מדיניות הבונוס על ביצועים ברבעון הרביעי?", "ref": "The documents do not say.", "source": null, "lang": "he"}
Steps
python ingest.py docs. Check the chunk count. Open the index and read ten random chunks, including three Hebrew ones from PDFs. Is the text readable and in the right order?python retrieve.py "ימי מחלה" and then a query with an English term, such as a system name. Do the right files come back?python answer.py "How many sick days can I carry over?" and the same question in Hebrew. Check that every claim has a citation and the language matches the question.qa.jsonl as described in 5.8. Do this from the documents, not from the retrieval output.python eval.py --no-judge --mode vector, then --mode bm25, then --mode hybrid. Record recall@5 for each, in total and per language.python eval.py). Hand-check five judged answers. Fix what you find: chunking, tokenizer, prompt.--rebuild --contextualize and rerun. Then try --rerank. Fill the table below.ingest.py again, and confirm that only that file is re-processed and the old text is no longer returned.| Variant | recall@5 (all) | Hebrew | English | Faithful | Note |
|---|---|---|---|---|---|
| Vector only | |||||
| BM25 only | |||||
| Hybrid | |||||
| Hybrid + contextual | |||||
| Hybrid + contextual + rerank |
Stretch: turn off the prefix variants in tokenize() and see how BM25-only recall changes on Hebrew questions. That one experiment is your best client-facing proof of why Hebrew needs special handling.
Two things, both reusable with every client.
Plain language, no jargon, in English and in Hebrew. Suggested structure:
Use the Help Center and Trust Center pages from Module 1 for any data-handling statements, and note the date you checked.