Ritus, the coffee app I'm building, has a chat tab called the Lab for coffee questions. It runs 100% on the iPhone with Apple's Foundation Models framework, the model behind Apple Intelligence. This post is how I made that small on-device model answer from the app's own recipes, grind guide and glossary instead of making things up.
In short:
- Search does most of the work: keyword search (BM25) re-ranked by Apple's on-device embeddings, merged with Reciprocal Rank Fusion.
- Only the best ~1,400 characters go into the prompt, so they fit the model's 4k-token window, and the app only cites what the model actually read.
- If a question shares no words with the app's content, nothing is retrieved, and the app says it's answering from general knowledge instead of citing a wrong source.
- 44 real questions run as a unit test: 97% of the Portuguese ones find the right chunk in the top 4, and the test caught four bugs.
The Going deeper blocks are closed by default. Open them if you want the math and the code.
Here's where it ends up, on a real iPhone:
"What should water be like per SCA?" comes back with two sources from Ritus. The follow-up, "What is SCA?", pulls four, including two glossary terms.
The problem
A small model that has never seen the app
On-device means privacy (nothing leaves the phone), offline support and zero cost per token. It also means real constraints:
- Small: about 3B parameters, with a 4,096-token context window.
- Offline: it can't search the web.
- Not built for facts: Apple positions it for focused text tasks inside apps (summarizing, extracting, rewriting), not as a general-knowledge chatbot.
- It has never seen the app: not its recipes, grind guide or glossary.
And Ritus isn't short on content. Besides the app's own recipes, grind guide and glossary, it shares its educational content with my site, ritus.club: 52 pages on brewing, roasting, fermentation and more, in English and Portuguese. The Portuguese version alone is about 237k characters of text, and the English one is about the same size. The model just doesn't know any of it.
So ask it for the V60 recipe and it makes one up:
User: What's the right V60 recipe?
AI: Use 10 g of coffee for 700 ml of water at 100 °C, steep for 10 minutes… (totally wrong)
Fluent, confident, and not from the app. That's a hallucination: when a language model doesn't know something, it doesn't say "I don't know"; it generates the most plausible-sounding text.
Four ways to fix it, and only one fits
| Option | Why it falls short here |
|---|---|
| Use a bigger cloud model | Data leaves the phone, it costs money per token, and it still doesn't know your app. |
| Fine-tune the model | Slow and expensive, and it goes stale whenever the content changes. |
| Paste all the content into the prompt | 237k characters of content vs. a ~4k-token window: about 16× too big. |
| RAG: look up only the relevant parts | Needs good search. That's the rest of this post. |
Going deeper: why not fine-tuning
Fine-tuning means continuing to train the model on your own data so it "learns" the content. The analogy: RAG is an open-book exam; fine-tuning is studying for the exam. The student who memorized the book remembers it imprecisely, and if the book changes, they have to study again.
- Fine-tuning is good at teaching style and behavior ("always answer in short Markdown"). It's bad at making a model reliably recall exact facts like "18 g for 300 ml". It can still get them wrong, with the same confidence.
- Every content change means retraining. With RAG you regenerate a JSON file.
- The model answers "from memory", so you can't show where the answer came from.
You can combine both (fine-tune for style, RAG for facts). In Ritus, the system prompt already handles style, so fine-tuning isn't worth it.
Going deeper: why pasting everything doesn't fit
- 237k characters of Ritus site content in Portuguese alone, with about the same in English.
- ~4,096 tokens in the window (roughly 12–15k characters), shared by instructions, history, question and answer.
- 1,400 characters of retrieved context per question: what RAG actually sends.
The app needs to pick the right ~1,400 characters for each question. That's retrieval.
What RAG is
RAG = Retrieval-Augmented Generation: hand the model the right page before it answers.
- Retrieve: search your own content for the chunks that match the question.
- Augment: put those chunks into the prompt, next to the question.
- Generate: the model answers from what it just read, and the app shows the sources.
To retrieve, you first need something to search. So the full pipeline has four steps: one done once, and three done for every question.
ONCE EVERY QUESTION
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 0 Index │ → │ 1 Retrieve │ → │ 2 Augment │ → │ 3 Generate │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
Split the content Find the chunks Put those chunks The on-device
into chunks and that best match into the prompt model answers
prepare them for the question from them
search
Step 0: index the content
Split the content into chunks, one per section
The model only reads small pieces, so the content is cut before any search. Since ritus.club is my own site, I don't need to scrape anything: a build script reads the content straight from the site's code and writes one JSON file per language, which the app bundles.
- Cut at each heading, so every chunk holds one whole idea.
- Long sections are split by sentence, ~700 characters each.
- Ritus ends up with ~560 chunks from the site and the app.
Each chunk starts with its page and section title, so it still makes sense on its own.
Going deeper: the orphan chunk problem
A chunk on its own can lose its meaning. Take this sentence from the article on the Maillard reaction:
"It is a complex chemical process between amino acids and reducing sugars."
What is "it"? Neither the search nor the model can tell. Prefixing the page and section fixes it:
The Maillard Reaction in Coffee Roasting — The Chemistry Behind Flavor. The Maillard Reaction is a complex chemical process between amino acids and reducing sugars…
This is a lightweight version of what Anthropic calls contextual retrieval. The full version uses an LLM to write a context sentence for every chunk; here the page and section title do the job for free.
Going deeper: where the knowledge comes from
| Source | Count | Tapping a source opens… |
|---|---|---|
| App glossary | 58 terms | the glossary term |
| Curated recipes | 8 | the recipe |
| Grind guide ranges | 12 | the method in the grind guide |
| Grind troubleshooting (problem → fix) | 5 | the grind guide |
Site content (knowledge-<lang>.json) |
~484 (pt) / ~475 (en) | a text reader |
| "My Tasteprint" (the user's taste profile) | 1 | the Atlas screen |
- The app's own content is the source of truth. If the same thing exists on the site and in the app, only the app version is indexed. The chat never contradicts a screen, and the prompt budget isn't wasted on duplicates.
- "My Tasteprint" is RAG over the user's own data, still 100% local. "Which coffee suits me?" retrieves their profile.
Besides chunking, the app precomputes word statistics for BM25 and an embedding vector for every chunk, cached on disk. So each question only has to score, not prepare.
Step 1: retrieve the best chunks
Two ways to search: by words (BM25) and by meaning (embeddings). Then combine both.
BM25: keyword search, ranked
Lexical search: like Ctrl+F, but it scores every chunk and sorts the results.
- Matches the words of the question.
- Rare words weigh more: "Hoffmann" is in 4 of 558 chunks, "grind" in 101.
- Repeating a word helps, but less each time.
- Precise for exact terms like V60, TDS, Gesha.
Query "ideal grind for french press": the Grind Guide: French Press chunk wins, because "french" and "press" are rare and outweigh "grind". A V60 chunk that only shares "grind" scores under 25% of the best match and is dropped.
Weakness: no notion of synonyms. "Japanese pour-over method" shares no words with "V60".
Going deeper: normalizing the text
Before comparing words, both chunks and questions go through tokenization (lowercase, accents removed, only tokens with 3+ letters), stopword removal ("the", "how", "coffee", and small talk like "good morning") and a deliberately simple stemmer: tokens longer than 7 letters are cut to their first 7, so "extraction" and "extracted" both become "extract". Tokens with digits ("v60") stay whole.
static func tokenize(_ text: String) -> [String] {
text.lowercased()
.folding(options: .diacriticInsensitive, locale: nil)
.components(separatedBy: CharacterSet.alphanumerics.inverted)
.filter { $0.count >= 3 }
}
// Poor man's stemmer: keep the first 7 letters
static func stem(_ token: String) -> String {
guard token.count > 7, !token.contains(where: \.isNumber) else { return token }
return String(token.prefix(7))
}Going deeper: the BM25 math
Three ideas, two lines of math:
- Frequency saturates. Twice beats once, but 20 times isn't worth 20×.
- Rare words weigh more (IDF, inverse document frequency). In the English index, "Hoffmann" has IDF ≈ 4.8 and "grind" ≈ 1.7.
- Long chunks are penalized slightly, so a chunk doesn't win just by having more words.
// IDF: the rarer the term in the corpus, the higher its weight
let idf = log(1 + (n - df + 0.5) / (df + 0.5))
// BM25: saturating term frequency (k1 = 1.2), length normalization (b = 0.75)
let lengthNorm = k1 * (1 - b + b * Double(length) / averageLength)
score += idf * Double(tf) * (k1 + 1) / (Double(tf) + lengthNorm)n is the number of chunks, df how many chunks contain the term, tf how often it appears in this chunk, and length / averageLength the chunk's token count versus the average. k1 and b are the textbook defaults.
Embeddings turn meaning into coordinates
Semantic search: compare what texts mean, not which words they share.
- A model maps text to a vector: 512 numbers for Apple's sentence embedder.
- Similar meaning, nearby vectors, even with different words.
- Cosine similarity: 1 = same direction, 0 = unrelated.
- Strong on paraphrases, weaker on exact technical terms.
- Every vector is scaled to length 1 when the index is built, so comparing two of them is just multiplying and adding their numbers.
Think of a map of ideas: "French press", "prensa francesa" and "plunger coffee" sit together; "V60" is nearby; "latte art" is far away. And Apple's embedder runs on the device:
// Built into iOS: no server, no API key
let embedder = NLEmbedding.sentenceEmbedding(for: .english)
let vector = embedder?.vector(for: "grind for french press")
// -> 512 numbers. Similar meaning, nearby vectors.
// ~560 chunk vectors: computed once, cached on disk
let vectors = cachedVectors(for: chunks, embedder: embedder)
// The question is embedded off the main thread
actor QueryEmbedder { func vector(for text: String) -> [Float]? }Going deeper: iOS details
- The cache key is a SHA-256 of the content, the language and the OS's embedding model version. Vectorizing ~560 chunks takes seconds of CPU, right when the user opens the chat, so recomputing only when those change matters.
- Thread safety. An
NLEmbeddinginstance should only be used from one thread at a time, which is why the query embedder is anactor. - The Simulator turns embeddings off.
NLEmbeddingplus background vectorization corrupted the heap in the Simulator, so there the app uses lexical search only (#if targetEnvironment(simulator)). Demo on a real device.
Use both searches, but trust words more
Words and meaning fail on different questions, so combine them: hybrid search.
- Combine by rank, not raw score: the two scores use different scales.
- In Ritus, words count 65%, meaning 35%.
- No word in common? It retrieves nothing, so the app says "not in Ritus" instead of citing a wrong source.
| Chunk | Words rank | Meaning rank | Combined |
|---|---|---|---|
| A | 2nd | 1st | 1st |
| B | 1st | 3rd | 2nd |
| C | 3rd | 2nd | 3rd |
A wins by doing well in both lists, even though it isn't first for words.
Going deeper: Reciprocal Rank Fusion
BM25 scores are unbounded; cosine runs from −1 to 1. Adding them requires calibration that breaks easily. RRF combines positions instead: each list gives a document 1 / (k + rank), and you add them up. Ritus uses a weighted RRF with k = 60, and the full search goes like this:
- Small-talk guard. If no word of the question exists anywhere in the content ("good morning", "thanks"), return nothing immediately, without even computing the embedding.
- BM25 over every chunk. Candidates are the chunks sharing at least one term.
- Relative relevance floor. Drop candidates scoring under 25% of the best BM25 score.
- Semantic ranking, only among the candidates.
- Weighted RRF, then keep the top 4.
// No word in common? Retrieve nothing, not even by meaning.
guard snapshot.hasLexicalMatch(queryTokens) else { return [] }
// Drop weak matches: under 25% of the best BM25 score
lexical = lexical.filter { $0.score >= best * 0.25 }
// Weighted Reciprocal Rank Fusion, k = 60
var score = 0.65 / (60 + Double(lexicalRank)) // BM25
if let rank = semanticRank[candidate.index] {
score += 0.35 / (60 + Double(rank)) // embeddings
}
// -> sort by score, keep the top 4This is lexical search re-ranked by semantics. The semantic layer never brings in a chunk that shares no words with the question: a deliberate product decision. Why 0.65 / 0.35? Coffee is full of exact terms where words are more reliable than "approximate meaning"; semantics breaks close ties.
The search runs in a Task.detached on an immutable, Sendable snapshot of the index, so typing and sending never stall the UI.
Step 2: augment the prompt
Fit the best chunks, and only those, into a small context window.
- Budget: add chunks by relevance until the limit.
- Delimit the context from the question with clear headers.
- Instruct the model for the no-context case.
- Cite only what the model actually read.
PREVIOUS CONVERSATION: <- last 2 exchanges
User: Hoffmann's V60 recipe?
Ritus: ## V60 James Hoffmann…
---
CONTEXT: <- retrieved chunks
• V60 James Hoffmann 300mL by James Hoffmann.
18g of coffee to 300ml of water…
---
Question: and for two cups?
The sources shown are only the chunks that made it into the prompt:
private static let contextBudget = 1400 // characters
static func contextChunks(for hits: [KnowledgeHit]) -> [KnowledgeChunk] {
var used = 0
var included: [KnowledgeChunk] = []
for hit in hits {
let cost = hit.chunk.text.count + 4
if used + cost > contextBudget, !included.isEmpty { break }
included.append(hit.chunk)
used += cost
}
return included // these, and only these, become sources
}1,400 characters leaves room for instructions, history and a 1,200-token answer. The top hit always gets in, even if it alone is over budget.
Going deeper: the system prompt
Fixed instructions tell the model to answer in the user's language, be direct, use Markdown for recipes, treat PREVIOUS CONVERSATION as conversational context only (not verified facts), and:
Questions WITHOUT a CONTEXT block have no verified excerpt from the app. Make it clear you're answering from general knowledge before answering. Never present a guess as a fact from the app.
The UI also shows a badge in that case: "Answer from the Apple Intelligence model, not based on Ritus content."
Going deeper: a fresh session per question
Letting the conversation grow inside one LanguageModelSession would keep every past CONTEXT block in the transcript until the 4k window overflows, and make every turn slower. Instead, each reply uses a new session with the last 2 exchanges (280 characters per message) in the prompt. The tenth question is as fast as the first.
"And for two cups?" has no topic of its own, so if retrieval returns nothing, it's retried with the previous question prepended, and the answer stays grounded.
Step 3: generate the answer
Apple's on-device model writes the reply and streams it to the screen:
for try await partial in session.streamResponse(to: prompt) {
pending = partial.content // full text so far
if ContinuousClock.now - lastFlush >= .milliseconds(50) {
messages[index].text = partial.content // ~20 updates/s
lastFlush = .now
}
}- Each partial is the full text so far, not a delta.
- Updating SwiftUI on every token would stall the UI, so updates are batched every 50 ms.
- Sources are attached only after the reply finishes, so citation cards don't flash under a half-written answer.
No Apple Intelligence? Search is the product
switch SystemLanguageModel.default.availability {
case .available:
availability = .chat
case .unavailable(.deviceNotEligible):
availability = .smartSearchOnly(reason: "This device isn't compatible with Apple Intelligence.")
case .unavailable(.appleIntelligenceNotEnabled):
availability = .smartSearchOnly(reason: "Turn on Apple Intelligence in Settings.")
case .unavailable(.modelNotReady):
availability = .smartSearchOnly(reason: "The model is still being prepared on this device.")
default:
availability = .smartSearchOnly(reason: "Apple Intelligence is unavailable.")
}Without Apple Intelligence, the Lab doesn't break or show an error: it becomes smart search over the same index, with results as cards that open the real screens. Search is the product; the LLM is the conversation layer.
Smart search on the Simulator, in Portuguese. "V60" and "bloom" each return four sources from the articles, the grind guide and the glossary, with no model involved.
Going deeper: robustness
- If a context-augmented reply fails before producing anything, it retries once without the context (and marks the answer as general knowledge).
- If the context window overflows (
exceededContextWindowSize), it opens a fresh session and asks the user to repeat. - If generation fails repeatedly, or the model assets are missing, it switches to smart-search mode.
All three steps in one function
ChatSession.send(_:), simplified:
// 1. Retrieve
var hits = await knowledgeBase.search(question)
if hits.isEmpty, let previous { // follow-up question
hits = await knowledgeBase.search(previous + " " + question)
}
let context = Self.contextChunks(for: hits)
// 2. Augment
let prompt = augmentedPrompt(question: question,
history: history,
context: context)
// 3. Generate
let stream = session.streamResponse(to: prompt)Does it work? I test it
An eval is a fixed set of questions with known answers, run again after every change. Without one, every tweak is a guess.
- Write 44 real questions, each with the chunk that answers it. For example:
"ideal grind for french press"→ Grind Guide: French Press. - Run the search for every question.
- Count how often the right chunk is in the top 4 results.
| recall@4 | recall@2 | |
|---|---|---|
| First version (old knowledge base, 25 questions) | 0.92 | 0.80 |
| Current, Portuguese (29 questions) | 0.97 | 0.90 |
| Current, English (15 questions) | 0.87 | 0.80 |
recall@4 is how often the right chunk appears among the top 4 results. The eval also tracks recall@2, because the top 2 chunks almost always fit the 1,400-character budget. It runs as a regular unit test:
// coffee-toolsTests/RAGRetrievalEvalTests.swift
private static let casesPT: [(String, [String])] = [
("moagem ideal pra prensa francesa", ["grind-Prensa Francesa"]),
("o que é TDS?", ["glossary-tds"]),
("o que é maceração carbônica", ["fermentations-"]),
// … 29 Portuguese + 15 English questions
]
@Test func retrievalRecallPT() async {
let recall = await evaluate(Self.casesPT, language: .pt)
#expect(recall.top4 >= 0.9)
#expect(recall.top2 >= 0.8)
}Expected IDs are prefixes, so any chunk from the right page counts. It also checks that small talk retrieves nothing and that chunk IDs are unique. Since it runs on the Simulator, where embeddings are off, it measures the lexical engine, the part that gates everything else.
The four bugs the eval caught
All from the first version, all invisible in day-to-day use:
- Duplicate IDs. Site copies of the app's recipes shared their IDs, so one recipe showed up twice in the top 4.
- A filter that never filtered. On iPhone, the semantic score alone passed the old absolute relevance threshold. That's why the floor is now relative and lexical.
- Sources the model never read. Source cards listed chunks that didn't fit in the prompt.
- Wrong language. The grind troubleshooting article was in English inside the Portuguese base, so "my coffee is bitter" found nothing.
Known limits and next steps
What's still not great:
- 3 eval questions still miss. 7-letter stemming is weak in English ("store" vs. "storage"), and "my coffee tastes bitter" falls outside the top 4.
- Semantics can't rescue a question with no shared word, by design.
- NLEmbedding's sentence embedder is older.
NLContextualEmbedding(iOS 17+) is transformer-based and multilingual. - Chat needs Apple Intelligence (iPhone 15 Pro or newer); other devices get smart search only.
What I want to try next: swapping in NLContextualEmbedding, a real stemmer per language (or a coffee synonym list), tool calling so the model decides when to search ("agentic RAG"), full contextual retrieval with an LLM writing a context line per chunk at build time, and growing the eval with real user questions.
Questions people ask
Why on-device and not OpenAI or Claude? Privacy (nothing leaves the device), offline support, zero cost per user, no network latency. The trade-off is a smaller model and Apple Intelligence devices only.
Why not a vector database? There are ~560 chunks. Searching all of them in memory takes under a millisecond. Vector databases solve millions of documents; here they'd only add complexity. The real bottleneck is the LLM generating text.
Why combine by rank instead of adding the scores? The scores live on different scales. Adding them needs calibration; RRF only uses positions. Adding scores is exactly what broke the relevance filter in the first version.
Why not let the model search with tool calling?
A searchKnowledge tool calling the same search() is still RAG, just triggered by the model. But for a 3B model with a 4k window, always retrieving is the safer choice: it's predictable and directly measured by the eval, it costs only the chunks in the window, and it avoids the main risk of tool calling, a small model not calling the tool and hallucinating anyway.
Is this "real" RAG? Yes: retrieve → augment → generate, with BM25, embeddings, rank fusion, structure-based chunking and an eval, at app scale and 100% on-device. What's missing is LLM re-ranking, on purpose: an extra on-device call would cost seconds.
Final thoughts
The whole pipeline, as built in the app:
Chunk by structure → Search by words + meaning → Combine by rank → Prompt within a budget → Generate on device → Measure with evals
Most of this is plain search engineering. The model is the easy bit. Getting the right 1,400 characters in front of it, and knowing that you did, is the actual work.
RAG isn't a bigger model. It's giving the model the right page at the right time.
References
- Apple, TN3193: Managing the on-device foundation model's context window
- Apple, Apple Intelligence Foundation Language Models Tech Report 2025
- Anthropic, Introducing Contextual Retrieval
- Robertson & Zaragoza, The Probabilistic Relevance Framework: BM25 and Beyond (2009)
- Cormack, Clarke & Buettcher, Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods (SIGIR 2009)