ADR-008: Embedding backend, vector storage and NER backend for v0.5
Date: 2026-06-02 Status: Accepted (locks the v0.5 brief decisions; consumed by SYN-81 + SYN-83 + SYN-164; supersedes the v0.4-era placeholder reservation) Author: Rômulo Febasi
Context
Slot 008 was reserved at the start of the v0.1 cycle for “the embedding
backend choice” with the explicit caveat that it would be filled “after a
benchmark spike, when v0.5 lands”. The v0.5 brief
(docs/researchs/2026-06-02-v0.5-semantic-layer-and-second-brain.md)
decided the four load-bearing questions on 2026-06-02:
- Embedding inference stays on-device. The local-first invariant from BR-1.4 and BR-12 binds; Ollama as a runner is a configurable alternative, not the default.
- Vector storage lives in the same
.synapse/index.dbto keep BR-1.5 (“one workspace, one source of truth”) intact. - Hybrid ranking uses RRF in v0.5: no labels yet, no place for convex combination.
- Entity linking at ingestion is mandatory (BR-13). It needs a small zero-shot NER model so that pt-BR-grammatical content like “reunião com a Carina sobre a flag do Remote Config” does not require the user to pre-create every proper noun as a slug.
The remaining unknown (and the thing the spike had to settle) was whether the chosen tooling actually composes:
- Does
sqlite-vecintegrate with arusqlite-backed connection without breaking thebundledSQLite that ADR-003 mandates? - Does
fastembed-rs5.x produce usable embeddings on a CPU-only laptop, and at what latency? - Does the Carina/Firebase canonical query (brief §1.1) actually surface the right notes when the corpus is small enough to read by eye?
The spike at spike-syn-89/ answered all three with numbers. This ADR
records the answers, locks the tooling choices, and registers the
issues SYN-81 + SYN-83 + SYN-164 have to navigate when production
wiring begins.
Decision
Adopt the following stack for the v0.5 semantic layer. Each row of the table is the default; the opt-down column is a workspace-level config override (per §1.3 of the v0.5 brief) for users who explicitly value smaller footprint over recall.
| Slot | Default (best) | Opt-down | Crate |
|---|---|---|---|
| Embedding inference | BGE-M3 multilingual quantised | BGE-small-EN-v1.5-Q | fastembed 5.x |
| Vector storage | sqlite-vec vec0 virtual table | (none: single backend) | sqlite-vec 0.1.x |
| NER (entity linking) | GLiNER-multi-base-Q | GLiNER-base-en-Q | also via fastembed-rs (ships GLiNER as ner_model) |
| Hybrid fusion | RRF with k = 60 | (none: v0.5 ships only RRF; convex combination is the v0.6 polish) | implemented in core::search |
The configuration knob lives in <workspace>/.synapse/config.toml:
[enrichment]
language = "pt-BR" # or "en-US"
embedding_model = "bge-m3" # or "bge-small-en-v1.5-q"
ner_model = "gliner-multi" # or "gliner-base-en"
mode = "heuristic+ner" # or "heuristic", "off"
Why these and not the alternatives
| Candidate | Why rejected as default |
|---|---|
| Ollama as embedding runner | ~700 MB binary + ~280 MB model = ~1 GB on disk, plus a separate daemon the user has to start. Violates “one binary, one workspace” UX even though it does not violate BR-1 strictly. Kept as configurable alternative (provider = "ollama") so a user who already runs Ollama for chat can reuse it. |
candle (pure Rust ML) | Strong on flexibility (any HF model, no ONNX conversion) but slower per-query than ort on CPU. For an ingestion-time hot path the throughput matters more than the flexibility. |
| OpenAI / Voyage / Cohere embedding APIs | Best raw quality but each write becomes a network call that leaves the workspace. Breaks BR-12 privacy posture without an explicit opt-in. Kept as stretch SYN-165 behind provider = "openai" | "voyage". |
lance for vector storage | Full ANN + HNSW out of the box, columnar Arrow-native storage. But ~50 MB of extra code in the binary and a separate mental model from “everything lives in .synapse/index.db”. The spike confirmed sqlite-vec is fast enough for the v0.5 ceiling of 10k entities. |
| Hand-rolled flat scan + SIMD cosine | Tempting (no extra dep). Rejected because sqlite-vec ships rank fusion + chunked storage + future HNSW for ~1.5 MB of native code; reinventing the storage layer just to avoid one dep is the wrong axis to optimise on. |
| Full Microsoft-style GraphRAG with community summaries | 3–5× ingestion cost ([GraphRAG vs Vector RAG 2026]). Requires LLM calls during ingestion which breaks local-first. The brief substitutes the lightweight heuristic + GLiNER combo and reserves the GraphRAG-class polish for v0.6+. |
Spike findings (the numbers behind the choices)
Ran 2026-06-02 on aarch64-apple-darwin (Apple M1 Pro, macOS 25.4),
cargo run --release, model: BGE-small-EN-v1.5-Q (the lightest variant:
the spike did not need the full BGE-M3 download to validate the
integration; the production-default BGE-M3 numbers are cited from the
public MTEB report referenced below).
[fastembed] model loaded in 11.12 s (cold start, first-time download)
[fastembed] embedded 8 samples in 69.48 ms (avg 8.69 ms/text, dim=384)
[sqlite-vec] registered, version = v0.1.9
[sqlite-vec] inserted 8 rows in 433.12 µs
[sqlite-vec] vec0 query (8-row corpus) ~130 µs / query
[footprint] release binary size ~21 MB
[footprint] model cache (BGE-small-EN-v1.5-Q) ~33 MB
[total wall-clock] 11.23 s (dominated by first-time fetch)
Carina/Firebase regression (brief §1.1 canonical query). Eight planted notes; query was the multi-hop concept “flag do Firebase para o checkout”. Top-3 cosine ranking (lower distance = closer):
project/checkout-v2 dist=0.6859
note/firebase-stack dist=0.7440
note/meeting-carina-2026-04-20 dist=0.7869
All three relevant notes surface in the top-3 with no tuning. This validates the v0.5 thesis: vector retrieval alone bridges the “Remote Config ↔ Firebase” concept gap even before the entity-linking heuristic + NER pipeline adds a graph layer on top.
Reverse query (“Remote Config feature flag eligibility”) was also
clean: firebase-stack + meeting-carina + api/firebase-remote-config
in the top-3.
Findings that survive the spike: issues for v0.5 production wiring
-
rusqlite version conflict: AMENDED 2026-06-02 during SYN-81. The spike flagged thatsqlite-vec0.1.9 declaresrusqlite ^0.31while the main crate runs onrusqlite 0.39(ADR-003). On closer inspection ofsqlite-vec’sCargo.toml, therusqlitedep is in[dev-dependencies]only: it backs the upstream crate’s own test suite, never its public API. SYN-81’s integration goes through the SQLite C ABI’ssqlite3_auto_extension, which is ABI-stable and version-agnostic. Decision: skip path B (vendor the C source) entirely. We depend onsqlite-vec0.1 directly as a runtime dep behindfeature = "vector"and register it viarusqlite::ffi::sqlite3_auto_extension. Total cost: ~10 lines insrc/index/vector.rsand oneOnceLock. The original path A (downgrade) was never viable; path B was real engineering work the spike’s surface read mis-attributed.unsafeexception. Wiringsqlite3_auto_extensionrequires a singletransmuteof a C function pointer. The crate’s#![forbid(unsafe_code)]is relaxed to#![deny(unsafe_code)]so thatsrc/index/vector.rscan scope a documented#![allow(unsafe_code)]. Every other file in the crate still fails the build onunsafe. This is the onlyunsafesite introduced by v0.5 and the only one allowed by the project’s architectural rules going forward.
-
First-run model download is 11 s + ~33 MB for BGE-small (~570 MB for the production-default BGE-M3 and an additional ~150 MB for GLiNER-multi). This download cannot land on the hot path of
syn init: UX-blocking. Decision: lazy initialise theEmbeddingBackendandNerBackendon the first write call, not on workspace open. Print an explicit “downloading model…” banner on the first fetch (SYN-83 + SYN-164 acceptance).Correction (SYN-214, 2026-06-07): the size estimates above were wrong. Measured from Hugging Face: the production-default BGE-M3 the code wires (
fastembed::EmbeddingModel::BGEM3→BAAI/bge-m3) is fp32, ~2.2 GB (the “~570 MB” was the int8gpahal/bge-m3-onnx-int8, offered as a choice in SYN-237), and GLiNER-multi quantised is ~350 MB, not ~150 MB. Real default footprint: ~2.5 GB. The English opt-downs are ~65 MB (BGE-small) + ~260 MB (GLiNER-base) ≈ ~325 MB. Canonical figures live indocs/MODELS.md. -
sqlite-vecis flat-scan only in v0.1.9: no HNSW index yet. For Synapse’s≤ 10k entitiesv0.5 ceiling this is fine (the spike measured ~130 µs per query in an 8-row table; even O(N) scaled to 10k stays well under the 150 ms search budget). Document the ceiling explicitly indocs/CONFIGURATION.md; revisit in v0.7 if a real user exceeds it. -
Fastembed cache discovery: the spike’s
duheuristic reported 0 MiB becausefastembed-rs5.x writes under~/.cache/fastembed_cache/(lowercase, no leading dot) rather than the~/.fastembed_cache/the spike probed. Cosmetic for the spike, butcore::embeddings(SYN-83) must readFASTEMBED_CACHE_PATHfirst, then fall back to the platform-correct path (XDG on Linux,~/Library/Caches/fastembed_cacheon macOS,%LOCALAPPDATA%on Windows), and Synapse should surface the chosen path insyn embed --checkso the user can sweep it manually.- AMENDED 2026-06-03 during SYN-83. Synapse owns the cache
directory and passes it to fastembed via
InitOptions::with_cache_dir. Precedence:[enrichment] model_cache_dir→SYNAPSE_MODEL_CACHE→ platform default (~/Library/Caches/synapse/models/fastembedon macOS, XDG on Linux,%LOCALAPPDATA%on Windows). The fastembed-ownedFASTEMBED_CACHE_PATHenv var is not read; one knob, one owner.syn embed --checkwill print the resolved path so users candu -shit themselves.
- AMENDED 2026-06-03 during SYN-83. Synapse owns the cache
directory and passes it to fastembed via
-
GLiNER via
gline-rsblocked byortRC pinning, NEW 2026-06-03 during SYN-164. The v0.5 brief asserted “fastembed-rs ships GLiNER via ORT: no separate ORT dep”. Verified false at wiring time: fastembed-rs 5.x carries embedding + reranker catalogues only, no NER. The canonical NER crategline-rs1.0.1 pinsort = "=2.0.0-rc.9"exactly while fastembed 5.15 pinsort = "=2.0.0-rc.12"exactly: Cargo cannot resolve two pinned RCs of the same crate. Three paths were considered:- A. Vendor / fork
gline-rsourselves and bump its ORT to rc.12. Owner of the fork; ORT RC API drift between rc.9 and rc.12 is non-trivial. - B. Use
kreuzberg-gliner-rs0.2.0: Kreuzberg’s Apache-2.0 fork ofgline-rswhose published crate re-pins ort to=2.0.0-rc.12and ships an alignedkreuzberg-orp 0.1.1. Public API surface (GLiNER<SpanMode>,Parameters,TextInput,SpanOutput) is identical to upstream, so the consuming code ports back the moment upstream re-aligns. - C. Ship v0.5 with heuristic-only NER via the existing
RegexNeruntil upstreamgline-rsre-aligns ort.
Initial reading (2026-06-03): path C, on the assumption that path B would land a second ORT copy in the binary (+50 MB).
Amended 2026-06-03: path B chosen. A build probe (
/tmp/probe-kg,cargo build --release) withkreuzberg-gliner-rs = "0.2"+fastembed = "5.15"resolved to a singleort 2.0.0-rc.12inCargo.lock. Both backends share the same ORT copy: no binary bloat. The earlier +50 MB claim came from misreading the fork’sCargo.toml: the published 0.2.0 carries ort=rc.12, even though the fork’smasterbranch on GitHub still shows=rc.9. With the real cost at zero MB, the only meaningful trade-off vs. path A is dependency hygiene, the Kreuzberg org’s main project (kreuzberg-dev/kreuzberg, 8.4k stars, 200 releases, last May 2026) is active enough to amortise that risk. SYN-164 lands in v0.5 againstkreuzberg-gliner-rs; we revisit when upstreamgline-rsre-aligns its ORT pin (issue #156’s exit criterion). - A. Vendor / fork
Consequences
- +1 dep family:
fastembed5.x pullsort(ONNX Runtime statically linked, ~25 MB compressed),tokenizers,hf-hub, and the BGE / GLiNER ONNX models on first use. Acceptable: the local-first invariant survives and the binary growth lands in the ~50 MB envelope the v0.5 brief §7 advertised. +1 dep:AMENDED 2026-06-02. Per the Findings amendment above, SYN-81 addssqlite-vecdoes not get added tosynapse’sCargo.tomlsqlite-vec = { version = "0.1", optional = true }as a normal optional runtime dep gated byfeature = "vector". No vendoring; the upstream Cargo dep ships the C source the build links in. The dev-deps-onlyrusqlite ^0.31constraint insidesqlite-vecis irrelevant to consumers.- Per-workspace language config: workspaces are now meaningfully
different from each other in what model they load. A user with
three pt-BR repos pays the ~720 MB cache once (shared); a mixed
install pays twice. Documented in
docs/IDENTITY.mdanddocs/CONFIGURATION.md. - Migration story: opening a v0.4 workspace with a v0.5 binary
triggers
syn embed --allautomatically on the first write (or runs it explicitly when the user typessyn embed --all); thevectormigration ships asmigrations_vec/0001__entities_vec.sqlper SYN-80. - Upgrade path documented in advance (added 2026-06-02 after the
owner-prompted critical review at
docs/researchs/2026-06-02-vector-storage-options-vs-sqlite-vec.md): if a workspace ever exceeds ~50k indexed entities and the flat-scan latency becomes visible, switch tovectorlite(HNSW-based SQLite loadable extension by1yefuwang1, ~10× faster searches than sqlite-vec’s flat scan, sameload_extensionshape, same SQL surface). The swap costs ~30 lines of FFI changes and zero business-logic change. Pre-staged ticket: SYN-203 in v0.7+. LanceDB / SurrealDB / DuckDB+VSS were evaluated in the same review and rejected as v0.5 migration targets: they would force rewritingWorkspace::tx, FTS5 callers, the pending queue and the audit-trail SQL (estimated ~2,400 lines + ~100 tests), which is a v1.0-shaped commitment, not a v0.5 spike. - Reverse compatibility: the schema additions are append-only;
removing the
vectorfeature flag at build time omits theentities_vectable cleanly. v0.4 binaries opening a v0.5 workspace ignoreentities_vec(FTS5 path still works). Documented in SYN-80. - MSRV stays at 1.88 (ADR-009); no fastembed-imposed bump in 5.x.
Alternatives considered (recorded for completeness)
The “why rejected” table above covers Ollama, candle, OpenAI/Voyage,
lance, hand-rolled flat scan, and full GraphRAG. Two more decisions
deserve explicit recording so the next reader does not have to relitigate
them:
- Embed model =
nomic-embed-text-v1.5instead of BGE-M3: Nomic is English-strong, 768-dim, ~280 MB cached, MTEB EN 62.39. The brief rejected it as default because Synapse is per-workspace multilingual per BR-13 §1.3; Nomic would force every pt-BR workspace to opt-up to a different model. BGE-M3 covers both languages competently at one model. - NER backend =
RelikorspaCyover an inference sidecar: both outperform GLiNER on top-tier benchmarks but require either a Python sidecar (Relik) or a separate runtime (spaCy). GLiNER is the Rust-native ONNX option. The 2026 Neo4j blog ([Entity Linking and Relationship Extraction With Relik]) explicitly recommends GLiNER for the “ingestion-time, no Python” branch of the decision tree.
Amendment 2026-06-04 (SYN-168): opt-out, not opt-in
The original decision shipped the semantic layer behind --features vector (off by default), deferring the default flip to “v0.6 if
adoption pulls it.” That was reversed inside v0.5: the layer is the
product, not an experiment, so it now ships in the default binary
(default = ["mcp", "v05_backends"]). Two constraints shaped how:
- Binary cost is small; model cost is not. Compiling fastembed +
GLiNER adds ~25 MB of statically-linked ONNX Runtime: acceptable as
a default. The ~920 MB of model weights are a different category, so
they are never pulled implicitly. A per-model readiness sentinel
(
core::embeddings::readiness, written the moment a backend loads) is the single source of truth;run_on_write, hybrid search, andsyn embed --checkall gate on it and degrade gracefully (skip embedding / fall back to FTS+graph) instead of triggering a download. The only consent paths that fetch are thesyn initonboarding prompt andsyn embed. Without the models the tool is a fully functional keyword + graph brain; the models add meaning-search and NER discovery on top. network_backendsandtest_backendstay opt-in. Hosted providers send content to a third party (privacy: local-first is the default per BR-13). The deterministic stub returns fake embeddings and must never reach a production binary; the release pipeline therefore builds--features network_backends(additive to the default set), not--all-features, which previously leakedtest_backendinto shipped binaries.
References
- v0.5 brief:
docs/researchs/2026-06-02-v0.5-semantic-layer-and-second-brain.md§2.1, §2.2, §2.4 - BR-13:
docs/business/BUSINESS_RULES.md - ADR-003:
rusqlite bundled(the constraint sqlite-vec collides with) - ADR-009: MSRV 1.88 (unchanged by this ADR)
- ADR-013: audit trail (the BR-13 enrichment pipeline writes to it on enrichment failures, but never on success: atomicity is preserved by the surrounding
Workspace::tx) - Spike crate:
spike-syn-89/(reproducer for the numbers above) fastembedcratesqlite-veccrate and Alex Garcia’s Rust integration walkthrough- Best Embedding Model for RAG 2026: Milvus (BGE-M3 multilingual MTEB numbers)
- Hybrid Search BM25 + Vector + Reranking 2026 (RRF baseline)
- GraphRAG vs Vector RAG 2026 (the v0.6 polish path we are not taking yet)
- Entity Linking and Relationship Extraction With Relik: Neo4j Developer blog (the GLiNER recommendation we are taking)