AI Engineer23h

RAG with citations you can check

A question answering system over documents you supply, which answers only from the text it retrieved, cites the chunk behind every sentence, and declines when nothing good came back.

Retrieval is the most common thing an AI engineer is asked to build and the most commonly built badly. The reason is nearly always the same: the retrieved chunks are never looked at, so months go into prompt wording while the answer was never in the context to begin with. This project makes inspection a stage of its own, and adds the two properties that separate a demo from something anyone would deploy — a citation that resolves to real text, and a refusal when the corpus does not cover the question. In an interview it lets you answer "how do you know retrieval is working" with numbers rather than with an architecture diagram.

Have these behind you first

You can start without them and look things up as you go. It will take longer and you will understand less of what you typed.

Stage 1 of 7, 3h

Chunks you can point at

You build: An ingestion script that turns your documents into chunks carrying a stable id, a source and a heading, so a citation can be resolved back to text.

If a line here loses you, this is where it is taught

Pick a corpus you can check answers against by hand: your own notes, a company handbook, the documentation of a library you use. A few hundred pages is plenty. You need to be able to tell, yourself, whether an answer is right, because for the next six stages you are the ground truth.

Split on structure rather than on a character count. A chunk is retrieved alone and read alone, so it has to make sense alone, which means it keeps the heading it lived under and never spans two of them.

import hashlib
import re
from dataclasses import dataclass

@dataclass(frozen=True)
class Chunk:
    id: str        # stable across re-ingestion, because citations outlive runs
    doc: str       # which file it came from
    heading: str   # heading path: shown in the citation, embedded with the text
    start: int     # character offset in the file, so a citation is checkable by hand
    text: str

HEADING = re.compile(r"^(#{1,6})\s+(.*)$")

def chunk_markdown(doc: str, text: str, max_chars: int = 1200) -> list[Chunk]:
    """Group paragraphs under their heading. A chunk never crosses a heading."""
    chunks: list[Chunk] = []
    heading, buf, start, pos = "", [], 0, 0

    def flush() -> None:
        nonlocal buf
        if not buf:
            return
        body = "\n\n".join(buf)
        # Content-addressed: an unchanged document re-ingests to the same ids,
        # so a citation printed last week still resolves to the same text.
        cid = hashlib.sha1(f"{doc}|{start}|{body}".encode()).hexdigest()[:12]
        chunks.append(Chunk(cid, doc, heading, start, body))
        buf = []

    for para in text.split("\n\n"):
        stripped = para.strip()
        match = HEADING.match(stripped)
        if match:
            flush()
            heading = match.group(2).strip()
        elif stripped:
            if sum(len(p) for p in buf) + len(stripped) > max_chars:
                flush()
            if not buf:
                start = pos
            buf.append(stripped)
        pos += len(para) + 2   # the blank line the split consumed

    flush()
    return chunks
Roughly 200 to 400 tokens per chunk, cut at paragraph boundaries.

Never use the list index as the chunk id

It is the obvious shortcut and it breaks silently. Re-ingest after one document gains a paragraph, and every index after it shifts by one: stored citations now point at neighbouring text, the answer still renders, and nothing errors. If your ids are positions rather than content, you do not have citations, you have coincidences.

Done when

Every chunk has an id, a source document, a heading path and a character offset, and you can print the exact text of any chunk given only its id.

Stage 2 of 7, 3h

Embed and index

You build: Vectors for every chunk and a search function returning the nearest k, with scores.

If a line here loses you, this is where it is taught

Below roughly a hundred thousand chunks, an array and a matrix multiply is the right answer. It is exact, it has no service to run, and there is no index build to be out of date. Add a vector database when you have a reason you can name.

Model calls stay behind three small helpers you write once — embed_batch for vectors, ask for a single completion, generate for the answer — so the rest of the code says what it does rather than which provider it uses, and swapping models is one file.

import numpy as np

def embed(texts: list[str]) -> np.ndarray:
    """Your embedding model, batched. One request per batch, not per chunk."""
    out = [embed_batch(texts[i : i + 64]) for i in range(0, len(texts), 64)]
    return np.vstack(out).astype("float32")

# Embed the heading with the body. The chunk will be read alone, so it is
# indexed the way it will be read.
vectors = embed([f"{c.heading}\n\n{c.text}" for c in chunks])
vectors /= np.linalg.norm(vectors, axis=1, keepdims=True)

def search(query: str, k: int = 20) -> list[tuple[Chunk, float]]:
    q = embed([query])[0]
    q /= np.linalg.norm(q)
    scores = vectors @ q                      # every similarity in one multiply
    top = np.argsort(-scores)[:k]
    return [(chunks[i], float(scores[i])) for i in top]

# The cheapest correctness check there is: text lifted out of a chunk must
# retrieve that chunk.
probe = chunks[100]
assert search(probe.text[:200], k=1)[0][0].id == probe.id

The vectors and the chunk list are one object

Row i of the array must be chunk i, forever. Re-embed a subset, sort the chunk list, or filter one of them and not the other, and search returns text with no relation to the query, with no error anywhere and scores that still look reasonable. Save both together, rebuild both or neither, and keep the assert above in the load path.

