DEV Community

Yusuf İhsan Görgel
Yusuf İhsan Görgel

Posted on

You don't need a vector database

I wanted a Flutter app to answer questions over a user's own notes without any of it leaving the phone. No server, no API key, no document upload. On a plane, on a locked-down work laptop, it should still work.

The full-stack packages get you there. flutter_gemma runs a small model like Gemma on-device, and ai_edge_rag builds on Google's MediaPipe on-device stack. If you want on-device retrieval working this afternoon, use one of those and stop reading.

I kept going because I wanted to know what "RAG" actually is once you take the abstraction away. On a phone you account for every millisecond and every megabyte yourself, and a black box that hides both is exactly the thing you cannot afford there. So I built the pipeline out of small Dart pieces and watched each stage.

Full disclosure before we start: I wrote the three packages I lean on below. None of the ideas depend on them, and I point back at the full-stack alternatives at the end.

The on-device RAG pipeline: on the way in, split each document into chunks, turn each chunk into a vector, and pack the vectors into one matrix. On the way out, turn the question into a vector, find the nearest chunks with a top-k search, and paste them into the model prompt.

What RAG actually is

Retrieval-augmented generation is two verbs. Retrieve the few pieces of the user's own text that are most relevant to their question, then generate an answer with those pieces pasted into the prompt. The model stays general, and the knowledge is whatever you retrieved.

The "vector database" that every tutorial reaches for is, at the scale one app holds on one device, a single matrix and a dot product. A thousand notes is not a database problem. It is arithmetic, and Dart is good at arithmetic.

One split matters before the stages. You build the index once, and you answer every question against it. Building means you chunk the documents and embed them. Answering means you embed the question and search. The answering path is the one that has to run on the phone with nothing leaving it, and it is pure Dart. Building can run wherever is convenient: on the phone at first launch, or on your own machine when the corpus is fixed and you ship the index with the app.

Stage one: chunk by meaning, budget by tokens

You cannot embed a whole document as one vector and hope for a useful match, and you cannot fit a whole document into the prompt. So you split. The naive split is by character count, which cheerfully cuts words and sentences in half. Splitting on paragraph boundaries keeps each chunk about one idea.

final chunker = Chunker.paragraphs(maxChars: 800);
Enter fullscreen mode Exit fullscreen mode

Character count is a rough ruler, though. The budget the model enforces is tokens, and only the model's own tokenizer knows the real count. "The quick brown fox" is four words and a different number of tokens for every tokenizer. So the honest version splits on paragraphs first, then re-splits any chunk whose real token count is over budget:

final tok = Tokenizer.fromFile('assets/tokenizer.json');
for (final c in chunker.chunk(doc)) {
  if (tok.encode(c.text).length > maxTokens) {
    // this chunk overflows the model's real budget: split it further
  }
}
Enter fullscreen mode Exit fullscreen mode

That exact count comes from hf_tokenizers, which wraps the Rust tokenizers crate over FFI and loads the same tokenizer.json the model ships with. One caveat, stated plainly: it is a native package that runs on desktop and server Dart today, not yet on Android or iOS. That lands where index-building usually lives, since you often prepare the index off the phone anyway. If you must build the index on the phone itself, chunk by paragraph and let the embedder's own tokenizer enforce the budget instead.

Stage two: embeddings turn text into geometry

An embedding is a function that turns a piece of text into a vector of numbers, arranged so that text with similar meaning lands close together in space. "annual leave" and "vacation days" share almost no letters and come out as near-identical vectors. That closeness is the whole trick behind retrieval.

Words placed in space by meaning:

Nothing in my pipeline generates embeddings. That is the one heavy piece, and it belongs to a model: MediaPipe Text Embedder on device, or whatever you already run. On a phone the embedder is the largest thing resident besides the model itself, so its size and speed set your real budget more than any of the code here. rag_kit takes a single function that turns a batch of strings into vectors and stays out of the way of how you produce them.

final retriever = Retriever(
  embedder: myEmbedder, // batch of texts in, vectors out
  store: InMemoryVectorStore(),
  chunker: Chunker.paragraphs(),
);
Enter fullscreen mode Exit fullscreen mode

Keeping the embedder as a plain function you pass in lets you hand it a fake in a test and exercise the whole pipeline with no model at all.

Stage three: the search is a dot product

To find the chunks nearest to a question, you compare the question's vector against every stored vector and keep the closest few. The standard measure is cosine similarity, the cosine of the angle between two vectors. Same direction scores near one, unrelated scores near zero. It ignores length and looks only at orientation, which is right when the direction is the meaning.

Cosine similarity is the angle between two vectors. The query and a matching chunk point almost the same way, so the angle is small and the cosine is close to one. An unrelated chunk points elsewhere, a wide angle and a cosine near zero.

