Skip to content

feat(plugin-embeddings): batch embed() calls through a SQLite vector store - #1227

Merged
thekevinscott merged 3 commits into
mainfrom
claude/1204-embed-plugin-batch
Oct 2, 2026
Merged

thekevinscott merged 3 commits into
mainfrom
claude/1204-embed-plugin-batch

Conversation

@thekevinbot

@thekevinbot thekevinbot commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #1204

Plugin half of #1204: the worker answers dirsql's batched {"calls": [...]} requests with one model.encode() per model, the per-value cachetta files give way to one SQLite database per model (whole batch looked up in one query, misses written in one transaction), the packaged fragment sets batch = 4096, and the embeddings speed-of-light test loses its xfail.

Measurements

Harness: tests/e2e/speed_of_light_test.py (best of 3, startup = mean of 10 --help runs, corpus doubled until the bare batch encode takes a second). Bar: dirsql wall minus startup <= 1.1x native.

Before (origin/main, --runxfail, corpus grew to 32768 abstracts):

embeddings: dirsql 5.680s minus startup 0.047s = 5.633s, over 1.1x native 1.242s = 1.366s

Batch sweep on this branch (corpus grew to 65536 abstracts, native 1.254s, startup 0.049s; a second sweep with other agents loading the box was noisier but 4096 stayed at 0.84x):

batch dirsql wall minus startup ratio to native rows identical
256 1.154s 1.105s 0.88 yes
1024 1.097s 1.048s 0.84 yes
4096 1.086s 1.037s 0.83 yes
16384 1.459s 1.409s 1.12 yes
65536 1.303s 1.254s 1.00 yes

batch = 4096 is the smallest value that clears the bar with margin and is the fastest measured. Above model2vec's multiprocessing_threshold (10,000 texts) encode forks joblib workers, which costs more than it saves on this corpus, so bigger batches lose. The committed test passes: tests/e2e/speed_of_light_test.py 1 passed.

Local note: the plugin venv had PyPI dirsql 0.4.56 (the user-level uv exclude-newer = "3 days" hides 0.4.60+, which carry batch); measurements ran against 0.4.61 pulled with --exclude-newer-package. CI installs from PyPI without that setting.

Manual run

$ printf 'hello world\n' > note.md; printf 'good morning\n' > other.md
$ XDG_CACHE_HOME=/tmp/manual-1204/xdg dirsql query "SELECT path, vec_length(embed(content)) AS dims FROM './*.md'"
[{"path":"note.md","dims":512},{"path":"other.md","dims":512}]
$ ls /tmp/manual-1204/xdg/dirsql/embeddings/
0dde311c249cb39e45dff0fb5e793d9123b2fa0857fdef23ee568b05c8049575.db
f52048887cd6a4a1863cd22d8cbb1881592f1afb74e8faef1d899bab40e868fb.db
$ printf '{"calls":[["hello"],["world","minishlab/potion-base-8M"],null,["hello"],["x",3]]}\n{"call":["hello"]}\n{"calls":"nope"}\n' | dirsql-plugin-embeddings worker
{"results":[{"ok":[512 floats],"meta":{"cached":true}},{"ok":[256 floats],"meta":{"cached":true}},{"err":"malformed request: a call must carry 1 or 2 arguments"},{"ok":[512 floats],"meta":{"cached":true}},{"err":"the model id must be TEXT"}]}
{"ok":[512 floats],"meta":{"cached":true}}
{"err":"malformed request: \"calls\" must be a list of argument lists"}

Second run of the same query: identical rows, all served from the two databases (3 rows in the default model's, 1 in potion-base-8M's).

E2E Verification

  • Ran e2e suites locally for every affected SDK
  • Python SDK e2e: N/A (packages/python untouched)
  • TypeScript SDK e2e: N/A
  • Rust core e2e (if applicable): N/A
  • packages/python/e2e-attestations/<branch>.json receipt written if packages/python changed (just e2e-attest-python): N/A
  • packages/ts/e2e-attestations/<branch>.json receipt written if packages/ts changed (just e2e-attest-ts): N/A
  • plugins/dirsql-plugin-embeddings/e2e-attestations/claude-1204-embed-plugin-batch.json receipt written
  • Command(s) run:
    • cd plugins/dirsql-plugin-embeddings then uvx testing-conventions e2e attest 'uv run --with-editable ../../packages/python python -m pytest tests/e2e/ -x -q'
    • uv run --with-editable ../../packages/python python -m pytest tests -q -x (unit, integration, e2e)
    • just preflight
  • Result summary:
    • e2e: 19 passed in 16.06s (includes the un-xfailed speed-of-light test)
    • full plugin suite: 137 unit + 79 integration/e2e passed
    • preflight: every plugin gate green; mutation 81 mutants, none surviving; unit coverage 100%

Changelog / Migrations

  • Changelog fragment added under plugins/dirsql-plugin-embeddings/changelog.d/2026-10-02-batched-embed-sqlite-cache.md
  • Migration fragment added under plugins/dirsql-plugin-embeddings/migrations.d/2026-10-02-batched-embed-sqlite-cache.md (on-disk cache format changes; old per-value files are ignored, never read, wipe the directory to reclaim space; the fragment's batch needs dirsql >= 0.4.60)

…store

Red: the worker answers {"calls": [...]} with one encode per model and
results in request order, the cache is one SQLite database per model with
whole-batch lookups, the fragment declares batch, and the speed-of-light
bar is no longer xfail.
…store

The worker answers {"calls": [...]} with one model.encode() per model and
results in request order; {"call"} keeps working. The per-value cachetta
files give way to one SQLite database per model, looked up per batch in one
query and written in one transaction. The packaged fragment sets
batch = 4096, measured as the fastest of 256..65536 on a 65536-abstract
corpus (0.83x a bare batch encode, startup excluded, down from 4.5x).
@thekevinscott
thekevinscott merged commit 9a5dc6d into main Oct 2, 2026
26 checks passed
@thekevinscott
thekevinscott deleted the claude/1204-embed-plugin-batch branch October 2, 2026 17:44
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.

perf: embed() round-trips one value per worker call; 3.4x the model's batch encode

2 participants