Done when

A sentence copied verbatim out of one chunk retrieves that chunk at rank 1. Assert it for ten different chunks before going further.

Stage 3 of 7, 4h

Read what comes back

You build: A retrieval inspector that prints the top chunks and scores for a query, and a hand label on twenty of your own questions saying whether the answer was in there at all.

If a line here loses you, this is where it is taught

No model calls in this stage. Write questions a user of this corpus would genuinely ask, including two or three the documents do not cover, and read the chunks that come back for each.

def inspect(query: str, k: int = 5) -> None:
    for rank, (chunk, score) in enumerate(search(query, k), 1):
        print(f"{rank}. {score:.3f}  {chunk.doc} > {chunk.heading}  [{chunk.id}]")
        print("   " + chunk.text[:300].replace("\n", " "))
        print()

for q in QUESTIONS:
    print("=" * 72)
    print(q)
    print("=" * 72)
    inspect(q)

Keep the verdicts in a file as you go. The note matters more than the label: it is what tells you which fix to build next.

question                                    answerable  note
Can I expense a train ticket?               yes         rank 1 is the travel policy
What is the notice period for interns?      no          all five are the full-time policy
Which regions is plan Pro available in?     partial     right page, the table came out as prose
What does error ERR_4013 mean?              no          matched every other error code
How do I rotate an API key?                 yes         rank 3, ranks 1-2 are release notes
Twenty rows of this is the most valuable artefact in the project.

This stage is the reason the project exists

Most RAG systems are bad because nobody ever looked at what the retriever returned. The prompt gets rewritten, the model gets swapped, the temperature gets lowered — and the answer was never in the top five to begin with. Generation cannot recover information that is not in the context. If you skip this stage you will spend the rest of the project tuning the half of the system that was already working.

Done when

Twenty questions you wrote, each labelled answerable or not answerable from the top five chunks, by you, having read them. You can state the percentage that were answerable — that number is the ceiling on the whole system.

Stage 4 of 7, 4h

Answer, with citations that resolve

You build: A generation step constrained to the retrieved text, where every sentence carries the ids it came from and every id is checked against what was actually sent.

If a line here loses you, this is where it is taught

Number the sources with the chunk ids themselves. The model then cites in the vocabulary you can verify, and no mapping table has to be kept in step.

SYSTEM = """Answer the question using only the sources below.

Every sentence of your answer must end with the ids of the sources it came from,
written as [id]. Do not use anything you know that is not in the sources.
If the sources do not answer the question, reply with exactly: NOT_IN_SOURCES."""

def build_context(hits: list[Chunk]) -> str:
    return "\n\n".join(f"[{c.id}] {c.doc} > {c.heading}\n{c.text}" for c in hits)

CITE = re.compile(r"\[([0-9a-f]{12})\]")

def bad_citations(answer: str, hits: list[Chunk]) -> list[str]:
    """Ids the answer cites that were never sent. Non-empty means the model
    invented a source, which is a bug, not a wording problem."""
    allowed = {c.id for c in hits}
    return [cid for cid in CITE.findall(answer) if cid not in allowed]

def uncited_sentences(answer: str) -> list[str]:
    sentences = re.split(r"(?<=[.!?])\s+", answer.strip())
    return [s for s in sentences if s and not CITE.search(s)]

Render each citation as a link to the chunk text, not as a footnote marker. A citation a reader cannot expand is decoration, and it is the part of this system that earns trust.

A fabricated citation looks exactly like a real one

Models will produce ids in the right shape for text they were never given, and a wrong citation is worse than none because it survives review. Run bad_citations on every answer before it reaches a screen and fail loudly when it returns anything. If you are not checking, you are not citing.

Done when

For every answer, a script confirms each cited id was in the context window and each sentence carries at least one citation. An answer that fails either check is rejected rather than displayed.

Stage 5 of 7, 3h

Retrieve widely, then rank precisely

You build: Keyword and vector retrieval unioned into a wide candidate set, reranked by a cross-encoder down to the few chunks you actually send.

If a line here loses you, this is where it is taught

Two retrievers with different failure modes, unioned rather than blended, then one model that reads the query and the chunk together and orders them. The first stage optimises for not missing anything; the second for what deserves the context budget.

from rank_bm25 import BM25Okapi

tokenised = [f"{c.heading} {c.text}".lower().split() for c in chunks]
bm25 = BM25Okapi(tokenised)

def candidates(query: str, k: int = 40) -> list[Chunk]:
    """Union of both retrievers. No score blending: the two scores are not
    on the same scale, and the reranker is about to decide the order anyway."""
    dense = [c for c, _ in search(query, k)]
    lexical_ranks = np.argsort(-bm25.get_scores(query.lower().split()))[:k]
    seen, out = set(), []
    for c in dense + [chunks[i] for i in lexical_ranks]:
        if c.id not in seen:
            seen.add(c.id)
            out.append(c)
    return out

