AI Transformation with Claude · Part 2 · Week 5

Module 5. Knowledge retrieval (RAG) for company documents

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.

≈ 9 hreading 2 hlab 5 hdeliverable 2 h

By the end of this module you can

5.1 Long context, Projects, or retrieval?

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.

Fits in context? with room to spare yes no Used in the Claude app? by people, not by your code yes no Projects no code, fastest start Long context paste docs, cache the prefix Retrieval (RAG) search first, send only the best passages Also choose RAG, even if it fits, when: different people may see different files files change every day cost per question matters
Ask the "fits in context" question first, with real page counts from the client. Re-ask it every time the document set grows.
OptionGood whenWatch 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 codeNo 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 wellEvery 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 pagesMore parts to build and monitor. Bad retrieval means a confident answer from the wrong page. You must measure it
Hybrid of the twoRetrieve whole documents or large sections, then give Claude those in fullNeeds good document-level metadata. Larger prompts per question
Consultant's line: "For 40 pages and ten users we use a Claude Project this week. At 2,000 pages, or when payroll files must stay hidden from most staff, we build retrieval. We decide with your real page count, not a guess."

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.

5.2 The pipeline, ingestion and chunking

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.

offline: runs when documents change Ingest PDF, DOCX, HTML to clean text Chunk split by structure, keep metadata Embed multilingual model, plus BM25 tokens Index vector store and keyword index Retrieve vectors and BM25, fused Rerank reorder, keep the best few Answer with citations Claude answers only from the passages and points at the source of each claim online: runs for every question
Most retrieval failures are made in the left half: bad text extraction and bad chunks. Fix those before you tune anything on the right.

From file to clean text

Chunking strategies

StrategyHow it worksUse it when
Fixed size + overlapCut every N characters, repeat a tailQuick baseline only. It cuts through sentences and tables
Structure-awareSplit on headings and paragraphs, merge small pieces up to a target sizeDefault for policies and procedures. The lab uses this
SemanticSplit where the embedding similarity between sentences dropsLong unstructured text such as meeting notes. Costs more, gains are uneven
Parent-childSearch small chunks, but send Claude the whole section they belong toAnswers need surrounding context, for example exceptions listed below a rule
Whole-documentOne 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.

Metadata is half the value

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.

5.3 Embeddings: choosing a model for Hebrew, Russian and English

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.

OptionHostingNotes for Hebrew and Russian
Voyage AI modelsHosted APIStrong 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-m3Open weights, run it yourselfMultilingual, 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 familyOpen weights, run it yourselfMultilingual and widely used. Needs the "query: " and "passage: " prefixes in front of text, or quality drops
Other cloud providers' embedding APIsHosted APIFine if the client already uses that provider. Check data terms and test Hebrew first
Choose with a test, not a leaderboard. Run the 30-question set from 5.8 on two or three candidates and keep the winner. Store the model name in the index (the lab does) because changing the model means re-embedding everything.

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.

5.4 Hybrid search and reranking

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.

StepWhat it doesIn the lab
Vector searchNearest chunks by embedding similarityChroma, cosine distance
BM25Keyword ranking over the same chunksrank_bm25 with a Hebrew-aware tokenizer
FusionMerge the two ranked lists. Reciprocal rank fusion uses only ranks, so you never calibrate scoresrrf() in retrieve.py
Rerank (optional)A second model reads the question and each candidate together and reorders themSmall 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.

5.5 Contextual retrieval

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.

Raw chunk "Unused days expire in March." which days? which policy? Claude reads both the full document (cached, reused for every chunk) and the chunk, then writes one short situating note Indexed text "Leave policy, annual vacation section." + "Unused days expire in March." embedded and BM25-indexed
The note is written once, at ingestion. Questions at run time cost the same as before.

5.6 Grounding and citations

An answer without a source is a claim the employee cannot check. Two approaches work.

Option A: the Claude citations feature

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.

Option B: quoted spans with source ids

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.

Rules that make answers trustworthy

5.7 Hebrew and Russian specifics

IssueWhat goes wrongWhat to do
Right-to-leftExtraction order from PDFs can reverse Hebrew; digits and Latin words inside Hebrew text jump around in the UIStore 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 prefixesOne-letter prefixes (ו, ה, ב, כ, ל, מ, ש) are glued to the word. A query for "חופשה" will not match "בחופשה" or "והחופשה" in BM25, because they are different tokensGenerate 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 variantsThe 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 BM25Vectors cover most of it. For BM25, add a stemmer or lemmatiser for Russian if misses show up in the eval
Mixed languageHebrew sentences contain English system names (Workday, Jira) and numbersThis is where BM25 helps: exact Latin tokens match perfectly. Keep them, lowercased
Cross-language questionsAn employee asks in Hebrew about a policy that exists only in EnglishUse a multilingual embedding model, and have Claude answer in the question's language while citing the English source
Build the Hebrew-specific part as a small, tested function, not as scattered regexes. When a client's documents turn out to have unusual spelling habits, you change one function and re-ingest.

