A recipe assistant that answers from your documents, with the network switched off at every step. It embeds a corpus with EmbeddingGemma, stores the vectors in sqlite-vec, retrieves the relevant ones for each question, and hands them to the on-device model as context.
By the end you will have an app that:
The code is short. What takes the hour is the handful of decisions RAG asks you to make, and each step here is built around one of them:
seq256 is notList — three answers, only one of which is speedflutter_gemma_rag_sqlite 1.4.0 requires sqlite3 3.6.0, whose build toolchain wants meta ^1.19.0, and every Flutter 3.44.x pins meta to 1.18.0 exactly. On 3.44 the Step 3 app will not resolve--dart-define=HF_TOKEN=hf_... covers bothWatch out: This codelab continues Getting Started with On-Device LLMs. Its finished app is this one's starter, byte for byte — a CI check enforces it. If you have not done that codelab, step_01_starter still runs on its own; you will just be meeting the download-and-chat code for the first time.
Every step of this codelab exists as a complete, runnable app, so you can join at any point or check your work against the next one.
git clone --depth 1 https://github.com/DenisovAV/flutter_gemma.git
cd flutter_gemma/codelabs/on-device-rag-flutter-gemma
ls
step_01_starter/ the chat app you start from
step_02_embed/ after Step 2 — the corpus, embedded
step_03_store/ after Step 3 — a real vector store
step_04_filters/ after Step 4 — filtered search
step_05_grounded/ after Step 5 — answers with sources
complete/ after Step 6 — the finished app
Open step_01_starter and run it.
cd step_01_starter
flutter run --dart-define=HF_TOKEN=hf_your_token
You get the finished app from Getting Started: it downloads a model once, then chats with it offline. Nothing in it knows anything about your documents — ask it about a recipe and you get whatever was in the weights.
That is the gap this codelab closes. RAG — retrieval-augmented generation — is three moving parts, and the model is only the last one:
Steps 2 to 5 build exactly those three, in order.
lib/recipes.dart is twelve recipes, in plain Dart — no asset bundle, no network. A corpus you can read in one screen beats one you have to go fetch.
class Recipe {
final String id;
final String title;
final String text;
final String cuisine;
final int minutes;
final bool vegetarian;
}
The three fields after text are not decoration. Step 4 turns them into filters, and between them they cover every condition the API has: a string, a number and a bool.
Good to know: Only text is embedded. The vector is built from that string and nothing else, so anything you want the search to match on has to be in it — a title that lives only in a field beside it is invisible to retrieval.
flutter pub add flutter_gemma_embeddings
One package, and it is not an engine. flutter_gemma_litertlm — already in the app — supplies LiteRtEmbeddingBackend, the thing that runs the forward pass. flutter_gemma_embeddings supplies the tokenizers. They are separate on purpose, and Step 2's whole registration hinges on why.
In lib/main.dart:
await FlutterGemma.initialize(
inferenceEngines: [LiteRtLmEngine()],
embeddingBackends: [LiteRtEmbeddingBackend()],
embeddingTokenizers: [GemmaEmbeddingTokenizers()],
// ...
);
Two lines, two packages, and the reason matters.
Which tokenizer an embedding model needs is a property of the model, not of the engine that runs it: EmbeddingGemma wants SentencePiece whether LiteRT or ONNX Runtime executes it, and a BERT-family model wants WordPiece under either. So since flutter_gemma 1.9.0 the backend no longer carries one, and the app says which families it has.
Watch out: Leave embeddingTokenizers out and the first embedding throws a StateError naming the package to add. That is the design working: the alternative — falling back to one family and tokenizing with the wrong convention — returns vectors that are quietly the wrong point in the embedding space, and no test downstream can tell them from good ones.
static const embeddingGemma = EmbedderChoice(
modelUrl: '.../embeddinggemma-300M_seq256_mixed-precision.tflite',
tokenizerUrl: '.../sentencepiece.model',
sizeLabel: '0.2 GB',
requiresToken: true,
);
Two files, and both are required: the .tflite holds the weights, sentencepiece.model turns text into the ids those weights expect. There is no tokenizer baked into the graph — hand over only the first and the install fails.
Good to know: seq256 is the sequence length in tokens, not the embedding dimension. The vectors are 768 long either way; 256 is how much text fits into one forward pass before it is truncated. Every recipe here is comfortably shorter.
await FlutterGemma.installEmbedder()
.modelFromNetwork(e.modelUrl, token: hfToken)
.tokenizerFromNetwork(e.tokenizerUrl, token: hfToken)
.withModelProgress((p) => setState(() => _installProgress = p / 100))
.install();
final embedder = await FlutterGemma.getActiveEmbedder();
final vectors = await embedder.generateEmbeddings(
kRecipes.map((r) => r.text).toList(),
taskType: TaskType.retrievalDocument,
);
install() is idempotent — the bytes are fetched once, and the second run of that button skips straight past the download. Progress is a link in the builder chain, not an argument to install().
One call for the whole corpus rather than a loop: the worker isolate is set up once and the model stays resident between texts.
taskType is the part worth slowing down for. EmbeddingGemma was trained with a different prefix for documents than for queries, and the enum is where that lives:
TaskType.retrievalDocument -> 'title: none | text: '
TaskType.retrievalQuery -> 'task: search result | query: '
Index your corpus with the query prefix and nothing errors. The vectors simply land slightly off, every search afterwards is a little worse, and no exception will ever point at it.
Watch out: This is the one place in the codelab you have to get right by hand. From Step 3 on, searchSimilar(query:) embeds the query for you and uses retrievalQuery by default — so the two halves stay matched as long as you index with retrievalDocument.
lib/embed_page.dart is a new screen — the full file is in step_02_embed, and the parts that matter are above. Two small changes put it in reach.
The Hugging Face token was a private constant in main.dart. Both the model download and the embedder install need it now, and they live on different pages, so it moves to lib/model.dart where both already import from:
// lib/model.dart
const hfToken = String.fromEnvironment('HF_TOKEN');
Then give the chat screen a way in — an action in its app bar:
// lib/chat_page.dart
import 'embed_page.dart';
// ...in the AppBar's actions, before the delete button:
IconButton(
tooltip: 'Recipes',
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => const EmbedPage(hfToken: hfToken),
),
),
icon: const Icon(Icons.restaurant_menu),
),
Skip this unless you are running in Chrome — but do not skip it and then run in Chrome, because the Embed button is the first thing that fails.
Embedding in a browser runs through LiteRT.js, which ships as four files in flutter_gemma_litertlm/web/. Copy them into your own web/, the same way cache_api.js was copied in Getting Started:
litert.js litert_embeddings.js sentencepiece.js tensorflow.js
Then load the entry point from web/index.html:
<script type="module" src="litert_embeddings.js"></script>
That is all of it — the WASM runtime underneath is fetched from a CDN, so there is nothing else to host.
Tap the recipes icon in the app bar, then Embed the corpus. After the download you get twelve green ticks and, at the bottom, what an embedding actually is:
768 dimensions
[-0.0147, 0.0412, -0.0038, 0.0221, 0.0095, -0.0176, ...]
That is the whole representation. Two recipes are "similar" when those two lists of numbers point in a similar direction — which is all a vector search ever computes.
Leave the page and come back. The ticks are gone. The vectors lived in a Map in the widget, and that is what Step 3 is about.
A Map and a hand-written cosine loop is where most RAG tutorials stop, and it does work — on twelve documents, once. Three things are wrong with it, and a bigger Map fixes none of them:
double is a float64, so one 768-dimension vector is 768 × 8 = 6 KB — not the 3 KB the model emits. Ten thousand documents is 60 MB of Dart heap, sitting beside an LLM that already wants a gigabyte or two.A vector store fixes all three for the same reason: the vectors stop being a Dart object and become rows in a database that knows they are vectors.
flutter_gemma ships two, behind one interface. This codelab uses sqlite-vec, and the table says why — but the code from here on is written against VectorStoreRepository, so swapping is one line either way.
|
| |
Platforms | all six, including web | five — no web |
Search | exact KNN, always | exact below 10 000 points, approximate (HNSW) above |
Scaling | brute force, linear in N | wins on large corpora |
| no-op on native, drains IndexedDB on web | required — points stay in memory until it is called |
Schema timing | at table creation — a new filter field means re-creating and re-indexing | at write time — declare and re-index |
Field names |
| free-form UTF-8, no |
Good to know: The precision row surprises people. qdrant's fullScanThreshold defaults to 10 000 points — below that it does a full scan and is exactly as precise as sqlite-vec. For a corpus that fits on a phone you are usually not choosing between exact and approximate at all; you are choosing between "runs in Chrome" and "grows better".
The last row is the one that quietly decides the others: the portable set is sqlite's. If you might ever switch backends, stay inside it — which is why this codelab's fields are named cuisine, minutes and vegetarian and not prep-time.
flutter pub add flutter_gemma_rag_sqlite path_provider
await FlutterGemma.initialize(
// ...
vectorStore: kIsWeb ? WebSqliteVectorStore() : SqliteVectorStore(),
);
Two classes, one per platform arm. The native one is sqlite3 over dart:ffi with the vec0 extension loaded; the web one is the same SQLite compiled to WASM with vec0 linked in, keeping its pages in IndexedDB. Both implement the same interface, which is what makes everything after this line platform-independent.
One more file, and it needs no tag. Copy the store's SQLite build from flutter_gemma_rag_sqlite/web/rag/:
web/rag/sqlite3.wasm
WebSqliteVectorStore fetches it by that exact relative path. It is a SQLite compiled with sqlite-vec linked in — which is why it comes from the package rather than a CDN, and why the store is a package rather than a few lines of SQL.
lib/rag_store.dart holds all three:
static Future<String> databasePath() async {
const name = 'recipes.db';
if (kIsWeb) return name;
final dir = await getApplicationDocumentsDirectory();
return '${dir.path}/$name';
}
await FlutterGemma.rag.initialize(await databasePath());
sqlite-vec wants a .db file path. On web there is no file system to put one on — the store registers an IndexedDB-backed VFS under that name instead, so the bare name is the whole path.
Writing a row takes the vector you already have:
await FlutterGemma.rag.addDocumentWithEmbedding(
id: r.id,
content: r.text,
embedding: vectors[i],
metadata: jsonEncode({
'title': r.title, 'cuisine': r.cuisine,
'minutes': r.minutes, 'vegetarian': r.vegetarian,
}),
);
Good to know: There is also addDocument(content:), which embeds for you — one document per call. Fine for one, wasteful for twelve, because each call sets the embedding worker up again. id is what a search result hands back, so it has to stay stable across re-indexes.
And searching takes text, not a vector:
FlutterGemma.rag.searchSimilar(
query: query,
topK: 3,
threshold: 0.3,
);
The query is embedded for you, with retrievalQuery — the other half of Step 2's asymmetry, handled.
Watch out: threshold is not optional in spirit. Cosine similarity runs 0.0 to 1.0, and without a threshold a search always returns topK rows however bad they are — which reads as "found something" to every caller downstream, including the model in Step 5.
lib/embed_page.dart changes shape with the store under it, and the full file is in step_03_store. Step 2's screen embedded and showed you a vector; this one opens the store on initState, reports what is already in it, and adds a search field:
@override
void initState() {
super.initState();
_open(); // RagStore.open() — the row count comes from a past run
}
The header now reads 12 rows · 768 dimensions rather than counting ticks, which is the whole difference: those numbers come from disk, not from this session.
Index once, then search for something warm with beans. Ribollita comes first, at around 0.6 — and the word "warm" appears nowhere in it. That is the difference between semantic search and LIKE '%warm%'.
Now kill the app and reopen the recipes page. The header still says 12 rows · 768 dimensions, and searching works without embedding anything again. The index is on disk.
Search finds things by meaning. "Italian, under half an hour, no meat" is not a question about meaning — it is three predicates, and they belong inside the query rather than around it.
await FlutterGemma.initialize(
// ...
filterSchema: const FilterSchema(
fields: [
FilterField(name: 'cuisine', type: FilterFieldType.string),
FilterField(name: 'minutes', type: FilterFieldType.number),
FilterField(name: 'vegetarian', type: FilterFieldType.bool),
],
),
);
FilterFieldType has exactly three values, and the corpus uses all three.
This is threaded to the store before its own initialize(), and that timing is the whole point on sqlite-vec: each declared field becomes a real typed vec0 column, and vec0 has no ALTER. Adding a filter field later means re-creating the table and re-indexing the corpus.
Watch out: A Filter over a field that was never declared is silently dropped. The search comes back unfiltered — no error, no log in a release build, just more results than you asked for. Declare what you might filter on, not only what you filter on today.
Filter? buildFilter({
Set<String> cuisines = const {},
int? maxMinutes,
bool vegetarianOnly = false,
}) {
final must = <Condition>[
if (cuisines.isNotEmpty)
FieldMatchAny(key: 'cuisine', values: cuisines.toList()),
if (maxMinutes != null)
FieldRange(key: 'minutes', lte: maxMinutes.toDouble()),
if (vegetarianOnly) const FieldEquals(key: 'vegetarian', value: true),
];
return must.isEmpty ? null : Filter(must: must);
}
Three conditions, which is all of them:
FieldMatchAny — metadata[key] in values. Set membership, so "italian or greek" is one condition rather than two ORed together.FieldRange — inclusive gte / lte, either of which may be null.FieldEquals — one value, any of the three field types.They combine by where they sit: must is AND, should is OR, mustNot is NOT. An empty Filter is not "match nothing" — the store checks isEmpty and skips filtering entirely.
FlutterGemma.rag.searchSimilar(
query: query,
topK: 3,
threshold: 0.3,
filter: filter,
);
Good to know: The filter is applied inside the store, as part of the same query that ranks by distance. That is what makes it different from filtering the results afterwards: topK still means three, and all three cleared the predicates.
One control per declared field, above the results — the full widget is in step_04_filters:
for (final c in const ['italian', 'greek', 'indian', 'japanese'])
FilterChip(
label: Text(c),
selected: _cuisines.contains(c),
onSelected: (on) =>
setState(() => on ? _cuisines.add(c) : _cuisines.remove(c)),
),
plus one for under 30 min and one for vegetarian, which set _maxMinutes and _vegetarianOnly. Those three pieces of state are exactly what buildFilter above takes.
Search for something warm with beans with no filters: ribollita, then gigantes plaki. Now tick under 30 min and search again — both are gone, and what comes back is whatever bean-adjacent recipe is quick. Tick japanese and the corpus has no beans at all in that cuisine, so you get nothing: an empty result, not a bad one.
Retrieval on its own gives a list. Grounding is handing that list to the model and constraining it to answer from there.
final hits = await RagStore.search(text);
final prompt = hits.isEmpty
? text
: '''
Answer the question using only the recipes below. If they do not contain the
answer, say so rather than inventing one.
${hits.map((h) => '- ${RagStore.recipeFor(h)?.title}: ${h.content}').join('\n')}
Question: $text''';
await chat.addQueryChunk(Message.text(text: prompt, isUser: true));
The model never sees the vector store. It sees text — the retrieved recipes, and the question, in one turn.
Good to know: A search that returns nothing is not an error and not a reason to stop. The model still answers, from its own weights; the UI says so by showing no sources under the reply. Refusing to answer at all would be a worse app than one that is occasionally ungrounded and visibly says which.
if (_sources.isNotEmpty)
Wrap(children: [
for (final h in _sources)
Chip(label: Text('${RagStore.recipeFor(h)?.title} · '
'${h.similarity.toStringAsFixed(2)}')),
]),
A grounded answer the user cannot check is not obviously better than an ungrounded one. The chips are the difference between "trust me" and "here is where this came from, and how close it was".
Ask what can I make with beans that isn't Italian? The sources strip shows gigantes plaki, and the answer talks about it rather than about ribollita. Ask something the corpus has no answer to — how do I make sourdough? — and watch the model say so instead of inventing one, because the prompt told it to and because no recipe cleared the threshold.
The index is on disk from Step 3, but the app only opens the store when you visit the recipes page. Move it to startup, so the very first question can be grounded:
await FlutterGemma.initialize(/* ... */);
await RagStore.open();
runApp(const QuickstartApp());
flush() is the one call whose meaning changes with the store underneath it:
xSync is a documented no-op, so without this the last batch can still be in flight when the tab goes away.Which is why RagStore.index() calls it unconditionally rather than behind if (kIsWeb): it costs nothing where it is a no-op, and it is the difference between a saved index and a lost one everywhere else.
Watch out: close() persists too, but it reports differently: on qdrant-edge a failed save is only logged, while flush() throws it. If you want to know that the index was saved, flush.
Index the corpus, then turn the network off — airplane mode, or your Wi-Fi switch. Ask the app anything about the recipes.
The download is the only thing that ever needed a connection. Embedding, search, filtering and generation all run on the device, which is the claim this codelab set out to make good on.
Three parts, all local: a model that turns text into vectors, a database that knows those vectors are vectors, and a prompt that keeps the answer honest.
vectorStore: QdrantVectorStore() — and the rest of the code is unchanged, which is the interface earning its keep. You give up the web and gain HNSW on large corpora.recipes.dart. Twelve constants become a folder of Markdown, and the only real design decision is what goes into text versus what becomes a filter field.flush() table