Skip to content

Embeddings

Semantic search and graph-seeded retrieval both need vectors, produced by an embeddings provider. embeddings is absent from a minimal config on purpose: the default provider is local, a bundled, fully offline dense embedder — no Ollama, no API key, no network call after the first run. On npm it is an explicit install: obsidian-tc does not install the embedder package, so run npm install -g @the-40-thieves/obsidian-tc-embedder-local@<same version> (or let obsidian-tc setup offer to). A source checkout and the Docker image need nothing extra; see Availability by install method below for the exact current state.

{
"vaults": [{ "id": "primary", "path": "/home/user/vaults/primary" }]
}

With no embeddings block at all, indexing and semantic search work immediately, PROVIDED the local provider resolves on this install (see Availability by install method below — on npm that means the embedder package is installed alongside it). On its first embed call, the server downloads a quantized ONNX model (~137 MB) (nomic-embed-text-v1.5, Apache-2.0 licensed, 768 dimensions) from Hugging Face, checksum-verifies it file-by-file, and caches it under <cacheDir>/models/embedder-local/. Every embed call after that is offline — no network, no external service. Two smaller, faster catalog entries (~23-34 MB, 384 dimensions) are also available — see Model choice: measured, not assumed for why they are not the default.

Runtime: @huggingface/transformers v4 (Transformers.js) running on CPU, via the optional @the-40-thieves/obsidian-tc-embedder-local package — the same “small optional package, resolved at runtime, never a hard dependency of the server” shape as the local reranker. It is reachable from the npm install (once the embedder is installed), the standalone bun --compile binary (compiled in) and the one-click .mcpb bundle (bundled), with the one exception of the macOS x64 binary (see Availability by install method below).

To choose explicitly, or to pick a different catalog model:

