Skip to content

feat(search): support Ollama embedding models - #84

Merged
harlan-zw merged 7 commits into
skilld-dev:mainfrom
mrrobertkent:feat/ollama-embeddings
Aug 12, 2026
Merged

feat(search): support Ollama embedding models#84
harlan-zw merged 7 commits into
skilld-dev:mainfrom
mrrobertkent:feat/ollama-embeddings

Conversation

@mrrobertkent

@mrrobertkent mrrobertkent commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Stacked on #83. This branches from feat/configurable-embedding-model, which only exists on my fork, so GitHub has to target main and the diff currently shows #83's commit too. Once #83 merges this collapses to a single commit. Reviewing 7e49e0a alone gives the true diff.

Why

Search is limited to the transformers.js models bundled through retriv. Ollama already hosts stronger local embedders (qwen3-embedding, embeddinggemma, nomic-embed-text), and skilld already talks to Ollama for completions, so the capability was one HTTP call away.

Usage

Models are addressed as ollama:<name>, matching the existing enhancement-model syntax. skilld config lists locally-pulled models that advertise the embedding capability, alongside the built-ins:

◆  Embedding model — indexes and queries docs for skilld search
│  ○ BGE small (English)
│  ○ BGE base (English)
│  ● BGE large (English) (1024d · most accurate English retrieval, slowest to index)
│  ○ BGE m3 (multilingual)
│  ○ embeddinggemma:latest
│  ○ nomic-embed-text:latest
│  ○ qwen3-embedding:0.6b
│  ↑/↓ to navigate • Enter: confirm
└

Discovery is additive and never blocks. An unreachable daemon contributes nothing rather than erroring.

No new dependencies

retriv ships an Ollama provider, but it imports ai and ollama-ai-provider-v2. This talks to /api/embed over plain fetch instead, matching src/agent/clis/ollama.ts, which already uses fetch against /api/chat, /api/tags, and /api/show. Dependency footprint is unchanged.

ollamaHost() moves to core/ so the search worker can resolve OLLAMA_HOST without importing the agent registry.

Normalization

This is the part I wanted to verify rather than assume.

The sqlite-vec table is created as vec0(embedding float[N]) with no distance_metric=, so it defaults to L2, and score: 1 / (1 + distance). That only ranks correctly if vectors are unit length. transformersJs guarantees it via normalize: true, and measured norms are exactly 1.0000.

Magnitude genuinely corrupts ranking under L2. Scaling only the most-relevant document, everything else fixed:

doc0 x1.0   doc0:0.439  doc1:0.937  doc2:0.947
doc0 x1.5   doc0:0.734  doc1:0.937  doc2:0.947
doc0 x2.0   doc1:0.937  doc2:0.947  doc0:1.177   <- best match falls to last

I checked, and Ollama does normalize server-side: nomic-embed-text, embeddinggemma, and qwen3-embedding:0.6b all return norms of exactly 1.000000 on Ollama 0.32.9. This still normalizes on the way out, because that behaviour is undocumented and silently depending on it would make ranking correctness hostage to an Ollama implementation detail. It's idempotent for unit vectors.

Worth flagging separately: retriv's own ollama() provider returns raw embeddings with no normalization, so it carries this risk today when paired with the sqlite driver.

Metadata without a probe

/api/show reports everything needed, so the common path costs one request and no wasted inference:

qwen3.embedding_length = 1024      -> dimensions
qwen3.context_length   = 32768     -> maxTokens
capabilities           = ["tools","thinking","embedding"]

Falls back to a probe embedding when a model omits embedding_length. Capability confirmation is required rather than fail-open. Unlike completions, a chat model errors on /api/embed, so an unconfirmed model would break indexing later.

Measured

Apple M5 Max, 120 documents, after warm-up:

Embedder Throughput Dims
ollama:nomic-embed-text 231 docs/s 768
Xenova/bge-large-en-v1.5 (webgpu) 201 docs/s 1024
ollama:embeddinggemma 130 docs/s 768
ollama:qwen3-embedding:0.6b 80 docs/s 1024

End to end through skilld's own index pipeline, all three Ollama models returned the correct top hit.

Testing

test/unit/ollama-embeddings.test.ts has 12 tests with fetch mocked, so CI needs no Ollama daemon:

  • dimensions and maxTokens read from /api/show in exactly one request
  • probe-embed fallback when embedding_length is absent
  • vectors normalized to unit length ([3,4] becomes [0.6,0.8])
  • empty input short-circuits without an API call
  • daemon unreachable produces an actionable message
  • non-embedding model rejected
  • Ollama's own error text surfaced (try pulling it first)
  • discovery returns [] when the daemon is down, filters to embedding-capable, drops unconfirmable models

