RAG: Building Context, Not Search
28 July, a question from support: “What is the daily withdrawal limit rule?” The agent answered: clear, reasoned, with a citation. The model read the old page correctly, and the answer was still wrong. The limit policy had been updated on 14 July; the document was current, the index was not. The model did not invent anything — we handed it a page that was 14 days old. The hard part of RAG is not the search. It is which text you put in front of the model.
- Decide first: RAG or a tool? If a SQL query answers it, use a tool; if a person would have to read and interpret a document, use RAG. Not making this split was our first version’s biggest mistake.
- The chunk boundary is the document’s structure, not a character count. If the section heading is not part of the embedding, the chunk has no context.
- Freshness is not a feature, it is a promise. When indexing stops, the system does not throw an error. It gives an old answer, and the user cannot see that.
- Measure retrieval and answering separately. Is the right chunk in the top 5? Ours was recall@5 21/30 (70%); with reranking and keyword search together it became 27/30 (90%).
- “I don’t know” is taught: a similarity threshold plus mandatory citation. Unsourced claims fell from 14% to 2% — and the price was 2 of 30 answerable questions being refused.
- Permissions live in the index. Every chunk records who may see it; filtering happens during search, not after the answer is written.
The real decision: RAG or a tool?
In the first version we sent every question down the same path: take the question, search the documents, give what you find to the model. “How many dollars were deposited today?” went down that path too, and retrieved the “deposit procedures” page. The answer was a nice paragraph with no number in it.
| Question | Right path | Why |
|---|---|---|
| “How many dollars were deposited today?” | Tool | The answer comes from a query and is exact |
| “What is the daily withdrawal limit rule?” | RAG | The answer is in a policy document and needs interpretation |
| “Has this customer exceeded the limit?” | Both | The rule from RAG, the number from a tool, the comparison in code |
| “Why did we make this decision last year?” | RAG | Decision records, meeting notes |
| “How many KYC applications are waiting?” | Tool | A count; vector search is the wrong instrument here |
The third row is the most common type and the most instructive: RAG brings the rule, the tool brings the number, the code does the comparison. Asking the model “has this customer exceeded the limit” and expecting it to combine the rule and the number in its head opens the door to three mistakes at once.
Chunking: the document draws the line
Our first attempt split at 1,000 characters. The result: the start of a limit rule in one chunk, its exception in another. The model never saw the exception and explained the rule incompletely.
{
"id": "policy-withdrawal-v8#daily-limit",
"heading_path": "Withdrawal Policy > Limits > Daily limit",
"text": "The daily withdrawal limit ... (whole section, 420 tokens)",
"source": "policy/withdrawal.md",
"version": 8,
"updated": "2026-09-08",
"access": ["support", "risk", "management"],
"neighbour": ["policy-withdrawal-v8#exceptions"]
}
# the text is not embedded on its own:
# to_embed = heading_path + "\n" + text
# separate fields in JSON; joined on the way to the vector.
- The heading path goes into the embedding. It sits in its own JSON field, but it is added to the front of the text before the vector is taken. That is the only way the model knows which section of which policy it is reading; a paragraph alone is a pile of sentences with no context.
- Target 300–800 tokens. Too short and the rule is broken up; too long and irrelevant text eats the window.
- Neighbour links. The “exceptions” section is a separate chunk but marked as a neighbour, so when the rule is retrieved its exception comes with it. That single setting ended most of our incomplete answers. Neighbours are not counted within the top 5; they are added on top, so the model receives between 5 and 8 chunks. In the recall@5 measurement, however, only the 5 that came from the ranking count — a chunk delivered through a neighbour link is not included, or the measure would inflate itself.
- Permission labels. Filtering happens during the search. Filtering after the answer is written means you already showed the data to the model.
- The version field. Not just for information: removing the old version when a new one arrives depends on it — see below.
Freshness: our 14 days
12 Jul the nightly indexing job died quietly (after a dependency upgrade)
14 Jul withdrawal policy v7 published -> document current, index not
28 Jul INCIDENT: the agent read a 14-day-old rule correctly
~2 weeks "the model is hallucinating" discussion
August three-week round of fixes (router / neighbour chunks / webhook + alert)
8 Sep policy v8 shipped -> v7 chunks were still in the index; delete step added
The cause of the incident in the opening paragraph was simple: a nightly job updated the index, and that job had quietly died on 12 July — unrelated to the policy update on 14 July; it had stopped after a dependency upgrade. Nobody noticed, because the system kept answering. A broken RAG runs silently; an old answer does not look like an error message.
We made three changes:
- The index updates when a document changes (a webhook); the nightly job stayed as a backup.
- Every answer shows its source and date: “Source: Withdrawal Policy v8, updated 8 September 2026”. If the user sees an old date, they can push back.
- A freshness alert: for each document we compute the gap between the last change at the source and the version in the index; if the maximum of those gaps goes over 24 hours, the alert fires. (Our first alert looked at “the oldest document in the index” and was useless: a document can legitimately be two years old without changing. What you measure is not age, it is lag.) Now the job cannot die quietly again.
Deleting the old version: the sibling of those 14 days
We did not notice the second half of freshness during the three-week round below; we only
understood it three weeks after that round ended, when v8 of the policy shipped on 8
September. The chunk id carries the version: policy-withdrawal-v8#daily-limit.
When v8 was published, new chunks were written — but did v7’s leave?
They did not. The result is worse than an old answer: the model sees two contradictory rules at once and decides for itself which one applies. And because both are “sourced”, the citation check does not catch it.
Writing to the index now has two steps: the new version’s chunks are written, then
every older version with the same source field is deleted. The delete step is
separate and idempotent; if it is interrupted, the next run finishes it. The same path
handles a deleted source document — deleting a document means removing it from the
index.
Measuring accuracy: two separate numbers
“Is our RAG working?” is not one question but two: did it retrieve the right chunk, and did it produce the right answer from that chunk? Measure them separately, because the fixes are different.
| Measure | How | Ours |
|---|---|---|
| recall@5 (retrieval) | 30 questions, the correct chunk marked by hand for each; is it in the top 5? | 21/30 (70%) → 27/30 (90%) |
| Answer accuracy | The same 30 questions, with the correct chunk handed over | 28/30 (93%) |
| Unsourced claim rate | How many of the model’s claims attach to no chunk — measured before the filter runs | 14% → 2% |
| Unnecessary refusals | Of 30 questions that do have an answer in the documents, how many fell below the threshold | 0/30 → 2/30 (the starting 0 is not a measurement: with no threshold there were no refusals) |
| “I don’t know” accuracy | 10 questions with no answer in the documents; how many were correctly refused? | 6/10 → 10/10 |
A word on why the unsourced claim row is measured before the filter: the code already strips unsourced sentences from the answer, so in the text the user sees, that rate is close to zero. But that does not mean the model stopped inventing — only that the invention no longer reaches the user. 14% and 2% show how often the model tries, and that is the number to watch; if it rises, retrieval has degraded. The filter is a safety net, not a measure.
The unnecessary refusals row is the bill for the unsourced claim row. One distinction matters here: recall@5 is 27/30, so in three questions the right chunk never reaches the top 5 — a refusal on those would be retrieval’s doing, not the threshold’s, and would in fact be correct behaviour. The 2 refusals we count do not overlap with those three: in both, the right chunk was in the top 5 and simply scored below the threshold. So these really are the threshold’s bill. We pay that price on purpose — better than a wrong answer — but you should not accept it without measuring it: a threshold is always a two-sided trade.
The answer accuracy row told us where the problem was: given the right chunk, answer accuracy was already 28/30. So our problem was not the model, it was retrieval. Changing the model would not have fixed this table; we only saw where to improve once we separated the two numbers.
A note on sample size: in a set of 30, one question is worth about 3.3 points, and the “I don’t know” set is only 10 questions. These numbers pointed us in the right direction, but we treat differences of 3–4 points as noise and I suggest you do the same. While the fixed set is small, do not claim improvements to the decimal point.
The permission filter runs during the search, which means it shrinks the candidate
pool. A user with narrow permissions has fewer candidates for the same question, so the
chance of the right chunk reaching the top 5 changes mechanically. We measured our 27/30
with a single profile — the one with the widest access, which can see the whole
catalogue — so that number is an upper bound. (The access
list on a chunk is not a hierarchy: support, risk and
management see different sets of documents, and none contains another.)
Narrow profiles need their own measurement, and we do not yet do that regularly.
The second effect is on the user side and is sneakier: someone who gets “I have no document on this” because they lack permission reads it as “no such document exists”. The behaviour is right, the impression is wrong. We split the message: “I have no document on this” and “there is no document on this that you can see” are not the same sentence. The second one leaks the existence of the document; in some environments that is acceptable and in others it is not. Make that call deliberately rather than leaving it to the default. We chose to split them: this is an internal tool and everyone already knows the policy catalogue exists, so almost nothing leaks; meanwhile the “it does not exist” misreading was costing the support team real time. On a customer-facing surface we would not make the same call.
What raised recall@5?
- Reranking. Vector search brings 20 candidates, a small ranking model reorders them against the question, and the top 5 go to the model (5–8 chunks once neighbours are added). The largest single contribution: 21/30 → 25/30 (70% → 83%).
- Combining with keyword search (hybrid search). Vector search alone misses exact matches such as “IBAN”, “MT103” or “v8”. Combined with classic text search: 25/30 → 27/30 (83% → 90%).
- Putting the heading path into the embedding. Small and free: it tells apart which policy a word appeared in.
Reordering 20 candidates added about 180 ms per question on average (around 310 ms at p95) and raised the total model cost per question — embedding, reranker and the answering model together — by roughly 15%. (The reranker itself is cheap; the cost comes from running it over 20 candidates on every question.) For how we use it — questions asked by the support team and read by a person — that trade is easy: half a second bothers nobody, a wrong rule does. On a hot path, in an agent where the user is waiting, the arithmetic may come out differently. I work through that trade in terms of cost per task in the next post.
A problem specific to Turkish
The hybrid search point above matters more in Turkish than in English, because the keyword
side misses easily due to suffixes: limit, limiti,
limitinin, limitler are four different terms to a classic text
search. (Turkish is agglutinative: words take a chain of suffixes rather than standing
alone.)
- Stemming is mandatory. We turned on the search engine’s Turkish analyser so that queries and documents are reduced to the same stem. Before that, hybrid search added almost nothing — the keyword side simply was not finding anything.
- Embedding models are weaker in Turkish. The same model does not reach its English accuracy on Turkish text, so in our setup the keyword leg of hybrid search carries more weight than it would in an English deployment. When you pick a multilingual embedding model, measure it on your own set with your own language; the published general scores mislead.
- Do not translate terms. If the document says “chargeback”, keep it that way in the chunk; that is how users search for it too.
Teaching it to say “I don’t know”
The most dangerous answer is not a wrong one. It is an answer that is confident and unsourced. We set two rules:
# 1) Threshold: if the retrieved chunks are not similar enough,
# never ask the model
if best_score < THRESHOLD:
return MSG_NO_DOCUMENT # "I have no document on this.
# I can ask the policy team."
# 1b) If the permission filter removed every candidate, a SEPARATE message:
# (see the permissions section above - we chose to split them)
if candidates_before_filter > 0 and candidates == 0:
return MSG_NO_ACCESS # "There is no document on this that you can see."
# 2) Mandatory citation: every claim attaches to a chunk.
# after the answer is produced, code checks it:
for claim in answer.claims():
if not claim.source_id:
remove_from_answer(claim) # unsourced sentences do not ship
The second rule sometimes shortens the answer, and that is a good thing. Under the text the user sees there is a source list; they can click through to the document itself. The nicest side effect we measured: users trust the system more now that they can verify it — trust comes from a source you can show, not from sounding certain.
The code above only checks whether source_id exists. Two things it
does not catch: the model can retrieve the right chunk and cite the wrong one, or
attach a detail to a chunk that never contained it. Both look “sourced”, so
both pass the filter.
Our current answer is incomplete: for eight weeks we have reviewed 20 answers by hand each week; in 160 answers we found 6 with a wrong citation (~4%). Do not confuse the denominators: this rate is per answer, while the unsourced claim rate in the table is per claim. The two are not directly comparable. Automatic verification — checking each claim against the source text with a separate model — is worth trying, but it roughly doubles the cost and we have not made it permanent. It would be wrong to present this as a solved problem.
From the field: three weeks, three fixes
We did not choose the order. Freshness came last because we understood the cause last: for the first two weeks we talked about the model “making things up” and looked in the wrong place. We also missed half of freshness in that round entirely: old versions staying in the index only surfaced three weeks after the round, when v8 shipped on 8 September.
| Week | Problem | Fix | Result |
|---|---|---|---|
| 1 | Number questions answered from documents | A router: numbers → tool, text → RAG | 12 of 12 questions took the right path |
| 2 | The rule arrives, its exception does not | Neighbour chunk links | Incomplete answers 9 → 1, out of 30 questions |
| 3 | The job feeding the index had died and nobody saw it | Webhook + freshness alert + date in the answer | Typical lag ~24 h → ~2 min; and the failure is no longer silent |
The third row deserves an honest reading: our normal lag was never 14 days, it was at most a day with the nightly job. The 14 days were a failure caused by that job dying — the webhook shortened the lag, and the alert made the failure visible.
- Route the question first: tool or RAG?
- Split chunks at heading level; put the heading path into the embedding.
- Keep metadata: version, date, permissions, neighbours.
- Delete the old version from the index when a new one is written.
- Turn on stemming for Turkish; run hybrid search.
- Measure recall and answer accuracy separately; count unnecessary refusals too.
- Threshold plus mandatory citation; show source and date in the answer.
- Send number questions to vector search.
- Split by a fixed character count.
- Leave indexing to a nightly job and not watch it.
- Measure freshness with “the oldest document” — measure lag, not age.
- Say “the model is hallucinating” and change the model — look at recall first.
- Mistake a citation existing for a citation being correct.
- Produce the answer and then hide parts of it based on permissions.
What to track
- recall@5. Weekly, on a fixed set of 30. If it drops, the document structure has changed.
- Index freshness. Per document: p95 and maximum of “last change at source − version in index”. The alert watches the maximum.
- Leftover versions. No chunk older than the source’s current version should remain; if the count is not zero, the delete step is not running.
- “No document” rate and unnecessary refusals. Read them together: if the first falls it is inventing, if the second rises it is useless.
- Unsourced claim rate (before the filter). After the filter it will be near zero; the one to watch is before.
- Wrong citation rate. By sampling, by hand. Even if you cannot automate it, know the number.
- Retrievals per chunk. Documents that are never retrieved are either unnecessary or badly split.
- Recall per permission profile. Recall measured with one profile is an upper bound; measure a narrow profile too.
- Permission filter accuracy. A chunk a user must not see should never enter the results; measure it with a test set.
Checklist
- Is the answer to this kind of question in structured data or in text?
- Is there a router — do number questions go to a tool?
- What sets the chunk boundary: a heading, or a character count?
- Does the heading path go into the embedding?
- Does the metadata carry version, date and permissions?
- When does the index update? Is lag measured per document?
- When a new version arrives, are the old chunks deleted? Does a deleted document leave the index?
- Is stemming on for Turkish? Did you measure the embedding model on your own set?
- Is recall@k measured? How many questions in the set — and how many points is one question worth?
- Are neighbour chunks counted inside the top 5 or added on top — and do they enter the recall measurement?
- How many permission profiles did you measure recall with? Did you measure a narrow one?
- Is there reranking? Did you measure the latency and cost it adds?
- What happens below the threshold: does it invent, or say “I don’t know”? What is the unnecessary refusal rate?
- Are you checking that a citation exists, or that it is correct?
- Are the source and date visible in the answer?
- Does the permission filter run during the search?
Conclusion
The agent read the old page correctly that day, and the answer was still wrong. For the next two weeks we talked about the model “making things up”, when the model had summarised what we gave it perfectly well. Once we separated the two numbers and measured retrieval on its own, it was clear in ten minutes: given the right chunk, answer accuracy was already 28/30, while the rate of retrieving the right chunk was 21/30.
Today the question is routed first, chunks are split at heading level, results are reranked, old versions are deleted from the index, below the threshold it says “I have no document”, and every answer carries the source name and date. The lag went from a day to two minutes — but the real gain is that we will know on the day it slips. What produced those 14 days was not slowness. It was silence.
The sentence to remember: in RAG the model is the last step; quality is decided in the steps before it. A system that summarises the wrong page well produces a wrong answer that looks trustworthy.