flutter_gemma_rag_sqlite

First-class SQLite vector store for flutter_gemma. KNN runs inside SQLite via sqlite-vec (vec0 virtual table) — no Dart brute-force, no in-memory index.

Opt-in package implementing VectorStoreRepository:

  • Native (Android/iOS/macOS/Linux/Windows): SqliteVectorStorepackage:sqlite3 (dart:ffi) + the per-platform vec0 loadable extension.
  • Web: WebSqliteVectorStorepackage:sqlite3/wasm.dart driving a custom sqlite3.wasm with sqlite-vec/vec0 statically linked.

Both arms speak the same vec0 SQL dialect, so KNN and Filter behave identically across all six platforms. A vec0 table declares an id TEXT PRIMARY KEY, so KNN returns the document id directly — no JOIN, no rowid bridge.

Usage

import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter_gemma/flutter_gemma.dart';
import 'package:flutter_gemma_rag_sqlite/flutter_gemma_rag_sqlite.dart';

await FlutterGemma.initialize(
  vectorStore: kIsWeb ? WebSqliteVectorStore() : SqliteVectorStore(),
);

searchSimilar returns cosine similarity (1 = identical, higher = better), sorted descending, filtered by threshold — the same contract as the qdrant store (vec0 returns distance; the store converts 1 - distance at the boundary).

Declared-column filters

vec0 filters KNN only on declared, typed metadata columns (not arbitrary JSON). Declare the filterable fields once at init via filterSchema:; the store promotes those fields out of each document's metadata JSON into real columns and translates Filter (must/should/mustNot) into a vec0 WHERE:

await FlutterGemma.initialize(
  vectorStore: kIsWeb ? WebSqliteVectorStore() : SqliteVectorStore(),
  filterSchema: const FilterSchema(fields: [
    FilterField(name: 'lang', type: FilterFieldType.string),
    FilterField(name: 'year', type: FilterFieldType.number),
    FilterField(name: 'archived', type: FilterFieldType.bool),
  ]),
);

// later, at query time:
final hits = await store.searchSimilar(
  queryEmbedding: queryVec,
  topK: 10,
  filter: const Filter(
    must:    [FieldRange(key: 'year', gte: 2000)],
    mustNot: [FieldEquals(key: 'archived', value: true)],
  ),
);

FilterField.name must match ^[A-Za-z][A-Za-z0-9_]*$, and must not be a name vec0 already declares: id, embedding, content, metadata, and the hidden distance and k. configure() throws an ArgumentError otherwise — at that call, not at the first addDocument, which is when the table is really built.

The name becomes a real vec0 column, and sqlite-vec's DDL grammar accepts no quoted identifier form ("doc-type", [doc-type] and `doc-type` all fail), so a name outside that set is unrepresentable rather than merely unescaped. qdrant accepts most of these names, so a schema written for it may be refused here — this set is the portable one.

Filtering on an undeclared key is a safe no-op (never throws). With no filterSchema, the store ignores filters entirely — identical to filter: null. Supported operators: =, !=, >, >=, <, <=, BETWEEN, IN (FieldEquals, FieldRange, FieldMatchAny); max 16 declared columns.

Upgrading from 1.0.x

1.1.0 does not read an index written by 1.0.x. The switch to in-SQLite vec0 KNN moved the data from a plain documents table into a vec_documents virtual table. Nothing errors on upgrade: initialize() succeeds, getStats() reports 0 documents, searchSimilar() returns no hits, and the old rows sit untouched in documents. This was not called out when 1.1.0 shipped.

Your data is intact and needs no re-embedding — 1.0.x stored the vector as a Float32 BLOB alongside the id, content and metadata. Move it once:

import 'dart:typed_data';
import 'package:sqlite3/sqlite3.dart';   // add sqlite3 to your own pubspec

final store = SqliteVectorStore();
await store.initialize(path);

final db = sqlite3.open(path);
final hasLegacy = db
    .select("SELECT name FROM sqlite_master "
            "WHERE type='table' AND name='documents'")
    .isNotEmpty;

if (hasLegacy) {
  for (final row
      in db.select('SELECT id, content, embedding, metadata FROM documents')) {
    // 1.0.x wrote each element with setFloat32(..., Endian.little); read it
    // back the same way. ByteData.sublistView needs no 4-byte alignment,
    // which a raw asFloat32List view of the BLOB would.
    final bytes = ByteData.sublistView(row['embedding'] as Uint8List);
    await store.addDocument(
      id: row['id'] as String,
      content: row['content'] as String,
      embedding: List<double>.generate(
        bytes.lengthInBytes ~/ 4,
        (i) => bytes.getFloat32(i * 4, Endian.little),
      ),
      metadata: row['metadata'] as String?,
    );
  }
  db.execute('DROP TABLE documents');   // only after the loop succeeds
}
db.dispose();

Dropping the table is what makes the block a no-op on later launches. There is no built-in migration call — this is a one-time fix for an upgrade that has already happened. Full write-up in the migration guide.

Setup

Native needs no setup — the vec0 loadable extension is fetched per platform by this package's Native Assets hook (hook/build.dart), SHA256-verified, and loaded automatically before any database is opened.

New in 1.3.0: that fetch is real. Until 1.2.0 the loadables were committed into the package, so every install carried all seven platforms' binaries to use one of them. They now come from this repository's native-sqlite-vec-v* GitHub Release, which means the first build of each platform needs github.com reachable; the library is cached under ~/.cache/flutter_gemma/native/ (~/Library/Caches/… on macOS, %LOCALAPPDATA%\… on Windows) and later builds do not go out again. flutter_gemma_litertlm has always worked this way. If you build in an air-gapped environment, pre-populate that cache directory.

Web ships the custom sqlite3.wasm (with sqlite-vec linked in) as the package web asset web/rag/sqlite3.wasm. Copy it into your app's web root so it sits next to index.html at rag/sqlite3.wasm — that's the URL WasmSqlite3.loadFromUrl fetches. Resolve the package directory with dart pub deps/flutter pub (the path printed by your IDE) and copy the asset:

mkdir -p web/rag
# <pkg> = the flutter_gemma_rag_sqlite directory in your pub cache / workspace
cp <pkg>/web/rag/sqlite3.wasm web/rag/sqlite3.wasm

OPFS persistence and SharedArrayBuffer require your web server to send the cross-origin isolation headers:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

There is no CDN <script>, no wa-sqlite worker, and no index.html wiring anymore.

Libraries

flutter_gemma_rag_sqlite
SQLite vector search (sqlite-vec / vec0) on-device RAG vector store for flutter_gemma.