{
"embeddings": {
"provider": "local",
"model": "all-MiniLM-L6-v2",
"quantized": true
}
}
Field Type, default Meaning
model string, nomic-embed-text-v1.5 One of the pinned catalog names: all-MiniLM-L6-v2, bge-small-en-v1.5, nomic-embed-text-v1.5. Only these three are supported under provider: "local" — the download is checksum-verified against a pinned manifest, which requires knowing the exact bytes ahead of time. An unrecognized name is refused with the supported list.
quantized boolean, true true loads the pinned q8 (int8) ONNX export; false loads the pinned fp32 export — larger, slower, marginally more precise. Both variants are separately checksummed.
threads int, unset onnxruntime-node intra-/inter-op thread count override. Unset does NOT leave this to the runtime’s own default (GH #995) — it caps the intra-op pool to a quarter of the host’s available CPU cores (minimum 1), sets inter-op threads to 1, and disables ORT thread spinning. An explicit value overrides that default outright for both intra- and inter-op.
host "in-process" or "child-process", "in-process" Where the local model runs. "in-process" (default) runs it inside the server process. "child-process" runs it in a child process the server starts on the first embed and stops when the embedder is recycled or closed, so the model’s memory can return to the OS. It applies only to provider: "local": any other provider with "child-process" is refused, except that a vault whose existing index was kept on another provider falls back to "in-process" with a warning. A child that dies is retried once; three deaths within 60 s refuse embeds for 60 s. The cost: until the recycle policy runs, the parent and child together hold about the same memory as in-process (or a little more). The gain is that the child can be stopped to return its memory. Right after a respawn the child’s memory is unknown until its first stats reply, so server_health reports over_budget as null until then.
dimensions int, model-native Set automatically from model (768 for nomic-embed-text-v1.5, 384 for the smaller MiniLM/bge-small entries) — only override this if you also set truncate (see Embedding model migration).

Three candidates were benchmarked against a public, third-party-judged corpus before picking the default — see docs/EVALUATION.md for the full table (nDCG@10, recall, first-index time, model size, RAM) and the non-inferiority bar each candidate had to clear. The result was not the smaller/faster pick: both 384-dimension candidates (all-MiniLM-L6-v2, bge-small-en-v1.5), each run with its own correct pooling strategy, failed strict nDCG@10’s −0.015 non-inferiority floor against nomic-embed-text-v1.5 run through the identical code path (one-sided 95% lower bound −0.151 and −0.110 respectively, n=78). MiniLM’s deficit is real and clearly detected; bge-small’s nDCG@10 does not reach conventional significance at this n, so read that one number as non-inferiority not established at this corpus’s resolution rather than a pass — its recall@10 IS significant, and it still fails the floor on its own lower bound either way. nomic-embed-text-v1.5 is the default as the conservative choice under this underpowered comparison, not a claimed decisive win; the smaller models remain available via embeddings.model for deployments that prioritize download size or CPU cost over this measurement.

Two other candidates were evaluated and dropped before reaching the measurement stage, not silently excluded: EmbeddingGemma-300M’s model card carries Google’s Gemma Terms of Use — a custom license with a unilaterally-updatable prohibited-use policy and redistribution obligations that do not fit “auto-downloaded by default from every install of an AGPL-3.0 public server” — and a model2vec/potion static-embedding model has no Transformers.js-loadable ONNX export today (verified directly: it fails at inference with a missing-input error, not a licensing one).

local resolves the optional @the-40-thieves/obsidian-tc-embedder-local package the same way the local reranker resolves its own package — a published-npm route, a source-checkout route (for anyone developing inside the monorepo), and an explicit-path escape hatch. The package is published to npm, but npm install obsidian-tc does not install it: install it at the same version, and the resolution below then finds it. The published embedder is generated: Transformers.js and the ORT JavaScript shim are bundled into the base package, which has no runtime dependencies, and six exact-pinned optional packages carry the platform runtimes. npm’s os/cpu filters install only the host package (Linux packages also require glibc). Those native files cannot be loaded from inside a single-file executable or a bundle as they are, so scripts/build-binary.ts (the standalone binaries) and scripts/bundle-mcpb.ts (the .mcpb) bundle the embedder with a few build-time rewrites (scripts/lib/embedder-bundle.mjs) and ship the platform’s ONNX runtime files alongside: embedded in the binary and unpacked into <cacheDir>/runtime/ on first use, or sitting next to the bundle. Native targets carry the binding and runtime library; macOS x64 carries onnxruntime-web’s WASM loader and binary. The local reranker has no such packaging, so it stays unreachable there. Practically, today:

Install method local embedder
A source checkout of this monorepo (git clone + bun install) Works — resolves via the source-checkout route once packages/embedder-local is built. A root bun install + bun run build does not build it, so run (cd packages/embedder-local && bun install && bun run build); the root build warns with these commands when it is missing. CI does this automatically.
Docker (GHCR) Works — the image builds packages/embedder-local from source in the same stage as the server and copies its built package (dist + node_modules) into the same relative path the source-checkout resolution route walks for, so the identical route resolves inside the container.
npm (npm install -g obsidian-tc) Works once the embedder is installed. obsidian-tc does not install @the-40-thieves/obsidian-tc-embedder-local by default. Install it with npm install -g @the-40-thieves/obsidian-tc-embedder-local@<same version>, or let obsidian-tc setup offer to. Then it pulls the generated embedder base and only @the-40-thieves/obsidian-tc-embedder-local-<os>-<arch> for the host. The first-run matrix (ci-first-run-smoke) runs a real semantic search on the published package on Linux, macOS and Windows. Installing the embedder with --omit=optional/--no-optional omits its host runtime.
Standalone binary (bun --compile) Works on Linux x64 and arm64, macOS arm64, macOS x64 and Windows x64: compiled in, about 14 MB larger. macOS x64 uses WASM.
One-click .mcpb bundle Works on Linux x64 and arm64, macOS arm64, macOS x64 and Windows x64 (the bundle carries every required ONNX runtime; it is about 66 MB).

An unresolvable local provider does not crash boot — the same graceful degradation an unreachable Ollama endpoint has always had (a [index] reconcile degraded notice, FTS/lexical search stays fully functional). obsidian-tc doctor’s check (a doctor check id, not a config path, named embeddings.buildable ) distinguishes: WARN when running from a source checkout where the package simply hasn’t been built yet (a one-command fix) or when the platform genuinely has no onnxruntime-node prebuild AND another provider is already configured, and FAIL — with remediation naming the exact fix — when the platform IS supported but the package still cannot resolve (for example an embedder package that was never installed alongside obsidian-tc). A local embedder that resolves is still a WARN in a plain run: resolving it never loads the model. obsidian-tc doctor --probe embeds one probe string through the configured provider and reports whether it answered.

Intel macOS uses WASM instead of a native ONNX Runtime binding. It produces the same embedding results, but measured embedding time is roughly 4x slower than the native backend. A future target with neither a configured native runtime nor this WASM route will still need an explicit hosted or self-hosted embeddings.provider.

npm installs only one ONNX runtime. The old source-package layout installed about 585 MB of node_modules because @huggingface/transformers hard-depended on both onnxruntime-node (with every native platform) and onnxruntime-web. The generated npm package bundles the JavaScript and selects one optional runtime package, roughly 15-46 MB of runtime files depending on the host, plus the small bundled base. Model weights remain separate: about 137 MB for the default, or 23-34 MB for the smaller catalog entries, downloaded on first use. A source checkout still keeps the full dependency tree under packages/embedder-local/node_modules because it builds every published target.

RAM: peak RSS during first-index varies by catalog entry — see the per-model column in the measurement table rather than a single number here, since all-MiniLM-L6-v2/bge-small-en-v1.5 (384-dim) and the default nomic-embed-text-v1.5 (768-dim) measure differently. This is the obsidian-tc index process’s own footprint (model load + ONNX runtime + the batch of vectors being produced), not additive with any other provider’s, since a given process runs only the one provider it is configured for.

embeddings.quantized toggles automatically trigger a rebuild; a future catalog revision bump does not. Switching quantized between its default true (q8) and false (fp32) — same catalog model name, same dimensions — changes which ONNX export produces the vectors, so it is folded into vec_index_fingerprint and rebuilds vec_chunks on its own. A pinned catalog entry’s underlying Hugging Face revision, by contrast, is a package-internal constant (packages/embedder-local/src/model-info.ts’s revision field) that does not flow into the fingerprint automatically — this matches how every OTHER built-in provider already handles the same concern (model-tier’s own per-service revision fields are explicitly “provenance only” in config.schema.ts’s own description; the documented fix there is the same top-level embeddings.revision this paragraph is about). If a future @the-40-thieves/obsidian-tc-embedder-local release repins a catalog entry’s Hugging Face revision under the same catalog name, set embeddings.revision explicitly (any string) after upgrading to force a rebuild — omitting it reproduces today’s behavior (the previous checkpoint’s vectors keep serving) exactly, same as it does for every other provider.

The local embedder runs inside the server process, so its memory is the server’s memory. Resident memory rises with the number of chunks in one embed request, because each request’s ONNX activations are held until it returns. For the default nomic-embed-text-v1.5 (q8) the measured curve is roughly:

Chunks per request Resident memory (approximate)
1 ~0.5 GB
4 ~1.1 GB
32 ~1.9-2.7 GB

With provider: "local" the batching defaults are embeddings.maxBatchTokens 512 and embeddings.concurrency 1, which keep requests small. Every other provider keeps 2048 and 4. Setting either key explicitly overrides the local default. Lower concurrency makes a full reindex slower, so raise it only when the host has memory to spare.

For a host under 16 GB, a smaller catalog entry is the practical choice. all-MiniLM-L6-v2 (~23 MB q8) and bge-small-en-v1.5 (~34 MB q8) both measured about 1.1-1.3 GB peak RSS on the evaluation host, against about 2.7 GB for the default. Their retrieval quality is lower: neither clears the non-inferiority floor that the default was chosen against, so expect weaker recall. The per-model numbers are in docs/EVALUATION.md.

{
"embeddings": { "provider": "local", "model": "all-MiniLM-L6-v2" }
}

Switching models on a live index is a coordinated migration; see Changing providers or models on a live index.

Memory visibility and a budget are added by the memory-reporting change: server_health reports process memory, and embeddings.memoryBudgetMb sets a budget for it. These are available from the next release; earlier releases have neither key.

Every built-in from before this default changed is unchanged and still fully supported — local only changes what an absent block resolves to.

{
"embeddings": {
"provider": "openai",
"model": "text-embedding-3-small",
"dimensions": 1536,
"apiKeyEnv": "OPENAI_API_KEY"
}
}

Built-ins: openai, voyage, cohere, bge-m3, model-tier (splits dense and multi-vector across two services), the generic openai-compatible (any OpenAI-embeddings-shaped endpoint — LM Studio, vLLM, a gateway), ollama (deprecated but fully functional — see below), and the profile-gated module escape hatch. See Configuration reference for the complete field list.

ollama (model nomic-embed-text) was previously the zero-config default. Setting it explicitly still works exactly as it always has, including the implicit model — { "embeddings": { "provider": "ollama" } } with no model set still resolves to nomic-embed-text/768, the same pairing it always has. (This is restored one layer above the raw config schema — see Upgrading from a pre-local-embedder config below for exactly what changed and what didn’t.)

{
"embeddings": {
"provider": "ollama",
"model": "nomic-embed-text",
"dimensions": 768
}
}

It is marked deprecated (obsidian-tc doctor surfaces an advisory note, not a warning) because the generic openai-compatible adapter already serves Ollama’s OpenAI-shaped /v1/embeddings endpoint. Do not switch a live deployment without planning a re-embed: the internal provider.id identity field is the vector-index fingerprint, so changing it re-embeds the whole vault; see Embedding model migration.

Changing providers or models on a live index

Section titled “Changing providers or models on a live index”

A provider or model change is a coordinated migration, not a drive-by config edit — see Embedding model migration for the full procedure. In short: the server detects a stored-vs-configured mismatch automatically (the representation fingerprint folds in provider, model, and dimensions) and rebuilds the vector index rather than silently mixing vectors from two different spaces — but a dimension change also needs a full re-embed, which the migration page walks through.

Upgrading from a pre-local-embedder config

Section titled “Upgrading from a pre-local-embedder config”

If your config has no embeddings.provider set at all, the schema’s own default resolves to the in-process embedder (provider: "local", model nomic-embed-text-v1.5). obsidian-tc 1.31.4 and 1.31.5 applied that default unconditionally, so an existing install that had never configured embeddings was silently switched away from whatever it was already using (for most installs, Ollama) and fully re-embedded in-process on the next boot — see GH #995.

1.31.6 and later keep an existing index’s provider instead. When embeddings.provider is unset and your vault’s cache already holds active vectors from a different provider than the current default, the server keeps using that provider/model rather than switching — no re-embed, no config change required. This is controlled by embeddings.onProviderChange (default "keep"):

  • "keep" (default): an unconfigured install keeps its existing index’s provider. A fresh install (nothing indexed yet) still takes the current default — there is nothing to keep.
  • "switch": adopts the current default (local) outright, same as a fresh install — this re-embeds the whole vault once.

Setting embeddings.provider explicitly — including provider: "local" itself — always wins over onProviderChange and is itself the opt-in to switch; that also re-embeds the whole vault once (the server detects the stored-vs-configured mismatch automatically, the same way any other provider/model change does — see Changing providers or models on a live index above).

obsidian-tc doctor, the boot log, and obsidian-tc config show/config explain all report which of these ways the effective provider was decided — configured (you set it), kept-from-index (kept from your existing vault, GH #995), default (nothing configured, nothing to keep), or ambiguous-orphaned-index (see below) — so you can always tell which one applied. To keep using Ollama explicitly rather than relying on "keep", add "embeddings": { "provider": "ollama" } to your config — the implicit model (nomic-embed-text) is preserved for that one case; see ollama (deprecated, still supported) above.

obsidian-tc config show never lets a kept value masquerade as something you configured. Its plain embeddings block always shows the SCHEMA-resolved value (what your config file/env/profile actually produce) — never a value merely kept from your existing index — and a separate embeddingsEffective field reports the effective provider/model/dimensions plus source and an explicit note when it differs from what you wrote. Do not copy embeddingsEffective back into embeddings.provider unless you actually intend to pin it — writing embeddings.provider explicitly (even to the same value it was already using) IS the opt-in to switch, disables "keep" on the next boot, and re-embeds if the value differs from what was actually stored. obsidian-tc config explain likewise adds an embeddings.effective row (source derived, since it comes from reading cacheDir, not your config file — a synthetic output row, not a real config path) alongside its normal per-key attribution.

Every command that can construct an embedding provider honors this, not just serve — never silently, not even for a command reached indirectly. index, prefetch, gaps, citation-infer, cluster, rerun, and doctor each resolve onProviderChange against their own cache db before constructing an embedding provider — a query command (or a replayed session) run against an unconfigured install never silently embeds under a different provider than the one its vectors were written with. This resolution runs at the ONE construction choke point every one of these commands goes through (directly, or transitively through serve’s own runtime construction), so a new command that constructs a provider is covered by the same rule without having to remember to call it — never-silently-switch is the rule, not a per-command opt-in.

The kept identity carries the stored vector width and model revision, not an assumed historical one. A pre-1.31.4 config could set model/dimensions explicitly (e.g. a 1024-dim mxbai-embed-large) without ever setting provider; keeping now honors the width actually written to chunk_embeddings rather than a fixed 768. Likewise, if the stored chunk_embeddings.model value carries a revision suffix (provider:model@revision), the kept config carries that same embeddings.revision rather than dropping or doubling it.

Renaming a vault’s id no longer orphans its index. obsidian-tc records each vault’s canonical root path in cache.db the first time it sees it. At boot, if a configured vault’s root path matches a path already recorded under a different id, every vault-scoped row (chunks, embeddings, notes, links, sessions — every table keyed on vault_id, across both cache.db and the experiential store) is re-keyed from the old id to the new one automatically, in one pass, before anything else reads them — no re-embed, no manual cacheDir/id juggling. A boot notice (“vault identity: vault “old-id” was renamed to “new-id” … re-keyed”) confirms it happened. Two vaults resolving to the same id but different root paths — including two vaults both left at the zero-config default id "main" — are refused at boot instead, with an error naming both paths: give each vault a distinct id (and, if they still share a cacheDir, a distinct cacheDir too).

Ambiguous vault identity — the residual case. Because renames are now resolved automatically (above), onProviderChange: "keep"‘s ambiguous-orphaned-index source is now rare: it only fires when the cache db (cacheDir) holds active vectors under a vault id that matches no CURRENTLY configured vault id and has no recorded root path either — e.g. a vault entry removed from config outright, or a pre-upgrade cache db whose identity has not been recorded yet. In that case obsidian-tc still refuses to guess: it keeps the orphaned rows’ provider/model/width rather than silently adopting this vault’s own default identity over them, and reports the source as ambiguous-orphaned-index (never default or kept-from-index) with a notice naming the situation, both in obsidian-tc doctor and the boot log. If every orphaned row already belongs to the current default’s own provider family (nothing to keep), this falls back to the current default and says so in the same notice. Either way: re-add the vault to config (or run obsidian-tc doctor to inspect the cache directory’s stored providers), or set embeddings.provider explicitly (or embeddings.onProviderChange: "switch") to make the choice yourself instead of relying on "keep".

An unmappable stored provider fails closed, before any vector-index rebuild. The active chunk_embeddings.model id obsidian-tc would otherwise keep must be reconstructable back into a real provider config (provider:model, or provider:model@revision, for ollama, openai, voyage, cohere, and bge-m3). A stored id outside that set — a custom openai-compatible:... or module:... identity, or anything else obsidian-tc cannot rebuild on its own — is refused rather than guessed: obsidian-tc exits with a config error naming the unmappable stored id and telling you exactly what to set instead — embeddings.provider (plus embeddings.model / embeddings.dimensions if needed) to match whatever that id was actually embedded with. Run obsidian-tc doctor first if you are not sure which model/width that is. Guessing here would rebuild vec_chunks at the wrong width and silently drop every real vector — refusing is the smaller-safe-option.

Config files must set cacheDir when the embeddings provider is local (the default) — the bare obsidian-tc <vault> form (no config file) sets it for you, so this only affects a config file that never named cacheDir. The old .obsidian-tc-under-the-working-directory default is gone because it wrote model weights wherever the server happened to be started; config load now fails at startup, naming cacheDir, rather than that surprising at first embed instead.

Pacing the background re-embed against interactive use

Section titled “Pacing the background re-embed against interactive use”

After an upgrade that re-embeds a whole vault (see above), the leader’s boot/promotion/periodic reconcile used to run that embed pass at full speed the instant it started — competing with whatever tool calls the client was already making for the CPU-bound in-process embed step (the local provider’s ONNX runtime occupies the JS thread for the whole duration of a call). indexing.backgroundEmbed paces every one of those passes instead — boot, promotion catch-up, and the periodic scheduled vault-reconcile job (maintenance.reconcileIntervalMinutes) all share it, since a periodic repair pass is background work too:

{
"indexing": {
"backgroundEmbed": { "mode": "idle", "idleMs": 2000, "maxDeferMs": 30000 }
}
}
  • "idle" (default): before each embed sub-batch, the reconcile waits until the server has had no dispatch activity — no tool call in flight, and none finished more recently than idleMs (default 2000) — before issuing the next provider call. A quiet server (nothing else calling in) pays nothing: the wait only ever engages while there is real contention to defer to.
  • "immediate": runs the embed pass at full speed with no pacing at all — the behavior before this key existed.
  • maxDeferMs (default 30000): a floor under the deferral above. Ordinary polling traffic faster than idleMs (or a handler that stops observing its own abort signal) would otherwise defer a sub-batch forever, since every dispatch resets the quiet window. Past maxDeferMs of continuous deferral, the next sub-batch is admitted as soon as no call is currently in flight — not waiting for a full quiet window — and if no call ever clears at all, a second cap at 2x maxDeferMs admits it unconditionally.

Explicit index_vault calls and index-on-write (a note saved through the client) are never paced, regardless of this setting — those are calls the user asked for directly, not a background catch-up pass, and pacing them would make an intentional reindex feel slower for no benefit. The obsidian_tc_background_embed_paused Prometheus gauge (see Observability) reports whether a background embed pass is currently paused for idle (1) or actively running / not reconciling at all (0).