-
Notifications
You must be signed in to change notification settings - Fork 0
v0.2.1 ‐ v0.2.5 changes and sweet sweet Federation
| Area | Change |
|---|---|
| Federation | First-class engine module — open a directory of .sfg stores and search across them with automatic routing. Exposed on all three surfaces: Python, C FFI, and the Rust wrapper. |
| Routing modes |
best (single best-matched store), top (best n stores), all (exhaustive fan-out). The result top_k is whatever the query asks for — it is not capped by the mode. |
| Parallel fan-out | Multi-store searches run concurrently across a work-stealing pool and merge by score. |
| Enterprise (Gov) | Per-operator identity is now folded into each record's tamper-evident chain rather than carried only as metadata. See Enterprise: Compartmented Stores. |
| Version | Engine, enterprise, and wrapper crates move in lockstep to 0.2.1. Wheels: seraph-db (GPU/candle) and seraph-db-onnx. |
Stores written by earlier 0.2.x engines open unchanged — this is an additive release. No store migration is required.
A federation is a set of independent single-file stores that share the same embedding model and dimension. You point the engine at a directory; it loads every store, builds a tiny routing summary per store, and routes each query to the store(s) most likely to answer it before searching.
my-federation/
├── biology.sfg
├── history.sfg
├── geography.sfg
└── physics.sfg
open_dir loads every *.sfg in the directory. Each store is named by its file
stem (biology.sfg → biology). Files beginning with _ or router are
skipped, so you can keep scratch or index files alongside without them being
treated as members.
from seraph import Federation, Encoder
fed = Federation.open_dir("my-federation")
print(len(fed), fed.member_names()) # 4 ['biology', 'geography', 'history', 'physics']
# A query is an embedding from the SAME model the stores were built with.
enc = Encoder.from_pretrained("BAAI/bge-small-en-v1.5")
q = enc.encode("how do cells regulate division")
# mode: "best" | "top" (with n) | "all"
hits = fed.search(q, top_k=10, tau=0.60, mode="best")
for h in hits:
print(h.store, h.score, h.snippet)
# Top-3 stores, still 10 results merged across them:
hits = fed.search(q, top_k=10, tau=0.60, mode="top", n=3)
# Exhaustive fan-out across every store:
hits = fed.search(q, top_k=10, tau=0.60, mode="all")use seraph::{Federation, RoutingMode};
let fed = Federation::open_dir("my-federation")?;
let hits = fed.search(&query_embedding, 10, 0.60, RoutingMode::BestMatch)?;
// RoutingMode::Top(3) | RoutingMode::All
for h in &hits {
println!("{} {:.3} {}", h.store, h.score, h.frame_id);
}The same surface is exported as seraph_federation_open_dir, _add_path,
_route, _search, _refresh, _len, _member_names, and _free. Calls
return JSON strings; free every returned string with seraph_string_free and
the federation handle with seraph_federation_free.
| Mode | Routes to | Use when |
|---|---|---|
best |
The single best-matched store | You expect the answer to live in one domain. Lowest latency. |
top (n) |
The best n stores | The query may straddle a few domains; n = 3 is a good default. |
all |
Every store | You want exhaustive recall and accept fan-out cost. Skips routing entirely. |
In every mode each routed store returns up to top_k hits; results are merged
by score and truncated to top_k. The mode controls which stores are
searched, never how many results you get back.
Federation tracks its member stores by event, not by polling or disk sync. When
a store promotes new structure, it is flagged stale; calling refresh()
re-pulls the routing summaries of exactly the stores that changed and leaves the
rest untouched:
fed.refresh() # cheap; touches only stores that changed since the last callYou do not need to reopen the federation to pick up writes to a member
store. You do need to call refresh() if you want routing to account for
structure added since the federation was opened.
-
One model, one dimension. Every store in a federation must share the same embedding model and dimension. Adding a mismatched store is rejected at load time — this is deliberate (cross-model embeddings can't be compared). Build each domain store with the same encoder.
-
The query is an embedding, not text.
searchandroutetake a query vector. Encode the query with the same model the stores were built with before passing it in. A query embedded by a different model will route and score meaninglessly. -
tauis model-dependent — calibrate it per encoder. The useful threshold band is a property of your embedding model's similarity distribution, not a universal constant. Forbge-class models (what the benchmarks below use) the useful band is roughly~0.58–0.65; a different encoder will land elsewhere. Sweep a handful of values against known-good queries on your own corpus and pick the knee — much lower returns loose, low-relevance hits; much higher starves results. -
Routing is a hint, not retrieval.
best/toptrade a little recall for a lot of latency. If a query genuinely spans domains and you can't afford to miss anything, useall. Routing accuracy is high but not 1.0 — don't usebestfor exhaustive-recall workloads. -
Call
refresh()after writing to members. A long-lived federation won't see new structure in routing decisions until you refresh. Reads of unchanged stores still work; only the routing summary is what goes stale. -
Latency scales per-store, not per-federation. Search cost is bounded by the size of the routed store(s), not the total corpus across the federation. Many medium stores beat one giant store for federated search. A practical sweet spot is ~50–100k frames per store.
-
Skipped filenames.
_*.sfgandrouter*.sfgare intentionally ignored byopen_dir. If a store isn't showing up inmember_names(), check its filename prefix. -
Free what you allocate (C FFI only). Every JSON string returned by a
seraph_federation_*call must be released withseraph_string_free, and the handle withseraph_federation_free. The Python and Rust surfaces manage this for you.
Drop-in for existing 0.2.x users:
-
Python:
pip install -U seraph-db(orseraph-db-onnxfor the ONNX encoder backend). -
FFI / wrapper: replace the
0.2.1shared library (seraph.dll/libseraph.so) and headers.
Existing stores open without migration. Federation is opt-in — nothing changes for single-store usage unless you call into the federation surface.
Real numbers, not projections. Setup: 14 DBpedia domain stores (~60k frames
each, ~840k total — album, animal, artist, athlete, building,
company, film, village, …), 98 queries drawn from real frame
embeddings, top_k = 10, p50 latency over the runs. Parallel is the shipped
rayon fan-out; sequential searches the same store set one at a time, shown for
comparison. CPU search path. Corpus embedded with bge-small (384-dim) — the
tau values below reflect that encoder; recalibrate for a different model (see
the tau gotcha above).
tau |
Mode | Stores searched | Sequential p50 | Parallel p50 | Speed-up |
|---|---|---|---|---|---|
| 0.58 | best |
1 | 0.674 ms | 0.658 ms | 1.0× |
| 0.58 |
top (3) |
3 | 1.288 ms | 0.797 ms | 1.6× |
| 0.58 | all |
14 | 3.196 ms | 1.013 ms | 3.2× |
| 0.60 | best |
1 | 0.480 ms | 0.475 ms | 1.0× |
| 0.60 |
top (3) |
3 | 0.986 ms | 0.612 ms | 1.6× |
| 0.60 | all |
14 | 2.858 ms | 0.815 ms | 3.5× |
| 0.65 | best |
1 | 0.284 ms | 0.292 ms | 1.0× |
| 0.65 |
top (3) |
3 | 0.752 ms | 0.395 ms | 1.9× |
| 0.65 | all |
14 | 2.638 ms | 0.604 ms | 4.4× |
What the numbers say:
-
Single-best routing is sub-millisecond and flat.
bestmode pays only for the one routed store (0.28–0.67 ms), regardless of how many stores are in the federation. Add a hundred more domains and this number does not move. - Parallel fan-out earns its keep as you widen. With one store there's nothing to parallelize (≈1.0×); across all 14 it's 3.2–4.4× faster than searching them sequentially — exhaustive recall over the whole federation in ~0.6–1.0 ms.
-
Higher
tauis faster. Fewer candidates clear the threshold, so latency drops as you tighten from 0.58 → 0.65.
No repo required — this runs against the published wheel and your own directory
of .sfg stores:
import time
from seraph import Federation, Encoder
fed = Federation.open_dir("my-federation") # your directory of .sfg stores
enc = Encoder.from_pretrained("BAAI/bge-small-en-v1.5")
queries = [enc.encode(q) for q in ("first query", "second query", "third query")]
for mode in ("best", "top", "all"):
samples = []
for q in queries * 30: # warm + repeat for a stable p50
t = time.perf_counter()
fed.search(q, top_k=10, tau=0.60, mode=mode, n=3)
samples.append((time.perf_counter() - t) * 1e3)
samples.sort()
print(f"{mode:5} p50 {samples[len(samples)//2]:6.3f} ms")Your absolute numbers will differ with hardware, store sizes, and encoder — but
the shape holds: best flat and sub-millisecond, all a few × that and
bounded by your largest routed store.