5.8 Evaluating retrieval and answers

"It seems to work" is not a deliverable. Build a small labelled set before you tune anything, and rerun it after every change.

The question set

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:

MetricQuestion it answersHow to compute
recall@kWas the right passage among the top k retrieved?Share of answerable questions where any top-k chunk matches the gold source (and page)
FaithfulnessIs every claim in the answer supported by the passages given?Claude as judge, given the passages and the answer
CorrectnessDoes the answer agree with the reference?Claude as judge, given the reference
Citation rateDid answerable questions get at least one citation?Count answers with a non-empty citation list
Refusal qualityDoes 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.

Judge the judge. Hand-check about ten judged answers, including some the judge approved. Use a model at least as capable as the one that answers, tell it to be strict, and report results per language. With 30 questions, one question is worth three points: treat differences of one or two questions as noise.

5.9 Keeping the index fresh

A knowledge assistant that quotes last year's policy is worse than none. Plan freshness before go-live.

Lab 5. Bilingual HR and procedures assistant

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

  1. Run 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?
  2. Run python retrieve.py "ימי מחלה" and then a query with an English term, such as a system name. Do the right files come back?
  3. Run 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.
  4. Write the 30 questions in qa.jsonl as described in 5.8. Do this from the documents, not from the retrieval output.
  5. Run python eval.py --no-judge --mode vector, then --mode bm25, then --mode hybrid. Record recall@5 for each, in total and per language.
  6. Run the full eval (python eval.py). Hand-check five judged answers. Fix what you find: chunking, tokenizer, prompt.
  7. Re-ingest with --rebuild --contextualize and rerun. Then try --rerank. Fill the table below.
  8. Edit one document, run ingest.py again, and confirm that only that file is re-processed and the old text is no longer returned.
Variantrecall@5 (all)HebrewEnglishFaithfulNote
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.

Deliverable: demo and the "Ask our documents" offer sheet

Two things, both reusable with every client.

1. A recorded or live demo (10 minutes)

  1. One Hebrew question, one English question, and one Hebrew question answered from an English document, each with visible citations
  2. One question the documents cannot answer, showing a clean refusal
  3. An edited document picked up by the next ingest run
  4. Your eval table: recall@5 and faithfulness per language, and what you changed to improve it

2. A one-page offer sheet for clients

Plain language, no jargon, in English and in Hebrew. Suggested structure:

  1. The problem: staff lose time searching policies and asking HR the same questions
  2. What they get: a place to ask in Hebrew, Russian or English, with answers that show the source page
  3. How it stays honest: answers only from your documents, citations on every claim, "not found" when it is not there
  4. How we prove it: a test set of 30 real questions, with results shared before go-live
  5. Your data: where documents and index live, who can see what, how updates are picked up
  6. Plan: week 1 document audit and question set, weeks 2 to 3 build and tune, week 4 pilot with a small group. Costs: setup and monthly run, which you quote from your own calculation
  7. What we need from you: the documents, an owner for them, ten pilot users

Use the Help Center and Trust Center pages from Module 1 for any data-handling statements, and note the date you checked.

Self-check

  1. A 25-person company has 60 pages of procedures that change twice a year and wants staff to ask questions in the Claude app. What do you build, and when would your answer change?
    Model answerA Claude Project with the files. It fits, it is stable, and the users are in the app. Move to retrieval when the set outgrows the context, when it changes constantly, when different people need different access, or when the answers must be embedded in the company's own app with citations and an eval.
  2. Anthropic offers no embedding model. How do you choose one for a Hebrew and English company?
    Model answerShortlist a hosted option such as Voyage AI and a multilingual open model such as bge-m3 or multilingual-e5, depending on where the client allows text to go. Run your 30-question set on each and compare recall@5 per language, including cross-language questions. Record the model name in the index.
  3. BM25-only search for "חופשה" misses a paragraph that says "בחופשה השנתית". Why, and what are two fixes?
    Model answerThe Hebrew prefix is attached, so BM25 sees a different token. Fixes: index prefix-stripped variants of tokens (and normalise spelling), and add vector search with fusion so meaning matches even when tokens do not. A morphological analyser is a heavier third option.
  4. What does contextual retrieval add, why does prompt caching make it practical, and what do you check before keeping it?
    Model answerFor each chunk, Claude writes a short note placing it in its document, and the note is embedded and BM25-indexed with the chunk, so ambiguous chunks become findable. The whole-document prompt is the same for every chunk of a file, so caching it avoids paying full price repeatedly. Check on your own eval that recall improves, and read a sample of notes for invented details.
  5. Your eval shows recall@5 of 90% but faithfulness of 70%. Where do you look first, and what extra check would you add?
    Model answerRetrieval is mostly fine, so look at generation: the system prompt (only use documents, refuse otherwise), conflicting or superseded documents in the passages, and answers with no citations. Add a quote-verification check or require citations, and hand-check the judge on a sample of its verdicts.

Further reading