feat(search): make the embedding model configurable - #83
Merged
harlan-zw merged 3 commits intoAug 12, 2026
Merged
Conversation
harlan-zw
approved these changes
Aug 11, 2026
mrrobertkent
force-pushed
the
feat/configurable-embedding-model
branch
from
August 11, 2026 20:54
25ab21b to
1f0ffd4
Compare
`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.
mrrobertkent
force-pushed
the
feat/configurable-embedding-model
branch
from
August 11, 2026 21:02
1f0ffd4 to
e79b291
Compare
Forward the selected device to Transformers.js and record the model-device identity in each search index. Reject incompatible indexes before queries can mix embedding spaces. Replace duplicated cache tests with API-level regression coverage.
Upgrade retriv to 0.15.0 and remove the temporary local Transformers.js provider now that device forwarding is available upstream.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
Semantic search is a headline feature, but the model behind it is fixed:
With no argument, retriv falls back to
bge-small-en-v1.5: 384 dimensions, 33M parameters, the smallest model it ships. There's no config key, no flag, and no env var to change it. The device is fixed too, and transformers.js defaults to CPU under Node.That's a fine default for a handful of packages. It gets thinner as you scale, because search builds one sqlite-vec DB per package and pools results across all of them:
Scores from separately-built indexes get merged, sorted, and truncated together. Cross-corpus ranking is exactly where embedding quality shows up, so the more skills you install, the more the smallest-model default costs you.
What this adds
An Embedding model and Embedding device entry in
skilld config:Backed by
embedModelandembedDeviceconfig keys, plusSKILLD_EMBED_MODELandSKILLD_EMBED_DEVICEfor single runs. Precedence is env, then config, then default.Every model runs offline through transformers.js, so no new dependency and no API key.
Device benchmarks
Apple M5 Max, 120 documents, best of 3 after warm-up (docs/sec, higher is better):
cpucoremlwebgpubge-small-en-v1.5bge-base-en-v1.5Xenova/bge-large-en-v1.5WebGPU is 2.6x to 2.9x faster than CPU at every size, and 4.4x end to end through the index pipeline (chunk, embed, write, query). The practical effect is bigger than the ratio suggests:
bge-largeon WebGPU indexes faster thanbge-basedoes on CPU, so you can move up two model sizes and still finish sooner.CoreML is consistently slower, by 3x to 8x, because it falls back to CPU for unsupported ops and pays for graph partitioning. It's still offered, because the ranking is hardware-specific and users on other machines should be able to try it. The picker leads with that caveat rather than burying it.
Backward compatibility
Both defaults are unchanged. The model stays
bge-small-en-v1.5, and the device defaults toauto, which resolves toundefinedso the option is omitted entirely and transformers.js keeps its own resolution. Nothing changes unless you pick something.I deliberately did not change the default model. A larger one would strand every existing index, and that migration call belongs to you, not to a feature PR. It's a one-line change in
DEFAULT_EMBED_MODELif you want it.Two correctness details
bge-largeis pinned by full repo id. retriv's barebge-large-en-v1.5preset maps toonnx-community/bge-large-en-v1.5, whose weights return 401.resolveModelForPresetandgetModelDimensionsboth succeed for it, so nothing surfaces the problem until the first index fails with an Unauthorized error.Xenova/bge-large-en-v1.5carries the same weights. (Fixing the preset itself is in skilld-dev/retriv#17.)The embedding cache needed a stronger identity. It keyed vectors by text hash and validated only the dimension count. That was safe while the model was fixed. Selecting one makes it reachable, since
bge-large-en-v1.5andbge-m3are both 1024d, so switching kept every cached vector and served one model's embeddings against the other's queries.No crash, just silently wrong ranking. On a 43-document corpus the correct answer for
"how do I install this"dropped out of the top 3 entirely:Cache identity is now
<model>@<device>, cleared on change. Device is included because the same model on a different backend can differ numerically. A cache with no stored model predates the key and has unknown provenance, so it's cleared too.Testing
test/unit/embed-models.test.ts(17 tests) covers resolution precedence for model and device, blank-env handling,autocollapsing toundefined, and registry integrity. One test cross-checks every entry againstretriv/embeddings/model-info:Our declared width drives the rebuild warning, so drifting from retriv's registry would mean telling users the wrong thing. This fails loudly instead.
test/unit/embedding-cache-identity.test.ts(5 tests) pins each invalidation branch: unchanged identity keeps vectors, model change at equal dimensions clears, dimension change clears, device-only change clears, legacy cache clears.pnpm typecheckclean.Two pre-existing issues, flagged not worked around
Both reproduce on an unmodified checkout:
test/unit/git-skills.test.tshas one failing test (fetchGitSkillslocal path returns[]). Verified identical on a clean tree viagit stash.pnpm lintcannot run at all. The repo pinstypescript@7.0.2and@typescript-eslint@8.66.0refuses to load with "typescript-eslint does not support TS 7.0". I matched surrounding style by hand, so happy to reformat anything that's off.CI
.github/workflows/test.ymltriggers onpushonly, with nopull_requestevent. Pushes from a fork run in the fork, so PRs from forks never report a check here. Glad to add the trigger in a separate PR if useful, since it would unblock check reporting for every outside contribution.Happy to adjust the model list, naming, or where these live in the config menu.