rag_kit is a small on-device retrieval toolkit for Dart and Flutter. Its InMemoryVectorStore holds Document values and answers similarity searches over their embeddings. Version 0.3.1, published today, fixes an aliasing bug in how that store kept each document's metadata map.
The API in play
The public shape of a Document:
class Document {
Document({
required this.id,
required this.text,
required this.embedding,
this.metadata = const {},
});
final String id;
final String text;
final List<double> embedding;
final Map<String, Object?> metadata;
}
InMemoryVectorStore exposes two methods this post uses: Future<void> upsert(List<Document> documents) to add or replace documents, and Future<List<ScoredChunk>> search(List<double> query, {int topK = 5, double? minScore, bool Function(Document)? where}) to query. Each ScoredChunk in the result has a document and a score.
The bug
InMemoryVectorStore built each stored entry by carrying the incoming document's fields across, including the metadata map:
metadata: document.metadata,
That stored the same Map instance the caller passed to upsert. The store held a reference to the caller's map. Two things follow from that.
First, editing the caller's own map after upsert returns changes what the store holds. There is no second call into the store. The write goes through the caller's reference, and the store observes it because it is the same object.
final store = InMemoryVectorStore();
final meta = <String, Object?>{'status': 'draft'};
await store.upsert([
Document(id: 'a', text: 'hello', embedding: [1, 0], metadata: meta),
]);
// The caller edits its own map, long after the upsert call returned.
meta['status'] = 'published';
final hits = await store.search([1, 0]);
print(hits.single.document.metadata['status']);
// rag_kit 0.3.0: prints "published". The later edit reached the store.
// rag_kit 0.3.1: prints "draft". The store kept its own copy.
Second, search returns the stored Document, and its metadata is that same map. A caller that reads a result and writes to its metadata is writing into the store. In 0.3.0 a search result is a live handle into the store's internal state.
final hits = await store.search([1, 0]);
hits.single.document.metadata['status'] = 'archived';
// rag_kit 0.3.0: silently rewrote the stored document.
// rag_kit 0.3.1: throws UnsupportedError (the returned map is unmodifiable).
The failure in 0.3.0 is silent. Nothing throws; the store's data changes and the next search returns the changed value.
The fix
Version 0.3.1 changes the one line that built the stored entry:
// before
metadata: document.metadata,
// after
metadata: Map<String, Object?>.unmodifiable(document.metadata),
Map.unmodifiable does two things here. It allocates a new top-level map, so the store no longer holds the caller's instance, and later edits to the caller's map do not reach the store. It also makes the stored map reject writes, so writing back through a search result throws UnsupportedError at the point of the write, rather than changing the store quietly.
What the fix does not do
The copy is top level only. Map.unmodifiable copies only the outer map; it does not recurse into the values. If a metadata value is itself a List or a Map, that inner collection is still the caller's object, shared and mutable. A write into a nested collection can still reach the store, and it will not throw, because only the outer map is unmodifiable, not its values.
The Document dartdoc now says so: "a store may copy the top level of metadata, but is not expected to deep-copy its values." If you keep mutable collections inside metadata and need them isolated, copy them yourself before putting them in a Document.
The regression tests
Two regression tests shipped in test/vector_store_test.dart. The first upserts a Document with a metadata map, mutates the caller's map after the upsert, runs a search, and asserts the result still carries the original value. The second takes a search result, writes to its metadata, asserts that write throws UnsupportedError, and asserts a following search shows the store unchanged.
Copy at the boundary
This root cause shows up outside vector search too. Any store, cache, or index that keeps a reference to a caller-supplied mutable collection is aliasing it: the caller and the store share one object, and either side can change what the other sees. Handing that same instance back out, the way search did, compounds the surprise, because a read result becomes a live handle into internal state.
The fix is to copy the collection where it enters the store. Copying breaks the alias, so a caller's later edits stay with the caller. Making the copy unmodifiable adds the second half: a write that used to change stored state quietly now throws where it happens, next to the code that made the mistake. The cost is one top-level map copy per stored document, and an exception on a write path that was already a bug.

Top comments (0)