Also verified live against Ollama 0.32.9 with all three models pulled.

README documents the ollama: syntax, capability filtering, OLLAMA_HOST, and the device interaction. Full suite: 895 passing. The one git-skills failure reproduces on an unmodified checkout, and pnpm lint still cannot run repo-wide, both as noted in #83.

Interaction with the device setting

Ollama manages its own execution device, so embedDevice does not apply. Rather than ignore it silently, the device picker says so when an Ollama model is active.

@mrrobertkent
mrrobertkent force-pushed the feat/ollama-embeddings branch 3 times, most recently from d0b59c5 to 7e49e0a Compare August 11, 2026 20:54
`getDb` called `transformersJs()` with no arguments, pinning every index and
query to retriv's smallest default (bge-small-en-v1.5, 384d) on whatever device
transformers.js chose, which is the CPU under Node. Neither was reachable
through config, a flag, or an env var.

That default thins out as skills accumulate: search builds one sqlite-vec DB per
package and pools scores across all of them at query time, so cross-corpus
ranking depends directly on embedding quality.

Adds `embedModel` and `embedDevice` config keys, matching entries in
`skilld config`, and `SKILLD_EMBED_MODEL` / `SKILLD_EMBED_DEVICE` overrides for
single runs. Precedence is env, then config, then default. Both defaults are
unchanged: `bge-small-en-v1.5`, and a device of `auto` that resolves to
undefined so the option is omitted entirely.

Device measurements on an Apple M5 Max, 120 documents, best of 3 (docs/sec):

  model                       cpu   coreml   webgpu
  bge-small-en-v1.5           664      198     1713
  bge-base-en-v1.5            198       68      580
  Xenova/bge-large-en-v1.5     71        9      201

webgpu is 2.6-2.9x faster than cpu at every size, 4.4x end to end through the
index pipeline; coreml is consistently slower. The ranking is hardware-specific,
so the device is offered rather than defaulted, and the picker leads with that
caveat.

Two correctness details:

The bge-large entry pins the full repo id `Xenova/bge-large-en-v1.5`. retriv's
bare `bge-large-en-v1.5` preset maps to `onnx-community/bge-large-en-v1.5`,
whose weights return 401, so selecting it would fail at first index.

The embedding cache keyed vectors by text hash and validated only dimensions.
That was safe while the model was fixed; selecting one makes it reachable, since
bge-large-en-v1.5 and bge-m3 are both 1024d. Switching kept every cached vector
and served one model's embeddings against another's queries. No crash, just
silently wrong ranking, with the correct answer dropping out of the top 3 on a
43-document corpus. Cache identity is now `<model>@<device>`, cleared on change,
because the same model on a different backend can differ numerically.
Search was limited to the transformers.js models bundled through retriv. Ollama
already hosts stronger local embedders, and skilld already talks to Ollama for
completions, so the capability was one HTTP call away.

Models are addressed as `ollama:<name>`, matching the enhancement-model syntax.
`skilld config` lists locally-pulled models advertising the `embedding`
capability alongside the built-in ones. Discovery is additive: an unreachable
daemon contributes nothing rather than erroring.

Talks to `/api/embed` over plain fetch rather than retriv's Ollama provider,
which would pull in `ai` and `ollama-ai-provider-v2`. That keeps the dependency
footprint unchanged and matches src/agent/clis/ollama.ts, which already uses
fetch against /api/chat, /api/tags and /api/show. `ollamaHost()` moves to core/
so the search worker can resolve OLLAMA_HOST without importing the agent
registry.

Dimensions and context length come from /api/show when the model reports them,
falling back to a probe embedding. Capability confirmation is required rather
than fail-open: unlike completions, a chat model errors on /api/embed, so an
unconfirmed model would break indexing later.

Vectors are L2-normalised on the way out. The index scores by L2 distance and
assumes unit vectors; Ollama normalises server-side today, but that behaviour is
undocumented and silently depending on it would make ranking correctness hostage
to an implementation detail. Normalising is idempotent for unit vectors.

Ollama manages its own execution device, so the embedding device setting does
not apply; the picker says so rather than ignoring it silently.
@mrrobertkent
mrrobertkent force-pushed the feat/ollama-embeddings branch from 7e49e0a to d0c5992 Compare August 11, 2026 21:02
@harlan-zw
harlan-zw merged commit 8bd26f0 into skilld-dev:main Aug 12, 2026
1 check passed
@harlan-zw

Copy link
Copy Markdown
Collaborator

Amazing, thank you for your efforts 💪

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants