Token Optimization in RAG
Token Optimization in RAG
A complete guide to reducing LLM costs, improving latency, and building efficient AI applications — chunking, retrieval, reranking, context compression and real-world examples.
A correct answer that costs too much and arrives too late
A steel manufacturer has built a RAG chatbot for its dealers and employees. The knowledge base holds product specifications, steel price policies, technical manuals, dealer FAQs, warranty documents, delivery policies, and thousands of PDF and Word files collected over many years.
A dealer asks:
“What is the minimum order quantity for TMT bars, and what documents are required to become an authorized dealer?”
The application retrieves several large document chunks and sends them to an LLM. The answer is correct. The system still has problems:
- Too many input tokens per question, so every answer is expensive.
- High monthly LLM API bills that grow linearly with traffic.
- Slow responses, because the model must read thousands of tokens before it writes the first word.
- Irrelevant retrieved content, such as warranty clauses and southern-region delivery rules.
- Repeated information: the same eligibility rule appears in three documents and in overlapping chunks.
- Large conversation histories resent on every turn.
- Poor performance when many dealers ask questions at once, because long prompts consume rate limits faster.
Why this is not a prompt-writing problem
Most of the tokens in this request were not written by a prompt engineer. They came from upstream decisions: how documents were extracted and cleaned, how large the chunks are, how many chunks the retriever returns, whether filters remove irrelevant regions, whether duplicates are removed, how much chat history is attached, which model is called, and how long the answer is allowed to be. Rewording the system prompt touches only a few hundred of the eight thousand input tokens. Token optimization is therefore a pipeline problem spanning retrieval, chunking, context selection, conversation memory, model choice, and generation.
What a token actually is
Language models do not read characters or words. They read tokens: integer IDs that each represent a piece of text from a fixed vocabulary. A token can be a whole common word (“steel”), part of a word (“author” + “ized”), a single punctuation mark, a space followed by a word, or a single digit.
A tokenizer converts text into these IDs. Most modern tokenizers use a variant of byte-pair encoding (BPE): training starts from individual bytes and repeatedly merges the most frequent adjacent pairs into new vocabulary entries. Frequent English words become single tokens; rare words, product codes and unfamiliar scripts are split into several smaller pieces.
| Unit | What it counts | “Steel prices increased by 5% in Delhi.” |
|---|---|---|
| Bytes | Raw storage (UTF-8) | 38 bytes |
| Characters | Visible symbols and spaces | 38 characters |
| Words | Space-separated strings | 7 words |
| Sentences | Grammatical units | 1 sentence |
| Tokens | Vocabulary entries the model reads | Depends on the tokenizer — roughly 9–11 |
An illustrative split — not verified against any specific tokenizer — might look like this. Note how the leading space is usually attached to the following word, and how “5” and “%” become separate tokens:
Illustrative only. “·” marks a leading space. Run the code below to see the real split for your tokenizer.
Why counts differ across text and models
- Different vocabularies. Each model family trains its own tokenizer. The same sentence can produce different counts on different models, so a budget tuned for one model does not transfer exactly to another.
- Numbers and codes. “12,500.75 MT” or a grade such as “Fe 550D” often splits into many small tokens. Spec sheets and price tables are token-dense.
- Punctuation and special characters. Bullets, pipes from table extraction, repeated dashes and OCR garbage each cost tokens and carry no meaning.
- Code and JSON. Braces, quotes and indentation all count. Pretty-printed JSON uses more tokens than compact JSON.
- Multilingual text. Many tokenizers are trained mostly on English, so Hindi, Tamil or Marathi text commonly needs more tokens per word. A bilingual dealer base can make average prompts noticeably longer.
A rule of thumb often quoted for English is about four characters per token. It is useful for back-of-envelope sizing and wrong for budgets, billing, and non-English text.
Input, output and cached tokens
- Input tokens (prompt tokens) are everything you send: system instructions, the question, history, retrieved context, tool definitions, and formatting the provider adds around messages.
- Output tokens (completion tokens) are what the model generates. They are produced one at a time, so they dominate latency, and providers commonly price them higher than input tokens.
- Cached input tokens are input tokens that a provider has recognized as an exact repeat of a recently processed prompt prefix. Where supported, they may be billed at a lower rate and processed faster. They are still input tokens; caching changes their price, not their count.
Embedding models and generation models are separate. The model that turns chunks into vectors may use a different tokenizer, a different maximum input length and a different price than the model that writes answers. Count each with its own tokenizer and read each provider’s usage report separately.
Counting tokens in Python
For OpenAI models supported by tiktoken, you can count locally. Verify that the encoding matches your model; other providers publish their own tokenizers or token-counting endpoints.
# pip install tiktoken
import tiktoken
# o200k_base is the encoding used by several recent OpenAI models.
# tiktoken.encoding_for_model("model-name") raises KeyError for models it does not know.
enc = tiktoken.get_encoding("o200k_base")
text = "Steel prices increased by 5% in Delhi."
ids = enc.encode(text)
print(len(ids)) # token count for this encoding
print([enc.decode([i]) for i in ids]) # the actual split Local counts are estimates. Chat APIs wrap messages in role markers and may add tokens for tool schemas or images, so the provider-reported usage field on each response is the authoritative number for billing.
Where a RAG system spends tokens
Retrieval-Augmented Generation has three phases. Ingestion runs once per document version and prepares a searchable index. Retrieval runs for every question and selects evidence. Generation runs for every question and produces the answer. Each phase has different costs.
Two kinds of token usage must be kept apart:
Storing vectors is not token consumption. A vector database charges for storage, memory and queries. Keeping a million embedding vectors does not consume a single generation token; only the chunk text you later paste into a prompt does.
| Stage | Tokens? | Consumer · phase | Optimize | Don’t optimize blindly |
|---|---|---|---|---|
| 1 Collection | No | Storage · ingestion | Exclude obsolete, draft and duplicate files | Deleting old versions still needed for historical contracts |
| 2 Extraction / OCR | No (OCR may use a vision model) | Parser or OCR · ingestion | Preserve table structure; avoid garbage characters | Skipping OCR on scanned annexures |
| 3 Cleaning | No | Code · ingestion | Strip headers, footers, navigation, whitespace | Removing dates, units, qualifiers, clause numbers |
| 4 Chunking | Indirectly | Code · ingestion | Structure-aware sizes measured in tokens | Shrinking chunks without testing recall |
| 5 Embedding | Yes | Embedding model · ingestion | Embed only new or changed chunks; batch | Changing embedding model without re-indexing everything |
| 6 Indexing | No | Vector / keyword index · ingestion | Store metadata for filters | Dropping fields you will later need to filter on |
| 7 Question processing | Sometimes | Code or small LLM · retrieval | Route deterministic questions away from RAG | Rewriting away product codes |
| 8 Query embedding | Yes (small) | Embedding model · retrieval | Cache repeated query vectors | Using a different embedding model than the index |
| 9 Retrieval | No | Index · retrieval | Filters, hybrid search, tuned top-K | Top-K = 1 everywhere |
| 10 Reranking | Yes (reranker’s own) | Reranker · retrieval | Rerank a bounded candidate set | Reranking hundreds of candidates per query |
| 11 Filter / compress | Sometimes (if LLM-based) | Code or LLM · retrieval | Dedupe; extract relevant sentences | Summaries that drop exceptions |
| 12 Prompt construction | Defines input | Code · generation | Budgeted, stable prefix first | Removing safety and grounding rules |
| 13 Generation | Yes (largest) | LLM · generation | Model routing, output limits, caching | Weak model for high-risk questions |
| 14 Validation | Sometimes | Code or LLM judge · generation | Schema and rule checks in code | Skipping citation checks |
| 15 Delivery / monitoring | No | Observability · generation | Log usage, latency, outcome | Logging only averages |
Token limits, pricing and accounting rules depend on the model, the service tier and the API configuration. Treat every number in this guide that is not derived from a formula as an illustration and replace it with the values from your provider’s documentation.
Token optimization and its neighbours
These terms overlap heavily, and teams often mean different things by them. The distinctions below are the ones used in this guide.
| Concept | Question it answers | Typical lever |
|---|---|---|
| Token optimization | How many tokens does each task process, and are they all necessary? | Trim context, history, prompts, output |
| Prompt optimization | Do the instructions produce the desired behaviour reliably? | Clear rules, schema, examples |
| Context engineering | What exact information should the model see for this request? | Selection, ordering, memory, tool results |
| Retrieval optimization | Does the retriever find the right evidence? | Chunking, hybrid search, reranking, filters |
| Model optimization | Is this the right model for this task? | Routing, fine-tuning, smaller models |
| Latency optimization | How fast does the user get a useful answer? | Streaming, parallelism, fewer output tokens |
| Cost optimization | What does a resolved question cost end to end? | All of the above, plus caching and avoiding calls |
A shorter prompt can cost more
Suppose prompt A uses 8,040 input tokens and succeeds on the first call. Prompt B is trimmed to 5,000 tokens, but in 40% of cases the answer fails validation and the system retries with the full 8,040-token prompt. Expected input per question for B is:
B also doubles latency for 40% of users and generates two answers for them. The same trap appears when an LLM-based compression step saves 3,000 tokens in the answer call but itself consumes 6,000 tokens. Optimize the end-to-end workflow per resolved question, not a single API request.
Measure before you optimize
Without a baseline you cannot tell whether a change saved money, lost quality, or did both. Instrument the current system first: log the token count of each prompt component, the provider-reported usage, latency, and whether the answer was correct.
An illustrative baseline for one dealer request (these are not industry benchmarks):
| Component | Illustrative tokens | Share of input |
|---|---|---|
| System instructions | 500 | 6.2% |
| User question | 40 | 0.5% |
| Conversation history | 1,200 | 14.9% |
| Retrieved context | 6,000 | 74.6% |
| Formatting and tool instructions | 300 | 3.7% |
| Total input | 8,040 | 100% |
| Generated answer (output) | 400 | — |
Retrieved context is three quarters of the input. That is where optimization should start.
Formulas
P_in, P_cached and P_out are prices per million tokens. Check how your provider reports cached tokens — some include them inside the input total, so uncached_input = input − cached_input.
Worked example. At hypothetical prices of $1.00 per million input tokens and $4.00 per million output tokens, with no caching:
Quality metrics to record alongside tokens
- Answer correctness — share of answers judged correct against a reference answer by a reviewer or a validated grader.
- Retrieval relevance — precision@K (share of retrieved chunks that are relevant) and recall@K (share of required evidence chunks that were retrieved).
- Groundedness — share of answer claims supported by the supplied context.
- Citation accuracy — share of citations that point to a chunk that actually supports the cited statement.
Reconcile your local estimates with the provider’s usage fields and invoices weekly. Differences come from message wrappers, tool schemas, tokenizer mismatches and retries you did not log; the bill is computed from provider usage, not from your estimate.
Sixteen techniques, from ingestion to the last output token
Each technique below explains what it is, why waste occurs, how it works on the steel dealer chatbot, and how to tell whether it helped. All before-and-after token counts are illustrative; your numbers depend on your documents, models and questions. The query-time path after applying the retrieval techniques looks like this:
Better document cleaning
PDF and Word extraction returns everything on the page: running headers, footers, page numbers, website navigation, repeated disclaimers, hyphenation breaks and OCR noise. All of it gets chunked, embedded, retrieved and paid for on every request. Cleaning removes text that carries no meaning before chunking, so it never reaches the index.
# Raw extraction (noisy)
Home | Products | Dealers | Contact Us
NorthBar Steel Ltd. | Confidential
Page 4 of 38
3.2 Minimum Order Quan-
tity
TMT bars (Fe 500D, Fe 550D): 5 MT
per delivery, North region.
Excep-tion: orders below 5 MT
for project supply require RM
approval. ¦¦ ~~ ¦
NorthBar Steel Ltd. | Confidential
Page 4 of 38# Cleaned
## 3.2 Minimum Order Quantity
TMT bars (Fe 500D, Fe 550D): 5 MT
per delivery, North region.
Exception: orders below 5 MT for
project supply require Regional
Manager (RM) approval. Cleaning must preserve qualifiers (“North region”), units (“MT”), product identifiers (“Fe 550D”), dates, clause numbers, table structure and legal language. A cleaner that removes all lines under 10 characters would delete “5 MT” rows in a table; a cleaner that joins every hyphenated break would turn “Fe-500D” into “Fe500D”.
Smarter chunking
A chunk is the unit you embed and retrieve. Its size decides how much text enters the prompt each time it is selected. Oversized chunks waste tokens: a 1,200-token chunk retrieved for a one-line MOQ rule brings 1,100 tokens of unrelated text. Undersized chunks lose context: a 60-token chunk containing “5 MT per delivery” may lose the heading that says it applies only to the North region.
| Method | How it splits | Good for |
|---|---|---|
| Fixed-size | Every N tokens or characters | Quick baselines; uniform text |
| Sentence-based | On sentence boundaries, grouped to a size | FAQs, short policies |
| Paragraph-based | On blank lines | Well-written prose documents |
| Recursive character | Tries paragraphs, then lines, then sentences, then characters until under the limit | Mixed documents; a sensible default |
| Semantic | Starts a new chunk where embedding similarity between sentences drops | Long unstructured text; costs extra embedding calls at ingestion |
| Structure-aware | Follows headings, clauses, table rows; keeps headings attached | Manuals, policies, spec tables |
| Parent-child | Small child chunks are searched; the larger parent section is returned | Precise matching with enough surrounding context |
Overlap repeats the last part of one chunk at the start of the next so a rule split across a boundary is not lost. It helps for flowing prose; it hurts when adjacent chunks are both retrieved, because the overlapping text is then paid for twice. With structure-aware chunking, boundaries already fall at headings and overlap can usually be small or zero.
Characters are not tokens. LangChain’s RecursiveCharacterTextSplitter measures characters by default; a 2,000-character spec table full of numbers may be twice as many tokens as 2,000 characters of prose. Use a token-based length function when the budget matters.
| Configuration (illustrative) | Chunks sent | Context tokens | Note |
|---|---|---|---|
| 1,200 tokens, 200 overlap, top-5 | 5 | 6,000 | Up to 800 duplicated tokens if neighbours retrieved |
| 500 tokens, 100 overlap, top-5 | 5 | 2,500 | Check that headings stay attached |
| 300 tokens, 50 overlap, top-8 | 8 | 2,400 | More chunks needed to cover the same evidence |
| Parent-child: 150-token children, 2 parents × 800 | 2 | 1,600 | Precise match, full clause context |
Better top-K retrieval
Top-K is the number of highest-scoring chunks the retriever returns. Context tokens scale roughly linearly with K. The dealer’s question needs two pieces of evidence from two documents: the MOQ clause in the Delivery Policy and the document list in the Dealer Onboarding Guide.
| K | Context tokens (400 each) | Recall@K (hypothetical eval) | Observation |
|---|---|---|---|
| 3 | 1,200 | 0.71 | Often returns three MOQ chunks and no onboarding chunk |
| 5 | 2,000 | 0.90 | Usually both documents |
| 10 | 4,000 | 0.93 | Small gain, double the tokens |
| 20 | 8,000 | 0.94 | Mostly noise; relevant facts buried in the middle |
More chunks do not guarantee better answers. Irrelevant chunks dilute attention, can contradict each other (an old MOQ next to the current one), and increase cost and time to first token. Precision is the share of retrieved chunks that are relevant; recall is the share of required evidence that was retrieved. Lowering K raises precision but risks omitting one critical piece of evidence, which makes the answer incomplete rather than slightly worse.
Metadata filtering
Metadata filters remove chunks before similarity search ranks them, so irrelevant text never competes for the top-K slots. Useful fields: product category, document type, region, effective date, customer authorization (dealer, distributor, internal), department, and document version.
A Delhi dealer asking about TMT delivery policy should not receive southern-region delivery chunks unless the question asks for a comparison. Without a filter, semantically similar southern policies fill two of five slots and add 800 tokens of wrong evidence.
filters = {
"region": ["North", "All"], # user's region plus company-wide documents
"audience": ["dealer"], # from the authenticated session, never from the prompt
"status": "current", # excludes superseded versions
"doc_type": ["policy", "faq"],
} Hybrid search and reranking
Semantic vector search compares embeddings and finds passages with similar meaning, even with different wording. It can miss exact strings: “Fe 550D”, “IS 1786”, a document ID or “12 mm” may not be close in embedding space to the right chunk. BM25 is a keyword ranking function that scores exact term matches, weighting rare terms more. Hybrid retrieval runs both and merges the results.
Reciprocal Rank Fusion (RRF) merges ranked lists without needing comparable scores: each chunk scores Σ 1 ÷ (k + rank) across lists, with k commonly set to 60.
A cross-encoder reranker reads the question and each candidate together and outputs a relevance score. It is more accurate than embedding similarity but slower, so it is applied to a bounded candidate set (say 40) to pick the final 3–5.
Rerankers consume their own compute, have their own input limits (long chunks may be truncated) and their own pricing if hosted. Those 40 candidates are read by the reranker, not by the generation model. Retrieval-model token usage and generation-model token usage are separate line items.
Context compression
Even a relevant chunk is mostly irrelevant to a specific question. Compression reduces each chunk to what the question needs. Approaches, from safest to riskiest:
- Remove irrelevant sentences using a lightweight relevance score per sentence.
- Extractive compression — keep original sentences verbatim, drop the rest.
- Redundancy elimination — keep one copy of a rule stated in several places.
- Query-focused extraction — an LLM copies only the sentences that answer the question.
- Abstractive summarization — an LLM rewrites content in fewer words.
- Contextual compression retrievers — frameworks such as LangChain wrap a retriever with a compressor that filters or extracts from each result.
The dealer eligibility rule appears in the Dealer Onboarding Guide, the Dealer FAQ and a circular, each with surrounding text — about 1,800 tokens. The model needs only the conditions and the exception:
[onboarding-guide#4 | effective 2026-04-01]
Eligibility: registered business with GST registration, minimum 2 years of
trading in construction materials, and covered storage of at least 500 sq ft.
Exception: the 2-year trading requirement does not apply to applicants
referred by an existing authorized dealer in the same district.
Required documents: GST registration certificate, PAN card, shop and
establishment licence, cancelled cheque, last 2 years' audited financials. The compressed version keeps the exception and every unit. Compression is most dangerous exactly where enterprise answers matter: exceptions (“does not apply to”), negations (“not eligible”), units (MT vs kg), and qualifiers (“in the same district”). Abstractive summaries paraphrase these and can invert them. Prefer extractive compression for policy, legal and numeric content; reserve summarization for narrative background.
An LLM compressor is another model call. If it reads 6,000 tokens to save 4,000 in the answer call, total tokens rise, and latency increases by a full model round-trip. It pays off only with a much cheaper compressor model, cached compressed results, or a very expensive answer model.
Remove duplicate retrieved content
Duplicates enter through overlapping chunks, the same PDF uploaded twice, a policy copied into an FAQ, and one page published at several URLs. Each copy consumes tokens and pushes distinct evidence out of the top-K.
- Document ID and canonical URL — drop repeated chunks from the same source and version.
- Content hash — SHA-256 of normalised text catches exact copies across files.
- Near-duplicate detection — Jaccard similarity over word shingles, MinHash, or embedding cosine similarity catches copies with small edits.
Similar is not identical. The 2025 and 2026 MOQ clauses may differ by one number; North and South delivery rules may share 95% of their text. Compare near-duplicates only within the same region, version and effective date, and keep the newest version when two are truly redundant.
Optimize system prompts
System prompts grow by accretion: each incident adds a sentence, and nobody removes the old ones. Remove repeated instructions, redundant examples, formatting rules the schema already enforces, policy text that belongs in retrieved documents, and explanations written for humans.
# Before (~500 tokens, abridged)
You are a helpful, friendly, professional
assistant for NorthBar Steel, a leading
manufacturer of TMT bars since 1987...
Always be polite. Always be accurate.
Please make sure you are accurate.
Only use the provided documents. Do not
make things up. Never make things up.
Our MOQ policy is 5 MT for North...
Format answers nicely with headings and
bullet points and bold text where useful.
Example 1: ... Example 2: ... Example 3: ...
If unsure, say you are unsure.# After (~300 tokens)
Role: NorthBar Steel dealer-support assistant.
Grounding: answer only from CONTEXT. If
CONTEXT is insufficient, say so and set
escalate=true. Cite chunk ids for each fact.
Include applicable exceptions and dates.
Access: never reveal content not in CONTEXT;
never discuss other dealers' accounts.
Prices: never state a price; the pricing
service provides prices.
Escalate: legal disputes, credit limits,
complaints -> escalate=true.
Output: JSON matching the provided schema.
One example: {...} The optimized prompt keeps every rule that changes behaviour: grounding, citation, access control, price handling, escalation and the output schema. It removes the MOQ policy because policy text should come from retrieval, where it can be versioned. Cutting further — for example removing the grounding rule to save 30 tokens — can increase hallucinations, unsafe tool calls and missing escalations.
Control conversation history
Chat APIs are stateless: the full history is resent on every turn, so a conversation’s cumulative input grows roughly quadratically with its length. A dealer has exchanged 30 messages about grades, districts and delivery dates. The current question needs only three facts: the product grade, the delivery district, and the last clarification.
| Strategy | What is sent | Trade-off |
|---|---|---|
| Full history | All 30 messages | Complete but expensive; old, irrelevant turns compete for attention |
| Recent-turn window | Last N turns | Cheap and simple; forgets facts stated early |
| Rolling summary | Summary of older turns + recent turns | Compact; summary can drop or distort details |
| Structured memory | Typed fields extracted per turn | Precise and auditable; requires a schema |
| Stable preferences | Stored profile (language, district) | Never resent as chat; must stay current |
| Retrieved history | Past turns relevant to the current question | Good for long sessions; adds retrieval work |
{
"product_grade": "Fe 500D",
"delivery_district": "South West Delhi",
"dealer_status": "applicant, not yet authorized",
"last_clarification": "Dealer asked about 12 mm and 16 mm bars only",
"recent_turns": ["user: And what about the documents?"]
} Structured memory protects details summaries tend to lose: numbers, codes and negations (“not yet authorized”). Update fields with code or a small extraction model after each turn, and keep the last one or two turns verbatim for pronoun resolution.
Query rewriting and query expansion
Query rewriting turns a long or ambiguous message into one concise search query. “Hi, so as I mentioned earlier we’re in Delhi and wanted to check the thing about how much we need to order minimum for the bars and also the paperwork” becomes “TMT bar minimum order quantity North region; authorized dealer required documents”. It also resolves pronouns from memory (“what about 16 mm?” → “Fe 500D 16 mm MOQ North region”).
Multi-query expansion generates several variants (“MOQ TMT”, “minimum order TMT bars tonnes”, “dealer onboarding documents”) and retrieves for each. It improves recall when users and documents use different vocabulary, but costs an LLM call, one embedding per variant, more retrieval work, and a larger candidate pool.
Keep the final context compact by merging the variant results with RRF, deduplicating, reranking against the original question, and packing to the same token budget. Expansion should widen the candidate pool, not the prompt.
Dynamic context budgets
A fixed context size is too large for simple questions and too small for complex ones. Classify the question (with rules or a small model) and assign a budget for retrieved context:
| Question type | Example | Context budget (illustrative) |
|---|---|---|
| Simple factual | “What is the MOQ for TMT bars?” | ≈ 800 |
| Multi-document policy | MOQ plus onboarding documents | ≈ 1,500–2,500 |
| Technical comparison | Fe 500D vs Fe 550D for coastal projects | ≈ 3,000–5,000 |
| High-risk contractual | Warranty claim after improper storage | Enough for all applicable clauses and exceptions |
The ceiling comes from the model: context budget = input limit − system − question − history − tool schemas − reserved output − safety margin. Section 09 works through the numbers. The ceiling is a limit, not a target; a model advertising a very large context window should not be filled for every request.
Structured output and concise answers
Without guidance, models add greetings, restate the question and append offers of further help. An explicit structure removes that filler and makes answers checkable:
{"answer": "MOQ for TMT bars in the North region is 5 MT per delivery. Orders below 5 MT for project supply need Regional Manager approval. To become an authorized dealer you need: GST registration certificate, PAN card, shop and establishment licence, cancelled cheque, last 2 years' audited financials.",
"evidence": ["delivery-policy#2", "onboarding-guide#4"],
"effective_date": "2026-04-01",
"escalate": false} Compact JSON is efficient for downstream systems and validators; readable prose is better for people. A common pattern is JSON from the model, rendered to prose by the UI. Savings depend on your actual format: verbose field names, pretty-printing and fields nobody consumes all cost output tokens. Include a field only if something downstream uses it.
Model selection and task routing
One large model for every step is the simplest design and rarely the cheapest. Route each task to the cheapest component that does it reliably:
| Task | Suitable component |
|---|---|
| Intent classification, query rewriting, memory extraction | Small, fast LLM or a classifier |
| Multi-document synthesis, contractual reasoning | Stronger LLM |
| Current prices, stock, order status, calculations | Deterministic API or database |
| Turning text into vectors | Dedicated embedding model |
| Choosing the best candidates | Reranker |
Routing reduces cost when most traffic is simple. Smaller models may need more retries, follow schemas less reliably and fail on complex synthesis, so a cheaper per-call model can produce a more expensive per-resolution system. Route by evaluated capability, and escalate to the stronger model on low confidence or validation failure.
Prompt caching and reusable prefixes
Some providers can reuse the internal computation for a prompt prefix they processed recently. When a new request begins with exactly the same tokens, those tokens may be served from cache: often billed at a reduced rate and processed faster. The logical token count does not change; the price and latency of the cached portion may.
- Exact prefix reuse. Caches match from the first token. One changed character early in the prompt invalidates everything after it.
- Dynamic content placement. Put stable content first (system prompt, tool schemas, fixed examples) and variable content last (memory, retrieved context, question). A timestamp at the top of the system prompt defeats caching.
- Eligibility and minimums. Providers set minimum prefix lengths; a 300-token system prompt may be too short to qualify.
- Lifetime. Cached prefixes expire after a period of inactivity; low-traffic routes may rarely hit.
- Provider differences. At the time of writing, OpenAI’s guide describes caching applied automatically to sufficiently long prompts, while Anthropic’s requires marking cache breakpoints in the request and prices cache writes separately. Minimum lengths, lifetimes and discounts vary by model and change over time — see the references.
Caching is never guaranteed for a given request. Read the cached-token field in the usage response to see what actually hit, and budget as if it might not.
Output token limits
Output tokens are generated sequentially, so each one adds latency, and they are commonly priced above input. The API’s maximum-output parameter is a hard stop, not a length target: the model does not plan to fit it, and an answer that reaches it is cut mid-sentence (or mid-JSON, which then fails to parse).
- Set the limit from observed lengths: for example the P99 of good answers for that route plus a margin.
- Control typical length with instructions (“at most 120 words”) and structure, not with the limit.
- Detect truncation from the finish reason in the response and retry or escalate instead of showing a broken answer.
- Remember that reasoning-style models may spend hidden output tokens before the visible answer; check how your provider counts them.
Avoid unnecessary LLM calls
The cheapest generation call is the one you do not make. Options: deterministic routing (keywords, intents), direct database queries, cached answers for stable FAQs, rule-based validation instead of an LLM judge, reusing stored embeddings, batch processing for offline work, and early exits for off-topic or unsupported questions. A query embedding is not an answer-generation call; it is small and cheap, and it can be cached for repeated queries.
“What is today’s price of Fe 500D 12 mm in Delhi?” should never be answered from retrieved PDFs. Prices change, and an LLM may reconstruct a plausible but wrong number. Route it to the authorized pricing API:
import re
PRICE_INTENT = re.compile(r"\b(price|rate|cost)\b.*\b(today|current|now)\b", re.I)
GRADE = re.compile(r"\bFe\s?(500D?|550D?)\b", re.I)
SIZE = re.compile(r"\b(8|10|12|16|20|25|32)\s?mm\b", re.I)
def route(question: str, session: dict, pricing_api) -> dict | None:
"""Return a direct answer, or None to continue to RAG."""
if not PRICE_INTENT.search(question):
return None
grade, size = GRADE.search(question), SIZE.search(question)
if not (grade and size):
return None # let RAG ask a clarifying question
quote = pricing_api.get_price( # your authorized internal service
grade=f"Fe {grade.group(1).upper()}",
size_mm=int(size.group(1)),
district=session["district"],
dealer_id=session["dealer_id"], # dealer-specific price, permission-checked
)
return {"answer": f"{quote['grade']} {quote['size_mm']} mm: {quote['price']} {quote['unit']} "
f"(valid until {quote['valid_until']})",
"source": "pricing-api", "llm_called": False} Caching answers needs the same care as caching data: invalidate when the source document changes, key the cache by permissions and region so one dealer never sees another’s terms, set short lifetimes for anything time-sensitive, and never cache user-specific responses in a shared cache.
Before and after, for one dealer request
The optimized scenario applies cleaning and structure-aware chunking, metadata filtering, hybrid retrieval with reranking, deduplication and extractive compression, structured history, and a revised system prompt. These numbers are an illustration of the method, not a promise of achievable savings.
| Component | Baseline | Optimized | Main technique |
|---|---|---|---|
| System prompt | 500 | 300 | H |
| Question | 40 | 40 | — |
| History | 1,200 | 250 | I |
| Retrieved context | 6,000 | 1,500 | A, B, D, E, F, G |
| Formatting / tools | 300 | 100 | H, L |
| Input total | 8,040 | 2,190 | |
| Output | 400 | 250 | L, O |
| Total | 8,440 | 2,440 |
Calculations
Costs below use hypothetical prices of $1.00 per million input tokens and $4.00 per million output tokens, no caching, and 300,000 requests per month. They are not any provider’s prices; adjust them in the Tweaks panel.
| Cost line | Baseline | Optimized | Saving |
|---|---|---|---|
| Input cost per request | $0.00804 | $0.00219 | |
| Output cost per request | $0.00160 | $0.00100 | |
| 7. Cost per request | $0.00964 | $0.00319 | $0.00645 |
| 8. Cost per 1,000 requests | $9.64 | $3.19 | $6.45 |
| 9. Monthly (300,000 requests) | $2,892.00 | $957.00 | $1,935.00 |
Why cost fell by 66.9% while tokens fell by 71.1%. Input and output are priced differently. Input fell by 72.8% but output only by 37.5% (400 → 250), and output carries the higher price, so the blended cost reduction is different from the token reduction. With prompt caching, cached input would be priced lower again and the percentages would shift once more. Always compute savings from priced token categories, not from total tokens.
The full picture includes added costs: the reranker, any query-rewrite calls, and extra embedding calls for expansion. Subtract them from the saving.
A token-aware RAG pipeline you can run locally
The file below runs end to end with only the Python standard library (Python 3.10+); tiktoken is optional. To keep it runnable without accounts or GPUs, three components are local stand-ins behind clearly defined interfaces: a lexical retriever in place of vector/hybrid search, an overlap scorer in place of a cross-encoder, and an extractive “LLM” that assembles an answer from the context. A provider adapter for a real chat API follows the listing.
"""token_aware_rag.py — minimal, runnable, token-aware RAG pipeline.
Python 3.10+. Optional: pip install tiktoken
Local token counts are estimates; provider-reported usage is authoritative for billing.
"""
from __future__ import annotations
import hashlib
import json
import logging
import math
import re
import time
from dataclasses import dataclass
from typing import Protocol
logging.basicConfig(level=logging.INFO, format="%(message)s")
log = logging.getLogger("rag")
# HYPOTHETICAL prices in USD per 1M tokens. Replace with your provider's current prices.
PRICES = {"input": 1.00, "cached_input": 0.25, "output": 4.00}
# ---------------------------------------------------------------- 1. Token counting
try:
import tiktoken
_ENC = tiktoken.get_encoding("o200k_base") # verify this matches your generation model
except Exception: # not installed, or encoding download failed
_ENC = None
def count_tokens(text: str) -> int:
"""Estimate tokens. Falls back to ~4 chars/token (English-biased, rough)."""
if _ENC is not None:
return len(_ENC.encode(text))
return math.ceil(len(text) / 4)
# ---------------------------------------------------------------- 2. Cleaning
BOILERPLATE = [
re.compile(r"^page \d+ of \d+$", re.I),
re.compile(r"^northbar steel ltd\. \| confidential$", re.I),
re.compile(r"^(home|products|dealers|contact us)( \| (home|products|dealers|contact us))+$", re.I),
]
def clean_text(raw: str) -> str:
"""Remove boilerplate and noise; keep single blank lines as paragraph breaks."""
lines: list[str] = []
for line in raw.splitlines():
line = re.sub(r"[ \t]+", " ", line.replace("\u00ad", "")).strip()
line = re.sub(r"[¦~]{2,}", "", line).strip() # OCR artifacts seen in this corpus
if any(p.match(line) for p in BOILERPLATE):
continue
if not line:
if lines and lines[-1] != "":
lines.append("")
continue
lines.append(line)
text = "\n".join(lines).strip()
# Re-join words hyphenated across lines, lowercase only so codes like "Fe-500D" survive.
return re.sub(r"([a-z])-\n([a-z])", r"\1\2", text)
# ---------------------------------------------------------------- 3. Chunking
@dataclass
class Chunk:
chunk_id: str
doc_id: str
text: str
metadata: dict
tokens: int
def chunk_document(doc_id: str, text: str, metadata: dict,
max_tokens: int = 400, overlap_paragraphs: int = 0) -> list[Chunk]:
"""Paragraph-aware chunking bounded by token count.
Limitation: a single paragraph longer than max_tokens becomes one oversized chunk.
"""
paragraphs = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
chunks: list[Chunk] = []
current: list[str] = []
def flush() -> None:
if current:
body = "\n".join(current)
chunks.append(Chunk(f"{doc_id}#{len(chunks)}", doc_id, body,
dict(metadata), count_tokens(body)))
for para in paragraphs:
if current and count_tokens("\n".join(current + [para])) > max_tokens:
flush()
current = current[-overlap_paragraphs:] if overlap_paragraphs else []
current.append(para)
flush()
return chunks
# ---------------------------------------------------------------- 4. Metadata filtering
def matches(meta: dict, filters: dict) -> bool:
for key, allowed in filters.items():
value = meta.get(key)
if isinstance(allowed, (list, tuple, set)):
if value not in allowed:
return False
elif value != allowed:
return False
return True
# ---------------------------------------------------------------- 5. Candidate retrieval
def words(text: str) -> list[str]:
return re.findall(r"[a-z0-9]+", text.lower())
class Retriever(Protocol):
def search(self, query: str, filters: dict, k: int) -> list[tuple[float, Chunk]]: ...
class LexicalRetriever:
"""LOCAL STAND-IN for vector or hybrid search: IDF-weighted term overlap."""
def __init__(self, chunks: list[Chunk]):
self.chunks = chunks
self.df: dict[str, int] = {}
for c in chunks:
for w in set(words(c.text)):
self.df[w] = self.df.get(w, 0) + 1
def search(self, query: str, filters: dict, k: int = 20) -> list[tuple[float, Chunk]]:
q, n = set(words(query)), len(self.chunks)
scored = []
for c in self.chunks:
if not matches(c.metadata, filters): # filter BEFORE ranking
continue
shared = q & set(words(c.text))
score = sum(math.log(1 + n / self.df[w]) for w in shared)
if score > 0:
scored.append((score, c))
scored.sort(key=lambda s: s[0], reverse=True)
return scored[:k]
# ---------------------------------------------------------------- 6. Reranking
class Reranker(Protocol):
def score(self, query: str, texts: list[str]) -> list[float]: ...
class OverlapReranker:
"""LOCAL STAND-IN for a cross-encoder: share of query terms present in the text."""
def score(self, query: str, texts: list[str]) -> list[float]:
q = set(words(query))
return [len(q & set(words(t))) / (len(q) or 1) for t in texts]
# ---------------------------------------------------------------- 7. Deduplication
def shingles(text: str, n: int = 5) -> set[str]:
w = words(text)
return {" ".join(w[i:i + n]) for i in range(max(1, len(w) - n + 1))}
def deduplicate(chunks: list[Chunk], threshold: float = 0.85) -> list[Chunk]:
"""Drop exact copies (hash) and near copies (Jaccard), keeping input order.
Near-duplicates are compared only within the same region and effective date,
so similar-looking but distinct policies are both kept.
"""
kept: list[Chunk] = []
hashes: set[str] = set()
sigs: list[tuple[tuple, set[str]]] = []
for c in chunks:
h = hashlib.sha256(" ".join(words(c.text)).encode()).hexdigest()
if h in hashes:
continue
key = (c.metadata.get("region"), c.metadata.get("effective_date"))
s = shingles(c.text)
if any(k == key and len(s & t) / len(s | t) >= threshold for k, t in sigs):
continue
hashes.add(h)
sigs.append((key, s))
kept.append(c)
return kept
# ---------------------------------------------------------------- 8. Context budgeting
def context_budget(window: int, max_output: int, fixed_text: str,
margin_ratio: float = 0.05, task_cap: int = 3000) -> int:
"""Tokens available for retrieved context = window - output - margin - fixed parts."""
available = window - max_output - int(window * margin_ratio) - count_tokens(fixed_text)
return max(0, min(available, task_cap))
def format_chunk(c: Chunk) -> str:
return f"[{c.chunk_id}] effective {c.metadata.get('effective_date', 'n/a')}\n{c.text}"
def pack_context(ranked: list[Chunk], budget: int) -> tuple[list[Chunk], int]:
"""Greedy packing in rank order. Whole chunks only: never cut a clause mid-way."""
selected, used = [], 0
for c in ranked:
cost = count_tokens(format_chunk(c)) + 2 # +2 for the blank-line separator
if used + cost > budget:
continue # a smaller, lower-ranked chunk may still fit
selected.append(c)
used += cost
return selected, used
# ---------------------------------------------------------------- 9. Prompt construction
SYSTEM_PROMPT = (
"Role: NorthBar Steel dealer-support assistant.\n"
"Grounding: answer only from CONTEXT. If CONTEXT is insufficient, say so and set escalate=true.\n"
"Cite chunk ids for each fact. Include applicable exceptions and effective dates.\n"
"Prices: never state a price; the pricing service provides prices.\n"
'Output JSON: {"answer": str, "sources": [str], "effective_date": str or null, "escalate": bool}'
)
def build_messages(question: str, memory_json: str, chunks: list[Chunk]) -> list[dict]:
context = "\n\n".join(format_chunk(c) for c in chunks)
user = f"MEMORY: {memory_json}\n\nCONTEXT:\n{context}\n\nQUESTION: {question}"
# Stable system prompt first (cache-friendly); variable content last.
return [{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user}]
# ---------------------------------------------------------------- 10. Generation interface
@dataclass
class LLMResult:
text: str
input_tokens: int
output_tokens: int
cached_input_tokens: int = 0
finish_reason: str = "stop"
class LLMClient(Protocol):
def generate(self, messages: list[dict], max_output_tokens: int) -> LLMResult: ...
class ExtractiveLocalLLM:
"""LOCAL STAND-IN, not a language model: answers with the first sentence of each
context block so the pipeline runs offline. Token counts are local estimates."""
BLOCK = re.compile(r"^\[([^\]]+)\] effective (\S+)\n(.+?)(?=\n\n\[|\n\nQUESTION|\Z)", re.S | re.M)
def generate(self, messages: list[dict], max_output_tokens: int) -> LLMResult:
prompt = "\n".join(m["content"] for m in messages)
blocks = self.BLOCK.findall(prompt)
answer = " ".join(body.split(". ")[0].rstrip(".") + "." for _, _, body in blocks)
dates = sorted(d for _, d, _ in blocks if d != "n/a")
payload = {"answer": answer or "The available documents do not cover this question.",
"sources": [cid for cid, _, _ in blocks],
"effective_date": dates[-1] if dates else None,
"escalate": not blocks}
text = json.dumps(payload)
return LLMResult(text, count_tokens(prompt), count_tokens(text))
# ---------------------------------------------------------------- 11. Cost, usage, latency
def estimate_cost(r: LLMResult, prices: dict = PRICES) -> float:
uncached = r.input_tokens - r.cached_input_tokens
return (uncached * prices["input"] + r.cached_input_tokens * prices["cached_input"]
+ r.output_tokens * prices["output"]) / 1_000_000
# ---------------------------------------------------------------- 12. Evaluation
def evaluate(result_text: str, required_facts: list[str], allowed_ids: set[str]) -> dict:
try:
data = json.loads(result_text)
except json.JSONDecodeError:
return {"valid_json": False}
answer = data.get("answer", "").lower()
found = [f for f in required_facts if f.lower() in answer]
cited = set(data.get("sources", []))
return {"valid_json": True,
"fact_recall": len(found) / len(required_facts) if required_facts else 1.0,
"missing_facts": [f for f in required_facts if f not in found],
"citations_valid": bool(cited) and cited <= allowed_ids,
"escalate": data.get("escalate")}
# ---------------------------------------------------------------- Orchestration
def answer_question(question: str, filters: dict, memory: dict, retriever: Retriever,
reranker: Reranker, llm: LLMClient, *, window: int = 16_000,
max_output: int = 600, task_cap: int = 1_500,
required_facts: tuple[str, ...] = ()) -> dict:
t0 = time.perf_counter()
candidates = retriever.search(question, filters, k=20)
if not candidates: # early exit: no LLM call
return {"answer": "No authorized document covers this question.",
"escalate": True, "llm_called": False}
chunks = [c for _, c in candidates]
try:
scores = reranker.score(question, [c.text for c in chunks])
except Exception as exc: # degrade, don't fail
log.warning("reranker failed, using retrieval order: %s", exc)
scores = [s for s, _ in candidates]
ranked = [c for _, c in sorted(zip(scores, chunks), key=lambda p: p[0], reverse=True)]
ranked = deduplicate(ranked)
memory_json = json.dumps(memory, separators=(",", ":")) # compact JSON
budget = context_budget(window, max_output, SYSTEM_PROMPT + memory_json + question,
task_cap=task_cap)
selected, context_tokens = pack_context(ranked, budget)
messages = build_messages(question, memory_json, selected)
t1 = time.perf_counter()
try:
result = llm.generate(messages, max_output)
except Exception as exc:
log.error("generation failed: %s", exc)
return {"answer": None, "escalate": True, "error": str(exc), "llm_called": True}
t2 = time.perf_counter()
if result.finish_reason == "length":
log.warning("answer truncated at max_output=%d", max_output)
record = {
"candidates": len(candidates), "after_dedup": len(ranked), "sent_chunks": len(selected),
"context_budget": budget, "context_tokens_est": context_tokens,
"input_tokens": result.input_tokens, "cached_input_tokens": result.cached_input_tokens,
"output_tokens": result.output_tokens, "cost_usd_est": round(estimate_cost(result), 6),
"retrieval_ms": round((t1 - t0) * 1000, 1), "generation_ms": round((t2 - t1) * 1000, 1),
}
log.info(json.dumps(record))
evaluation = evaluate(result.text, list(required_facts), {c.chunk_id for c in selected})
return {"response": result.text, "usage": record, "evaluation": evaluation, "llm_called": True}
# ---------------------------------------------------------------- Demo (fictional data)
RAW_DOCS = [
("delivery-policy", """Home | Products | Dealers | Contact Us
NorthBar Steel Ltd. | Confidential
Page 4 of 38
3.2 Minimum Order Quantity
TMT bars minimum order quantity is 5 metric tonnes per delivery for dealers in the North region. Orders below 5 metric tonnes for project supply require Regional Manager approval. ¦¦ ~~
3.3 Delivery Window
Deliveries within Delhi NCR are scheduled within 3 working days of order confirmation.""",
{"region": "North", "audience": "dealer", "doc_type": "policy", "effective_date": "2026-04-01"}),
("delivery-policy-south", """3.2 Minimum Order Quantity
TMT bars minimum order quantity is 8 metric tonnes per delivery for dealers in the South region.""",
{"region": "South", "audience": "dealer", "doc_type": "policy", "effective_date": "2026-04-01"}),
("onboarding-guide", """4 Becoming an Authorized Dealer
Required documents to become an authorized dealer: GST registration certificate, PAN card, shop and establishment licence, cancelled cheque, and the last 2 years of audited financial statements. Applicants referred by an existing authorized dealer in the same district are exempt from the 2-year trading requirement.""",
{"region": "All", "audience": "dealer", "doc_type": "policy", "effective_date": "2026-04-01"}),
("dealer-faq-copy", """4 Becoming an Authorized Dealer
Required documents to become an authorized dealer: GST registration certificate, PAN card, shop and establishment licence, cancelled cheque, and the last 2 years of audited financial statements. Applicants referred by an existing authorized dealer in the same district are exempt from the 2-year trading requirement.""",
{"region": "All", "audience": "dealer", "doc_type": "faq", "effective_date": "2026-04-01"}),
("credit-terms-internal", """Internal: dealer credit limits are reviewed quarterly by finance.""",
{"region": "All", "audience": "internal", "doc_type": "policy", "effective_date": "2026-01-01"}),
]
if __name__ == "__main__":
all_chunks: list[Chunk] = []
for doc_id, raw, meta in RAW_DOCS:
all_chunks += chunk_document(doc_id, clean_text(raw), meta, max_tokens=120)
out = answer_question(
"What is the minimum order quantity for TMT bars, and what documents are "
"required to become an authorized dealer?",
filters={"region": ["North", "All"], "audience": ["dealer"]}, # from the session
memory={"product_grade": "Fe 500D", "delivery_district": "South West Delhi",
"dealer_status": "applicant"},
retriever=LexicalRetriever(all_chunks),
reranker=OverlapReranker(),
llm=ExtractiveLocalLLM(),
required_facts=("5 metric tonnes", "GST registration certificate"),
)
print(json.dumps(out, indent=2)) Running it shows the southern MOQ and the internal credit document removed by filters, the FAQ copy of the onboarding text removed by deduplication, and an evaluation confirming both required facts and valid citations. Change task_cap, max_tokens or the filters to see the effect on context tokens.
Provider adapter for real generation
Provider-specific sketch. This adapter targets the OpenAI Python SDK’s Chat Completions interface. Parameter and field names change between SDK versions and models; verify them against the current documentation before use. Note that the SDK reports cached tokens inside the prompt-token total, which matches estimate_cost above.
# PROVIDER-SPECIFIC — verify against your SDK version. pip install openai
from openai import OpenAI
class OpenAIChatAdapter:
def __init__(self, model: str, client: OpenAI | None = None):
self.model, self.client = model, client or OpenAI() # reads OPENAI_API_KEY
def generate(self, messages: list[dict], max_output_tokens: int) -> LLMResult:
resp = self.client.chat.completions.create(
model=self.model,
messages=messages,
max_completion_tokens=max_output_tokens,
response_format={"type": "json_object"},
)
usage = resp.usage # authoritative for billing
details = getattr(usage, "prompt_tokens_details", None)
cached = getattr(details, "cached_tokens", 0) or 0
choice = resp.choices[0]
return LLMResult(choice.message.content or "", usage.prompt_tokens,
usage.completion_tokens, cached, choice.finish_reason) A production vector or hybrid retriever implements the same search(query, filters, k) method; a cross-encoder implements score(query, texts). The orchestration does not change.
Limitations and production controls
- The lexical retriever and overlap reranker are teaching stand-ins; they do not understand synonyms.
- The fallback token estimate is crude; the tiktoken encoding may not match a non-OpenAI model at all.
- The extractive evaluator checks fact strings, not meaning. Use human review or a validated grader for correctness and groundedness.
- Add before production: timeouts and retries with backoff, rate-limit handling, request IDs, permission checks in the retriever (not just filters passed by the caller), PII redaction in logs, schema validation with a retry on failure, and metrics export to your observability stack.
A worked budget for a 16,000-token window
Assume a hypothetical generation model with a 16,000-token context window that covers input and output together. Replace this with the documented limits of your model; some models state input and output limits separately.
| Line | Tokens | Reasoning |
|---|---|---|
| Context window (hypothetical) | 16,000 | From model documentation |
| − Reserved output | 1,000 | P99 answer length plus margin |
| − Safety margin (5%) | 800 | Tokenizer mismatch, message wrappers, provider overhead |
| = Usable input | 14,200 | |
| − System instructions | 400 | Measured |
| − Tool schemas and instructions | 600 | Function definitions are serialized into input |
| − User question (cap) | 200 | Longer messages are rewritten or rejected |
| − Conversation memory (cap) | 1,000 | Structured memory + last turns |
| = Maximum retrieved context | 12,000 | A ceiling, not a target |
The usable budget is smaller than the window because output shares the window, providers add formatting tokens around each message, tool-call payloads and tool results are counted as input, and a local tokenizer may disagree with the provider’s by a few percent. If the input reaches the window, the request fails or the output is cut short.
Then apply a task cap below the ceiling: about 800 tokens for a simple factual question, 1,500–2,500 for a multi-document policy question, 3,000–5,000 for a technical comparison, and whatever the applicable clauses require for a high-risk contractual question, up to the 12,000 ceiling. If even the ceiling cannot hold the required evidence, split the task (answer per document, then combine) or escalate rather than truncating clauses.
Fifteen ways token optimization goes wrong
Proving the optimization did not break anything
Build an evaluation set from real, anonymised dealer and employee questions, labelled with the reference answer and the chunk IDs that contain the required evidence. A few hundred questions spread across categories is more useful than thousands of easy ones. Cover each category deliberately:
| Category | Steel company example | What it catches |
|---|---|---|
| Simple factual | Delivery window in Delhi NCR | Basic regressions |
| Multi-document | MOQ and onboarding documents | Top-K and budget too small |
| Exact numeric / product code | Elongation spec for Fe 550D 16 mm | Semantic search misses, unit loss |
| Exceptions | MOQ for a project supply under 5 MT | Compression dropping “except” |
| Insufficient evidence | Policy for a product not sold | Hallucination instead of escalation |
| Multilingual | The MOQ question in Hindi | Token inflation, retrieval gaps |
| Permission-restricted | Dealer asking about internal credit rules | Leaks through filters or caches |
| Outdated / conflicting | 2025 and 2026 MOQ both indexed | Version filtering, dedup merging |
Metrics, measured together
Running a controlled comparison
- Run baseline and candidate on the same questions, same index snapshot, same model version, and low or zero temperature.
- Change one technique at a time so you can attribute gains and losses.
- Repeat runs or use enough questions to separate real differences from noise; report differences per category, not only overall.
- Agree acceptance thresholds before running, for example: correctness within 1 point of baseline, recall@K ≥ 0.90, zero permission leaks, all exception cases pass, P95 latency not worse.
- After offline acceptance, shadow or A/B test on live traffic and compare billed usage, not just logged estimates.
There is a real trade-off curve between tokens and quality. Early cuts (boilerplate, duplicates, irrelevant regions) are usually free; later cuts start removing evidence. Plot tokens per request against correctness for each configuration and pick the point where further savings start costing accuracy. Token reduction is never the only success criterion.
Production techniques and what each one actually improves
These techniques address different goals. Some reduce the tokens processed, some reduce the price paid for them, some mainly reduce latency, and some mainly improve quality. Few applications need all of them.
| Technique | What it does | Primary effect |
|---|---|---|
| Query-aware retrieval budgets | Budget and K chosen per question type | Fewer tokens |
| Adaptive top-K | Stop adding chunks when reranker scores drop sharply | Fewer tokens |
| Parent-child retrieval | Match small chunks, return their section | Quality |
| Contextual compression | Extract relevant sentences per chunk | Fewer tokens |
| Semantic / near-duplicate detection | Remove repeated evidence | Fewer tokens |
| Document versioning, incremental indexing | Re-embed only changed documents; retire old versions | Ingestion cost Quality |
| Embedding cache | Store vectors keyed by content hash and model | Ingestion cost |
| Semantic answer cache | Reuse answers to similar questions, keyed by permissions and document versions | Billable cost Latency |
| Batching offline ingestion | Embed in batches; use batch APIs where offered | Billable cost |
| Model routing | Cheaper model for simple tasks | Billable cost |
| Prompt-prefix caching | Stable prefix reused across requests | Billable cost Latency |
| Streaming responses | Show tokens as they are generated | Perceived latency |
| Parallel retrieval | Run BM25, vector and API lookups concurrently | Latency |
| Token and cost observability | Per-component token logs, per-route cost dashboards | Enables all others |
| Rate-limit-aware scheduling | Queue and prioritise by tokens-per-minute limits | Reliability |
| Multi-tenant cost controls | Per-tenant budgets, quotas and alerts | Billable cost |
| Regression testing | Run the eval set on every chunking, prompt or model change | Quality |
Streaming does not reduce tokens or cost; it reduces how long the user waits to see something. Semantic caching reduces cost but is the riskiest item on the list: two similar questions from dealers in different regions may need different answers, so the cache key must include region, permissions and the versions of the source documents.
From symptom to technique
| Observed problem | Likely cause | Technique | Expected benefit | Quality risk | Validate by |
|---|---|---|---|---|---|
| High input tokens | Large chunks, high K, boilerplate | A, B, C, G | Smaller context | Lost evidence | Recall@K, correctness |
| High output tokens | Verbose answers, no structure | L, O | Lower cost and latency | Truncation, terse answers | Finish reasons, user feedback |
| Poor retrieval relevance | Vector-only search, no filters | D, E, J | Fewer, better chunks | Wrong filters | Precision@K by category |
| Slow first-token latency | Long prompts, sequential steps | C, F, N, streaming, parallel retrieval | Faster response | Low if quality held | P50/P95 time to first token |
| Repeated API costs | Same questions and prefixes | N, P, answer cache | Lower billed cost | Stale or leaked answers | Cache hit rate, freshness tests |
| Excessive history | Full transcript resent | I | Flat history cost | Forgotten constraints | Multi-turn tests |
| Wrong answers after compression | Exceptions or units dropped | Extractive F, keep-rules | Restored accuracy | Fewer savings | Exception test set |
| Too many reranker calls | Large candidate pools, every query reranked | Smaller pools, skip rerank on high-confidence hits | Lower latency and cost | Lower precision | Precision@K, retrieval P95 |
| Outdated cached responses | No invalidation on document change | Version-keyed cache, TTLs | Correct answers | Lower hit rate | Update a doc, re-ask |
Prioritization
- Measure. Log tokens per prompt component and build the evaluation set.
- Remove waste with no evidence in it. Cleaning, deduplication, history control, prompt clean-up, output structure.
- Route away from the LLM where a database or API is the correct source.
- Improve retrieval with filters, hybrid search and reranking, then lower K.
- Add caching and model routing once traffic patterns are known.
- Introduce compression last, with exception tests, because it carries the highest quality risk.
Checklist
Fewer tokens, or better RAG?
Fewer tokens is a measurement. Better RAG is an outcome: the dealer gets the correct MOQ, the applicable exception, the complete document list and a citation, quickly and at a sustainable cost. The two usually move together when you remove waste, and diverge when you start removing evidence.
Principles
- Optimize the pipeline, not the prompt: most tokens come from retrieval and history.
- Measure cost per resolved question, not per request.
- Count with the right tokenizer and reconcile with provider-reported usage.
- Remove noise and duplicates before removing evidence.
- Use deterministic systems for prices, stock and calculations.
- Treat budgets as ceilings and context windows as limits, not targets.
- Protect exceptions, negations, units and permissions in every transformation.
Implementation order
Instrument → evaluation set → cleaning → deduplication → history control → system prompt and output structure → deterministic routing → metadata filters → hybrid search and reranking → tuned K and dynamic budgets → prompt caching and model routing → extractive compression.
Metrics to watch
Input tokens per request by component, output tokens, cost per resolved question, recall@K, correctness, groundedness, citation accuracy, exception coverage, P95 latency, escalation rate and cache hit rate.
For tomorrow
- Log token counts per prompt component for one day of traffic.
- Pull 50 real questions, label answers and evidence, and include exceptions.
- Read ten logged prompts end to end and mark every duplicate and boilerplate line.
- Replace full history with structured memory plus the last two turns.
- Route current-price questions to the pricing API.
- Re-run the 50 questions and compare tokens, cost and correctness against the baseline.
“Optimize the evidence sent to the model, not just the words in the prompt. The goal is the minimum sufficient context that preserves the maximum reliable answer quality.”
Provider features, limits and prices change frequently. Check these pages for current details before relying on any behaviour described above.
- openai/tiktoken — tokenizer library and supported encodings.
- OpenAI — Prompt caching guide
- OpenAI — API pricing
- Anthropic — Prompt caching
- Anthropic — Pricing
- LangChain — Text splitters
- Sentence Transformers — Cross-encoders
- Cormack, Clarke and Büttcher (2009). “Reciprocal Rank Fusion outperforms Condorcet and individual rank learning methods.” SIGIR.
- Robertson and Zaragoza (2009). “The Probabilistic Relevance Framework: BM25 and Beyond.” Foundations and Trends in Information Retrieval.
All companies, documents, token counts, prices and evaluation scores in this guide are fictional or hypothetical and are used only to illustrate methods.
- Tags:
- business
- technology