Cosine between two normalized vectors is just their dot product, and a dot product is a multiply-and-add down two lists. The obvious implementation is a double loop. It works, and it is also the slowest thing you will write, because it fights the machine on every row.

The fix is not a cleverer algorithm. It is memory layout. If you store each vector as its own List<double>, the CPU chases a pointer to a fresh heap object for every row and reads eight bytes per component. If instead you pack every vector end to end into one contiguous Float32List, the whole corpus is one flat buffer the CPU can stream, four bytes per component, and read four lanes at a time through Dart's SIMD Float32x4 types.

Memory layout decides the speed. On the left, one List of double per row: each row is a separate heap object reached through a pointer, eight bytes per number. On the right, one packed Float32List: every row lies end to end in a single buffer, four bytes per number, read four lanes at a time.

That is the difference between the slow version and the fast one, and it is what vector_kit does under VectorMatrix. It caches each row's length when the row is added, so topKCosine never has to normalize by hand at query time.

final index = VectorMatrix(384);
for (final v in chunkVectors) {
  index.add(v); // packed in, L2 norm cached
}
final hits = index.topKCosine(questionVector, 5);
Enter fullscreen mode Exit fullscreen mode

On the demo corpus, twenty thousand chunks of 384 dimensions, a top-5 query lands in 1.4 ms against 15.9 ms for the honest hand-written cosine loop over the same data. Eleven times, from nothing but layout and a bounded min-heap instead of sorting all twenty thousand scores. Measure it on your own hardware before you trust the number; the absolute figure moves with the device.

Stage four: the megabytes you forgot to count

Speed is what people benchmark. Memory is what actually kills the app on a phone. Twenty thousand vectors of 384 dimensions at four bytes each is 29.3 MB sitting resident, and it sits there next to an on-device language model that already wants most of the RAM the phone will give you. Two hungry things in one address space is how you get the low-memory killer.

The lever is precision. Each embedding component is a small number that does not need a full 32-bit float. Store one per-vector scale, the largest absolute component divided by 127, and quantize every component to a signed byte against it. Rank order survives, because the 255 levels of a signed byte are plenty to preserve the ordering. The same index drops from 29.3 MB to 7.6 MB, close to a quarter, with full recall on the demo's data.

The same 20,000 by 384 index stored two ways. As float32 it holds 29.3 megabytes resident. Quantized to int8 it holds 7.6 megabytes, roughly a quarter, small enough to sit next to an on-device model.

final quantized = QuantizedMatrix.from(index);
final hits = quantized.topKCosine(questionVector, 5);
Enter fullscreen mode Exit fullscreen mode

Nothing is free. Quantization introduces rounding error, and on some data it will nudge the ranking. Full recall on one demo corpus is not a promise about yours, so you measure recall against the float version before you ship it. But a near four-times cut in resident memory is often the difference between a feature that runs beside the model and one that does not.

Putting it together

Once the pieces exist, the pipeline is short. rag_kit wires chunking, your embedder, the store, and context building into one object.

await retriever.addText(handbook, sourceId: 'handbook');

final context = await retriever.buildContext(
  'how do I request leave?',
  maxChars: 4000,
);
// context is now ready to paste into the model prompt
Enter fullscreen mode Exit fullscreen mode

Chunk and embed on the way in, search and paste on the way out. At query time no network call happens, and the user's notes never leave the device.

When to build from primitives, and when not to

If you want on-device RAG working today and you are happy to take the whole stack as given, use flutter_gemma or ai_edge_rag. They carry the model, the embedder, and the store together, and that is the right choice more often than not.

Build from the primitives when you need to own a stage: your own embedding model, your own memory budget, your own chunking rules for the shape of your documents. Or when you want to understand what you shipped, because on a device you are accountable for what it costs in time and RAM, and you cannot account for a box you cannot see into.

Know the ceiling too. Brute force over a packed matrix is the right answer up to roughly a hundred thousand vectors. Past that you want a real approximate-nearest-neighbor index like HNSW, and a different set of tradeoffs. On a phone, holding one user's own data, you are almost never past that ceiling.

So there was no vector database to install. At one device's scale the retrieval everyone treats as infrastructure is a matrix and a dot product, running in pure Dart with the user's notes never leaving the phone. The chunker and your existing embedder are the rest of it.


The Dart pieces above: hf_tokenizers for exact token counts (desktop and server today), vector_kit for the packed-matrix search and int8 quantization, and rag_kit for the pipeline wiring. vector_kit and rag_kit are pure Dart with no runtime dependencies; hf_tokenizers wraps the Rust tokenizers crate over FFI and ships a native library.

Top comments (0)