Search stages b (local embeddings) and c (JEV reranker): module cut#
Status: built (cut drafted 2026-11-06, implemented on
feat/search-stages). Sections 1 to 14 keep the design text; where the build differs, section 15 names the deviation and wins. Measurements are in section 16. Open points are in section 14. Contract sections: architecture.md §6.5, §7, §15.4; providers and data location: providers.md; purge: redaction.md §2, §4; deviation: abweichungen.md D101.
1. Summary#
Stage a (lexical, synchronous) stays the recall floor. Stage b embeds the query with a local ONNX
model (@huggingface/transformers, optional dependency, model files only from disk) and scores it
brute force against the vec_rule table in the scope file; a and b are fused by RRF (k = 60).
Stage c sends the query and the statements of the top 50 heads of a scope in one call to JEV
(jev-1.14.0, TypeSafe AI, USA), keeps heads with a score ≥ 0.5 and orders by that score. Every
response names state and reason per stage; b or c not running is never an error. search()
becomes async.
2. Starting state (evidence, before the build)#
| What | Where |
|---|---|
search() is synchronous, stages b/c hard-wired to STAGE_OFF |
src/search/search.ts:162, :219 |
SearchStage interface (no implementations), Candidate, StageQuery without the query text |
src/search/stage.ts:4-35 |
| Stage a returns up to 200 hits on current versions | src/search/stage-a.ts:37 |
HeadHit has only rankA; bundle writes rank.b/rrf/reranker = null |
src/search/bundle.ts:40-48, :234 |
Response schema already carries stages.{a,b,c}, rank.{a,b,rrf,reranker}, found_via |
src/search/schemas.ts:30, :80-86, :130 |
Config: semantic: Off, reranker: Off |
src/config/file.ts:20, :89-90 |
Every rule write ends in reindexRule inside the write transaction |
src/db/fts.ts:84; callers src/domain/proposals.ts:160, src/decision/decide.ts:231-281 |
Every committed write calls ScopeStore.recordWrite |
src/db/scope-store.ts:158; 5 call sites (rg "recordWrite\(") |
| Scope migrations 0001–0003 exist; redaction.md reserves 0004/0005 on paper | src/db/migrations/scope/index.ts, redaction.md §3.1, §4 |
| Ops migrations 0001–0002 | src/db/migrations/ops/index.ts |
Coverage is computed only for the first 50 stage-a candidates (private explain) |
src/search/lexical/index.ts:520 |
JEV request form: {model, state: {request}, questions: {id: {type: "noul", instructions: {question, rule}, criteria}}}, answer answers[id].noul, usage.input_tokens |
git show origin/feat/jev-eval:eval/jev/run.ts lines 10-170 |
Callers of search(): src/http/routes/search.ts:16 (handler already async),
src/mcp/tools/search-rules.ts:29 (inside the synchronous runTool, src/mcp/result.ts:71) and the
test helper test/search/support.ts:4 (used by test/search/*.test.ts, test/fuzz/search.test.ts
and others). No other module imports the function; src/approval/queries.ts,
src/decision/rejected-similar.ts, src/domain/proposals.ts, src/domain/provenance.ts import
only types.
3. Module cut#
| File | Responsibility | Single writer of |
|---|---|---|
src/search/stage.ts |
SearchStage, StageRequest, StageOutcome, Candidate (changed, section 4) |
— |
src/search/fusion.ts |
rrf() over ranked lists |
— |
src/search/search.ts |
async search(), flow and time budget (section 7), stage aggregation |
search_log (as today) |
src/search/semantic/models.ts |
model table (section 5.1) | — |
src/search/semantic/model-files.ts |
verifyModelFiles, fetchModel (SHA-256 per file) |
files under model_dir |
src/search/semantic/embedder.ts |
Embedder interface, pool() (CLS / last token, L2 norm), priority queue |
— |
src/search/semantic/transformers-embedder.ts |
only file importing @huggingface/transformers (dynamic import()) |
— |
src/search/semantic/text.ts |
embeddingText(version): statement, keywords, relations (no quote, no speaker) |
— |
src/search/semantic/vector-store.ts |
only module with SQL on vec_rule; matrix cache; delete functions |
vec_rule |
src/search/semantic/indexer.ts |
VectorIndexer: background catch-up, rebuild, progress |
(via vector-store) |
src/search/semantic/stage-b.ts |
SemanticStage implements SearchStage |
— |
src/search/rerank/jev-client.ts |
only module that calls api.typesafe.ai; request, retry, answer parsing, price table |
— |
src/search/rerank/budget.ts |
monthIn, spentThisMonthUsd, recordUsage |
ai_usage (ops file) |
src/search/rerank/stage-c.ts |
RerankStage implements SearchStage |
— |
src/search/lexical/index.ts |
new LexicalResult.explain(seqs) so b-only hits get coverage, matched_terms, found_via |
— |
src/db/migrations/scope/0005-vectors.ts |
vec_rule (section 6.1) |
— |
src/db/migrations/ops/0004-ai-usage.ts |
ai_usage (section 6.2) |
— |
src/config/file.ts |
search.semantic, search.reranker, scopes[].reranker (section 8) |
config |
src/ops/app.ts, src/ops/cli-commands.ts |
POST /ops/vector-index/:scope/rebuild, GET /ops/vector-index, CLI (section 10) |
— |
src/mcp/result.ts |
runToolAsync (same error mapping as runTool) |
— |
src/main.ts |
builds stages, starts the indexer, passes TYPESAFE_API_KEY |
— |
Architecture tests (same pattern as the existing import tests): @huggingface/transformers only in
transformers-embedder.ts; the string vec_rule only in vector-store.ts and the migration;
api.typesafe.ai only in jev-client.ts; ai_usage only in budget.ts and the migration.
4. Interfaces#
// src/search/stage.ts
export interface Candidate { scope: string; ruleId: string; seq: number; version: number; score: number }
export interface StageQuery {
text: string | undefined; // request.text; stages b and c need it
terms: readonly string[]; // as today
variants: Readonly<Record<string, readonly string[]>>;
includeProposals: boolean;
}
export type StageOutcome =
| { state: "ran"; reason: string | null; candidates: Candidate[] } // b: neighbours; c: scored heads
| { state: "failed" | "budget"; reason: string };
/** Per request; b embeds the query once in `start`, `run` is per scope. */
export interface StageRequest {
run(input: { scope: string; db: Db; candidates: readonly Candidate[] }): Promise<StageOutcome>;
}
export interface SearchStage {
name: "b" | "c";
/** Non-null = the stage does not run for this scope (off, not-allowed, failed before start). */
availability(scope: string): StageState | null;
start(input: { query: StageQuery; deadline: number; signal: AbortSignal }): StageRequest;
}
// src/search/fusion.ts
export const RRF_K = 60;
/** Σ 1 / (k + rank) over the lists that contain the key; rank is 1-based. */
export function rrf<K>(lists: readonly (readonly K[])[], k = RRF_K): Map<K, number>;
// src/search/semantic/embedder.ts
export type EmbedKind = "query" | "document";
export interface Embedder {
readonly model: ModelSpec;
/** L2-normalized, length model.dims each. Queries jump the queue ahead of indexing batches. */
embed(texts: readonly string[], kind: EmbedKind, signal?: AbortSignal): Promise<Float32Array[]>;
}
export type EmbedderLoad =
| { ok: true; embedder: Embedder }
| { ok: false; reason: "package-missing" | "model-missing" | "checksum-mismatch" | ModelDirReason | "load-error"; detail: string };
// ModelDirReason: "model-dir-symlink" | "model-dir-owner" | "model-dir-writable" | "model-dir-parent" | "model-dir-unreadable"
export type EmbedderLoader = (spec: ModelSpec, modelDir: string) => Promise<EmbedderLoad>;
/** Pooling done here from token-level output and attention mask, independent of pipeline options. */
export function pool(tokens: Float32Array, dims: number, mask: readonly number[], how: Pooling): Float32Array;
// src/search/semantic/vector-store.ts (single writer of vec_rule)
export interface VectorRow { seq: number; version: number; model: string; dims: number; textSha256: string; vector: Float32Array }
export interface IndexStats { total: number; embedded: number; otherModel: number }
export function writeVectors(db: Db, rows: readonly VectorRow[], now: Date): { written: number; discarded: number };
export function missingVectors(db: Db, spec: ModelSpec, opts?: { force: boolean }): { seq: number; version: number; text: string }[];
export function indexStats(db: Db, spec: ModelSpec): IndexStats;
export function nearest(db: Db, spec: ModelSpec, query: Float32Array, k: number, includeProposals: boolean): { seq: number; version: number; ruleId: string; cosine: number }[];
export function deleteVersionVectors(db: Db, ruleId: string, version: number): number; // rows removed
export function deleteRuleVectors(db: Db, ruleId: string): number; // all versions
export function invalidateVectors(db: Db): void; // drop the matrix cache
// src/search/semantic/indexer.ts
export interface VectorIndexer {
notify(scope: string): void; // after a committed write (debounced 500 ms)
rebuild(scope: string): { queued: number }; // force: re-embed every current version
status(scope: string): IndexStats & { running: boolean; lastError: string | null };
stop(): Promise<void>;
}
// src/search/rerank/jev-client.ts
export const JEV_MODELS = { "jev-1.14.0": { nanoUsdPerInputToken: 42 } } as const; // 0.042 USD / 1M
export type JevResult =
| { ok: true; scores: number[]; inputTokens: number; outputTokens: number; attempts: number }
| { ok: false; reason: "timeout" | "network" | "http"; status: number | null; inputTokens: number | null };
export interface JevClient {
score(input: { query: string; statements: readonly string[]; deadline: number; signal: AbortSignal }): Promise<JevResult>;
}
export function jevClient(deps: { apiKey: string; model: keyof typeof JEV_MODELS; fetch: typeof fetch; now: () => number }): JevClient;
// src/search/rerank/budget.ts (single writer of ai_usage)
export function monthIn(zone: string, now: Date): string; // "YYYY-MM"
export function spentThisMonthUsd(ops: OpsStore, now: Date, zone: string): number; // also read by `doctor`
export function recordUsage(ops: OpsStore, entry: { now: Date; zone: string; provider: "typesafe";
model: string; scope: string; inputTokens: number | null; outputTokens: number | null }): void;
HeadHit (src/search/bundle.ts:40) gains rankB, rankRrf, rankReranker (each
number | null); buildBundle writes them into rank instead of the three nulls.
5. Stage b#
5.1 Model table (semantic/models.ts)#
Both candidates are covered; the measurement decides which one the docs recommend.
export type Pooling = "cls" | "last_token";
export interface ModelFile { path: string; sha256: string; bytes: number }
export interface ModelSpec {
id: "bge-m3" | "qwen3-embedding-0.6b"; // config value and vec_rule.model
repo: string; revision: string; // Hugging Face repo and pinned commit (40 hex)
dtype: "q8" | "fp32" | "fp16";
files: readonly ModelFile[]; // every file transformers.js reads, relative to <repo>/
pooling: Pooling; dims: number; maxTokens: number;
queryPrefix: string; documentPrefix: string;
}
id |
repo |
dtype | pooling | dims | query prefix | document prefix |
|---|---|---|---|---|---|---|
bge-m3 |
Xenova/bge-m3 |
q8 | cls |
1024 | "" |
"" |
qwen3-embedding-0.6b |
onnx-community/Qwen3-Embedding-0.6B-ONNX |
per measurement | last_token |
1024 | "Instruct: Given a question or message, retrieve stored business rules that are relevant to it\nQuery: " (with the trailing space) |
"" |
revision, files[].sha256 and files[].bytes are taken once from the pinned commit when the
model is added (download, hash, record); a unit test checks that every entry has a 40-hex revision
and 64-hex hashes. The Qwen instruction text is the exact text of the measurement (src/search/semantic/models.ts); changing it needs a new measurement.
maxTokens 512: a rule is short (statement ≤ 500 chars); a long query is truncated by the
tokenizer.
5.2 Loading without network#
transformers-embedder.ts does await import("@huggingface/transformers"); ERR_MODULE_NOT_FOUND
→ package-missing. Before the import it checks the files with verifyModelFiles (size, then
SHA-256 streamed, once per start, in the background). Then: env.allowRemoteModels = false,
env.allowLocalModels = true, env.localModelPath = model_dir, env.useFSCache = false (names checked against the pinned version);
AutoTokenizer and AutoModel.from_pretrained(repo, { dtype, local_files_only: true }). Pooling
and normalisation in pool() from the token-level output and the attention mask (CLS = position 0,
last token = last position with mask 1), so the result does not depend on which pooling options
the pinned version of the library knows. Package version exact in optionalDependencies (no ^);
onnxruntime-node comes with it (check pnpm build-script allowance, section 14).
Queue: one inference at a time; queries go before indexing batches; at most 4 waiting queries,
more → failed "embedder busy". An inference cannot be cancelled: at the deadline the stage stops
waiting, the result is discarded.
5.3 What is embedded and when#
embeddingText = statement + \n + keywords joined by , + \n + relations as
kind name. No quote, speaker or source link: the quote is source text (often personal data),
JEV was measured on rule texts, and a source redaction then never touches vectors.
Indexing cannot run in the write transaction (async). Proposal: background catch-up.
ScopeStoregets an optionalonWrite(scope)listener;recordWritecalls it after the commit (the five existing call sites need no change).main.tswires it toindexer.notify.reindexRule(src/db/fts.ts:84) also callsinvalidateVectors(db)(drops the matrix cache; no SQL onvec_rule).- The indexer, per scope with b available:
missingVectors(current, not rejected, not redacted versions without a row of the configured model, dims and text hash) → batches of 8 →embed(…, "document")→writeVectors. Runs at start for every scope (catch-up after crash, restore, model change) and 500 ms after the lastnotify. writeVectorswrites in one short transaction and discards a row when, at write time, itsversionis no longer the current version, the rule is rejected or redacted, or the text hash differs from the currentembeddingText. So an in-flight embedding can never bring back a redacted text. In the same transaction it deletes older-version rows of the sameseq.- Search reports gaps (section 9): rows of another model or dimension count as missing.
5.4 Scoring#
nearest() keeps one matrix per scope connection (WeakMap<Db, …> like lexicalIndexes,
src/db/fts.ts:64): Float32Array of n × dims plus seq, version, rule_id, status, built from
vec_rule joined with current_version (v.version = c.version, v.model = spec.id,
v.dims = spec.dims). Vectors are normalised, cosine = dot product. Proposals only with
include_proposals. Top SEMANTIC_POOL = 50 by cosine, ties by seq. Memory ≈ 4 KB per rule
(10 000 rules ≈ 40 MB); 10 000 × 1024 multiply-adds ≈ a few ms.
6. Storage#
6.1 Scope migration 0005-vectors.ts (section 14.4)#
CREATE TABLE vec_rule ( -- derived; rebuilt from rule_version at any time
seq INTEGER NOT NULL REFERENCES rule(seq),
version INTEGER NOT NULL CHECK (version >= 1),
model TEXT NOT NULL CHECK (length(model) BETWEEN 1 AND 64), -- ModelSpec.id
dims INTEGER NOT NULL CHECK (dims BETWEEN 1 AND 8192),
text_sha256 TEXT NOT NULL CHECK (length(text_sha256) = 64), -- of embeddingText()
vector BLOB NOT NULL CHECK (length(vector) = dims * 4), -- float32 little-endian (explicit), L2-normalized
embedded_at TEXT NOT NULL CHECK (embedded_at GLOB '????-??-??T??:??:??*Z'),
PRIMARY KEY (seq, version));
CREATE INDEX vec_rule_model ON vec_rule (model, dims);
No WITHOUT ROWID (rows of 4 KB). Not append-only (no trigger), so backup verify
(not-ahead-of-live, export-hashes) ignores it; not part of the export. The BLOB is written and read as explicit little-endian; the vector store
checks os.endianness(), and on a big-endian host stage b is failed (the service still starts, stage a runs).
6.2 Ops migration 0004-ai-usage.ts#
CREATE TABLE ai_usage (
month TEXT NOT NULL CHECK (month GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]'), -- in config.zone
provider TEXT NOT NULL CHECK (provider IN ('typesafe')),
model TEXT NOT NULL,
scope TEXT NOT NULL,
calls INTEGER NOT NULL DEFAULT 0 CHECK (calls >= 0),
unmetered_calls INTEGER NOT NULL DEFAULT 0 CHECK (unmetered_calls >= 0), -- timeout/network: tokens unknown
input_tokens INTEGER NOT NULL DEFAULT 0 CHECK (input_tokens >= 0),
output_tokens INTEGER NOT NULL DEFAULT 0 CHECK (output_tokens >= 0),
cost_nusd INTEGER NOT NULL DEFAULT 0 CHECK (cost_nusd >= 0), -- nano-USD, exact integer
updated_at TEXT NOT NULL CHECK (updated_at GLOB '????-??-??T??:??:??*Z'),
PRIMARY KEY (month, provider, model, scope));
CREATE TRIGGER ai_usage_grows BEFORE UPDATE ON ai_usage
WHEN NEW.calls < OLD.calls OR NEW.unmetered_calls < OLD.unmetered_calls OR NEW.input_tokens < OLD.input_tokens
OR NEW.output_tokens < OLD.output_tokens OR NEW.cost_nusd < OLD.cost_nusd
BEGIN SELECT RAISE(ABORT, 'append-only'); END;
CREATE TRIGGER ai_usage_no_delete BEFORE DELETE ON ai_usage BEGIN SELECT RAISE(ABORT, 'append-only'); END;
recordUsage upserts (ON CONFLICT … DO UPDATE SET calls = calls + excluded.calls, …); cost =
input_tokens × 42 nano-USD (JEV_MODELS). spentThisMonthUsd = SUM(cost_nusd) / 1e9 for
monthIn(zone, now). The month is the calendar month in config.zone (2026-12-31T23:30Z is
2027-01 in Europe/Berlin). Concurrent searches may overshoot the cap by the calls already in
flight (cents at most); an unmetered call (timeout) is booked with an estimate of its input tokens
like every call without usage (section 15.11). A usage write that fails (SQLITE_BUSY) is kept in
memory per ai_usage row, counts against the cap and is written before the next budget check and
with the next usage.
7. Flow of search() and time budget#
async search(deps, caller, request)
t0 = monotonic()
b = stages.b.start({query, deadline: t0 + SEMANTIC_DEADLINE_MS, signal}) // starts the query embedding (ORT threads)
per scope: a-hits = runStageA(...) // synchronous, runs while b embeds
per scope (parallel): b-outcome = availability(scope) ?? await b.run({scope, db, candidates: a-hits})
per scope: pool = a-hits ∪ b-hits // every a-hit stays (recall floor)
order = b ran ? rrf([a order, b order]) : a order
b-only hits: coverage, matched_terms, found_via via LexicalResult.explain(seqs); found_via += "semantic"
heads = resolve → relation → entity → dedupe (as scopeHeads today)
c = stages.c.start({query, deadline: now + reranker.timeout_ms, signal})
per scope (parallel): c-outcome = availability(scope) ?? await c.run({scope, db, candidates: heads[0..50)})
c ran: keep heads with score ≥ threshold, order by score desc (ties rrf, then seq); heads past 50 drop
c not ran: min_coverage as today
top `limit` per scope → interleave → bundles → search_log per scope → abstention
SEMANTIC_DEADLINE_MS = 1000(code constant, see section 14), c:reranker.timeout_ms. Worst case ≈ a + 1000 ms + 1000 ms; usual (measured JEV p50 171 ms) a + embedding + ~200 ms.rank.b= cosine, 4 decimals, null if b did not find the head;rank.rrf= RRF sum, 6 decimals, null if b did not run;rank.reranker= JEVnoul, 3 decimals, null if not sent or c did not run.- c sends per scope one request:
state.request=request.text(or the terms joined by spaces), questions keyed"0"…"49"(positions, no rule ids or scope names),instructions.rule= the head'sstatementonly, with thecriteriaof the measurement (fixed text injev-client.ts). Retry once on 429/529 ifRetry-After(or 200 ms) still fits before the deadline; no retry on timeout, network or other status. Any missing or out-of-range answer →failed. - Before the call:
spentThisMonthUsd(…) ≥ monthly_cap_usd→budget, no call. After it:recordUsage(also for a failed call:unmetered_calls + 1when tokens are unknown). - Cross-scope aggregation (response contract unchanged):
stages.x.state=ranif the stage ran in at least one scope, else the state of the first scope;duration_ms= the longest;reason= null when all scopes agree on a null reason, else"<scope>: <reason>; …"in request order.search_log.stagesof each scope holds that scope's states. - Reasons carry counts and limits only, never query or rule text (they land in
search_log).
Async callers: src/http/routes/search.ts:16 → await search(…);
src/mcp/tools/search-rules.ts:29 → runToolAsync(…, async () => …) (new in src/mcp/result.ts,
the error mapping of runTool moves into a shared function); test/search/support.ts:4 and every
test using it → await. SearchDeps gains stages: { b: SearchStage | null; c: SearchStage | null },
ops: OpsStore, monotonic?: () => number.
8. Configuration#
const SemanticModel = z.enum(["bge-m3", "qwen3-embedding-0.6b"]);
const Semantic = z.discriminatedUnion("enabled", [
z.object({ enabled: z.literal(false) }).strict(),
z.object({
enabled: z.literal(true),
model: SemanticModel.default("bge-m3"), // stored in every vec_rule row
model_dir: z.string().min(1).optional(), // absolute or relative to GROUNDNOTE_DATA_DIR; default <data_dir>/models
}).strict(),
]);
const Reranker = z.discriminatedUnion("enabled", [
z.object({ enabled: z.literal(false) }).strict(),
z.object({
enabled: z.literal(true),
provider: z.literal("typesafe").default("typesafe"),
model: z.enum(["jev-1.14.0"]).default("jev-1.14.0"), // pinned; a new model is a code change with a re-measure
monthly_cap_usd: z.number().min(0).max(1000).default(5),
timeout_ms: z.number().int().min(100).max(5000).default(1000),
threshold: z.number().min(0).max(1).default(0.5),
}).strict(),
]);
// search: { …, semantic: Semantic.prefault({ enabled: false }), reranker: Reranker.prefault({ enabled: false }) }
// scopes[]: { name, providers_allowed, reranker: z.boolean().default(false) }
| Owner | Rule |
|---|---|
search.semantic |
b runs in a scope only with enabled: true and local in its providers_allowed. |
search.reranker + scopes[].reranker |
c runs in a scope only with enabled: true, scopes[].reranker: true and typesafe in providers_allowed; default off everywhere. |
TYPESAFE_API_KEY (env, never config) |
Required (start refused, reranker-key-missing) when at least one scope can run c; must differ from every other secret; never logged. |
| start warnings | scopes[].reranker: true without typesafe allowed (reranker-not-allowed); semantic enabled but package or model missing or the model directory unsafe (semantic-unavailable). Neither refuses the start. |
model_dir |
Not inside scopes/ (start refused, config-invalid); files are checked by hash. Outside the data directory it gets the permission check of threat model F2 (modelDirPermissionProblem): resolved to its real path, it and every entry below it are owned by this user, not group- or world-writable and no symlinks; every parent is owned by root or this user and not writable by others unless sticky. A problem, or a path the check cannot read (EACCES), is no refusal: the load fails with a ModelDirReason, b stays failed and the log names the path. |
Docs: providers.md "Configuration" gets this shape; next to scopes[].reranker it repeats that
query and candidate statements are processed by TypeSafe AI in the USA.
9. States and reasons#
| Stage | state | reason (exact text pattern) |
|---|---|---|
| b | off |
disabled in config |
| b | not-allowed |
provider local not allowed for this scope |
| b | failed |
package @huggingface/transformers not installed |
| b | failed |
model <id> not downloaded (groundnote model fetch) |
| b | failed |
model <id> files do not match their checksums |
| b | failed |
model <id> is loading |
| b | failed |
model directory contains a symlink · model directory is not owned by this user · model directory is group- or world-writable · a parent of the model directory is not owned by root or this user, or writable by others · model directory cannot be checked (path only in the log) |
| b | failed |
vector store needs a little-endian host (the vector matrix is float32 little-endian) |
| b | failed |
index empty: 0 of <n> rules embedded, indexing in background |
| b | failed |
deadline of <ms> ms exceeded · embedder busy · embedding error (detail only in the log) |
| b | ran |
null, or index incomplete: <m> of <n> rules (<p> %) not embedded yet |
| c | off |
disabled in config · disabled for this scope |
| c | not-allowed |
provider typesafe not allowed for this scope |
| c | budget |
monthly cap of <cap> USD reached (<YYYY-MM>, <zone>) |
| c | failed |
timeout after <ms> ms · provider answered <status> · network error · invalid answer |
| c | ran |
null; nothing to rerank when no head reached c (no call, no cost) |
| b, c | failed |
internal error when the stage throws (section 15.13; class and code only in the log) |
10. Commands#
groundnote model fetch [--model <id>] [--json](local, no ops socket; model andmodel_dirfromgroundnote.json): downloadshttps://huggingface.co/<repo>/resolve/<revision>/<path>(https only, also after redirects) into<model_dir>/<repo>/<path>.part, hashes while streaming, renames only on a SHA-256 match; directories0700, files0600; a present file with the right hash is skipped. Mismatch →.partdeleted, exit 3.groundnote model verify [--model <id>]→ exit 3 on any mismatch. The service never downloads.- Rebuild (route name from architecture §12 kept):
POST /ops/vector-index/:scope/rebuild→ 202{scope, model, queued}; unknown scope 400scope-unknown; b unavailable 409semantic-unavailable. Re-embeds every current version in place (search keeps serving the old rows until each is replaced).GET /ops/vector-index→[{scope, model, total, embedded, other_model, running, last_error}]. CLI:groundnote vector-index rebuild --scope <s>,groundnote vector-index status [--json]. groundnote doctor(15.3) shows b readiness per scope andspentThisMonthUsdagainst the cap.
11. Redaction#
src/redaction/purge.ts calls, inside the purge transaction, deleteRuleVectors(db, ruleId) for
every redacted rule (all versions) and invalidateVectors(db); the following VACUUM of the scope
file (redaction.md §4) covers the freed pages, secure_delete overwrites them already. Source,
entity and link targets leave vec_rule untouched (no quote, speaker or source link is embedded).
missingVectors and writeVectors skip redacted rules, so the indexer never re-embeds them.
deleteVersionVectors(db, ruleId, version) exists for a single-version redaction (D-R4: not in
Phase 1) and for tests.
12. Test plan (no network in tests)#
A vitest setup file (new, in vitest.config.ts) replaces globalThis.fetch with a function that fails the test for any non-loopback
URL. The embedder is a FakeEmbedder (deterministic vectors from a word-hash, controllable delay);
JEV is a fetch fake returning scripted responses.
| Area | Cases |
|---|---|
| models | every entry: 40-hex revision, 64-hex hashes, dims > 0, pooling known |
pool() |
CLS picks position 0; last token honours the mask (left and right padding); output L2-normalized |
| vector-store | BLOB round trip (LE); missingVectors for new rule, new version, other model, other dims, changed text hash, rejected, redacted; writeVectors discards a stale in-flight row; older-version rows deleted; delete functions return counts and clear the cache |
nearest() |
ties by seq, k bound, proposals only on request, only current versions |
rrf() |
k = 60 formula; a-only, b-only, both; rounding of rank.rrf |
| stage b | every state of section 9 with the fake loader and fake clock; query embedded once for several scopes; queue priority and the busy limit |
| indexer | catch-up at start, notify debounce, model change re-embeds, rebuild in place, stop |
| jev-client | request body snapshot (model, state.request, positional ids, criteria, no ids or scope names); 429→200; 529→200; 429 twice → failed; Retry-After too long → no retry; 500 no retry; timeout; invalid JSON; missing answer |
| budget | monthIn at year end and DST change in Europe/Berlin; upsert sums; nano-USD exact; cap reached → budget and no fetch; trigger refuses lower counters and deletes |
| stage c | off (global, scope), not-allowed, budget, each failure → result equals a+b exactly; threshold filter and order; rank.reranker |
search() |
b and c off: responses identical to today (existing tests unchanged apart from await); a+b: b-only head with found_via semantic and coverage; every a candidate stays in the pool; cross-scope aggregation; per-scope search_log.stages; property test: random query text never appears in stages |
| config | each new key, defaults, strict unknown keys, key missing/equal → start refused, warnings |
model fetch |
fake fetch: .part, rename, modes, mismatch exit 3, skip present, https-only redirect |
| architecture | import and SQL boundaries of section 3 |
A manual eval pnpm eval:semantic with the real model runs only when package and model are present
(skipped otherwise, never in CI).
13. Follow-ups for the documentation (done 2026-11-06)#
architecture.md §3, §6.5, §7.8, §11, §12, §15.4; abweichungen.md D2, D3, D101; providers.md;
glossary.md (<scope>.vector.sqlite is gone, timeout_ms); threat-model.md (external judge,
supply chain); redaction.md §4.
14. Decisions on the open questions (2026-11-06)#
- Key name:
timeout_ms, as confirmed by the coordinator (the CLIdoctorreads it); glossary.md line 130 follows.candidates(50) stays a code constant. - Empty result after c:
abstained.reasongainsbelow-reranker-threshold(additive). min_coveragewith b/c: where c ran in a scope, its threshold replacesmin_coveragethere; without c,min_coverageapplies to all heads as today.- Migration numbers: scope
0005-vectors.ts(feat/approval-modes holds0004-approval.tsand merges first), ops0004-ai-usage.ts. The redaction worker counts from0006. SEMANTIC_DEADLINE_MS: code constant, 1000 ms.- Docker image: stays lexical-only; stage b needs the optional package and a fetched model, documented in providers.md.
- Channels: c runs on every channel of a scope where it is switched on; providers.md recommends it for proactive mail matching.
- Default model:
bge-m3(modeldefaults to it whenenabled: true). Measured on the synthetic set (300 rules, 180 queries, stage b alone, q8): bge-m3 Recall@5 0.97, MRR 0.89, query p50 12 ms, +1.3 GB RSS, 587 MB on disk, MIT; qwen3-embedding-0.6b (with query instruction) Recall@5 0.92, MRR 0.79, p50 39 ms, +1.5 GB RSS, 625 MB, Apache-2.0.
15. Deviations of the build from the cut#
opsbelongs to the stage, not toSearchDeps.RerankStagereceives the ops store in its constructor;SearchDepsgains onlystages: { b, c }andmonotonic?.RerankSearchStagecarriestimeoutMs(search.reranker.timeout_ms), sosearch()sets the deadline of stage c from the stage, not from a constant.JevResulthas a fourth failure reasoninvalid(answer parsed but unusable: missing or out-of-range score, wrong shape); it maps tofailedwithinvalid answer.- Stage c is always built (not
nullwhen off). It answersavailability()withoffornot-alloweditself, so the aggregation insearch()has one path. - Equal score, pool order. Where two heads have the same c score (or b cosine), the order of
the pool decides, not
seq. - Package import before
verifyModelFiles.transformers-embedder.tsfirst imports the package (package-missingis cheap to detect), then hashes the model files. - One reason without a
scope:prefix when all scopes agree. The prefix form of section 7 applies only if the scopes differ in state or reason.
Added during the build (built; the code is authoritative):
- Minimum cosine 0.45 for b neighbours. A neighbour below 0.45 does not enter the pool. The threshold comes from the cosine distribution in section 16 (at 0.45, 88 % of no-answer queries still have a neighbour, 96 % of answerable ones keep the relevant rule).
- The request to c is cut to 4000 characters of query text.
- Response body of JEV at most 1 MiB; larger →
failed. - Calls without
usageare booked with an estimate (characters / 4 as input tokens), and every HTTP attempt counts, also a retry, so the cap cannot be undercut by a provider that omits the field. - Proposals never go to c. Heads with status
proposedare appended after the c-ranked heads in a+b order; theirrank.rerankerisnull. Foreign text from a proposal thus never reaches the external judge. - A stage that throws is
failedwithinternal error. The search answers with the result of a (or a+b); no exception reaches the caller.
16. Measurement (pnpm eval:semantic, eval/semantic/results/latest.md)#
Synthetic set: 300 rules, 180 queries, bge-m3 (q8), limit 10, through the real search(); the
variants are a, ab (a + b, RRF) and abc (+ JEV).
| Variant | R@5 | Hit@5 | MRR | nDCG@5 | no-answer queries with a hit | p50 / p95 ms |
|---|---|---|---|---|---|---|
| a | 0.73 | 0.88 | 0.83 | 0.75 | 16/16 | 5 / 8 |
| ab | 0.84 | 0.95 | 0.89 | 0.83 | 16/16 | 25 / 114 |
| abc | 0.90 | 0.99 | 0.98 | 0.94 | 5/16 | 241 / 309 |
The largest gains from b are in paraphrase (R@5 0.30 → 0.54) and cross_lingual (0.48 → 0.85);
c lifts paraphrase to 0.84 and long_mail to 0.89. JEV: 180 calls, 1 441 694 input tokens,
0.0606 USD for the run. Peak memory with the model loaded was 6.6 GB RSS in the eval process.
Real data (74 rules, 52 queries): a Hit@5 0.82, Recall@5 0.75, MRR 0.66, false alarms 11/12; ab Hit@5 0.88, Recall@5 0.82, MRR 0.77, false alarms 12/12.
False alarms (a no-answer query that still returns a head) come from stage a already when
min_coverage is 0; b adds no filter, and only stage c lowers them (synthetic: 16/16 → 5/16).
For b alone the minimum cosine of section 15.8 is the lever.
Scale of nearest() (random unit vectors, 1024 dims, top 50, 40 queries):
| Rules in one scope | first call with matrix build | p50 | p95 | matrix cache |
|---|---|---|---|---|
| 20 000 | 409 ms | 17.6 ms | 20.3 ms | 140 MB |
| 50 000 | 1004 ms | 42.8 ms | 47.9 ms | 388 MB |
Linear extrapolation: nearest() passes 100 ms at about 98 000 rules per scope. From there,
consider an index extension (sqlite-vec) instead of the JS brute force.
Known limit: matrix rebuild after every write. Each write to a rule or a vector of a scope drops its matrix cache, and the next search rebuilds it synchronously on the event loop: 409 ms at 20 000 and about 1 s at 50 000 rules (table above). In that time the process answers no other request. An incremental update of the cached matrix is not built.
Model comparison bge-m3 against qwen3-embedding-0.6b: see section 14.8 (bge-m3 is the default).