def rerank(query: str, pool: list[Chunk], k: int = 5) -> list[Chunk]:
    """A cross-encoder reads query and chunk together, so it can separate
    'refunds on annual plans' from 'refunds on monthly plans'. Far too slow
    to run over the corpus, which is exactly why it runs second."""
    scores = cross_encoder.predict([(query, f"{c.heading}\n{c.text}") for c in pool])
    ranked = sorted(zip(scores, pool), key=lambda pair: -pair[0])
    return [c for _, c in ranked[:k]]

Exact strings are where embeddings lose

An embedding of ERR_4013 sits close to every other error code, and the same goes for part numbers, version strings, surnames and internal project names. BM25 matches them exactly and costs nothing. This is the single most common reason a retrieval demo works on a curated corpus and fails on a real one, and adding keyword search is usually a bigger win than changing embedding model.

Done when

Re-run the twenty questions from stage three and label them again. Either the answerable count went up, or you can name the queries it did not help and why.

Stage 6 of 7, 4h

Measure the retriever and the answer separately

You build: A labelled question set with gold chunk ids, recall@k for retrieval, and a per-sentence faithfulness check on the generated answers.

If a line here loses you, this is where it is taught

Labelling is the work here, and the stage three inspector is how you do it: run a question, read the chunks, record the ids that actually contain the answer. Forty questions takes an afternoon and is the difference between tuning and guessing.

# cases.jsonl, one per line:
# {"q": "How do I rotate an API key?", "gold": ["4c1f9ab207de", "9930ba7715c1"]}

def recall_at_k(cases: list[dict], k: int) -> float:
    """Did at least one chunk that contains the answer survive into the top k?"""
    hits = 0
    for case in cases:
        got = {c.id for c in rerank(case["q"], candidates(case["q"]), k)}
        hits += bool(got & set(case["gold"]))
    return hits / len(cases)

for k in (1, 5, 20):
    print(f"recall@{k}: {recall_at_k(cases, k):.2f}")
Recall@20 far above recall@5 means the reranker is the problem, not retrieval.

Faithfulness asks a different question: given that the right text was there, did the answer stay inside it. Grade one sentence at a time against the sources that sentence cited.

FAITHFUL = """Source:
{source}

Sentence:
{sentence}

Does the source state the sentence? Reply with one word: YES or NO."""

def faithfulness(answer: str, by_id: dict[str, Chunk]) -> float:
    verdicts = []
    for sentence in re.split(r"(?<=[.!?])\s+", answer.strip()):
        ids = CITE.findall(sentence)
        if not ids:
            continue
        source = "\n\n".join(by_id[i].text for i in ids)
        reply = ask(FAITHFUL.format(source=source, sentence=sentence))
        verdicts.append(reply.strip().upper().startswith("YES"))
    return sum(verdicts) / len(verdicts) if verdicts else 0.0

Two numbers, never one

Recall@k measures the retriever; faithfulness measures the generator. Collapse them into a single RAG score and you will know the system got worse while having no idea which half to fix — and the two are improved by completely different work. Report them side by side, always, and report them per k.

Done when

You can print recall@1, recall@5 and recall@20 over at least 40 labelled questions, plus a faithfulness score, and you can name the single change that moved recall most.

Stage 7 of 7, 2h

Say nothing rather than something

You build: A retrieval-quality gate that returns a refusal, with a reason, when nothing retrieved is good enough to answer from.

If a line here loses you, this is where it is taught

Two independent gates. The first is retrieval quality, decided before any generation happens. The second is the model's own NOT_IN_SOURCES from stage four, which catches the case where the chunks scored well and still do not contain the answer.

def answer(query: str, min_score: float = 0.35, min_hits: int = 2) -> dict:
    pool = candidates(query)
    top = rerank(query, pool, k=5)
    scores = cross_encoder.predict([(query, f"{c.heading}\n{c.text}") for c in top])

    # One weak hit is the shape of a question the corpus does not cover.
    strong = [c for c, s in zip(top, scores) if s >= min_score]
    if len(strong) < min_hits:
        return {"abstained": True, "reason": "no strong match", "sources": []}

    text = generate(SYSTEM, build_context(strong), query)
    if text.strip() == "NOT_IN_SOURCES":
        return {"abstained": True, "reason": "model found no answer", "sources": strong}

    assert not bad_citations(text, strong)
    return {"abstained": False, "answer": text, "sources": strong}

Pick min_score by looking, not by guessing. Plot the top rerank score for your answerable questions and for your unanswerable ones as two distributions and put the threshold where the overlap costs you least.

There is no correct threshold, only a chosen one

The two distributions overlap, and every value you pick converts some wrong answers into refusals and some good answers into refusals too. That is a product decision: for a support bot quoting policy, a refusal is cheap and a confident wrong number is expensive, so set it high. Answering that question with a number and a reason is the version of this project an interviewer will remember.

Done when

On ten questions your corpus cannot answer, the system abstains on at least eight, while abstaining on no more than one of your answerable questions. Both counts printed by a script, not observed by hand.

What you have at the end

A repository containing an ingestion script, an index, a retrieval inspector, an answerer that cites, and an evaluation reporting recall@k and faithfulness on questions you labelled yourself. You can say what your retriever's recall is, which change improved it most, and what your system does when it does not know — which is more than most production RAG systems can answer.

The other projects