Skip to content

ADD vector search plugin based on TurboQuant - #8994

Open
pubkey wants to merge 5 commits into
masterfrom
claude/vector-search-turboquant-plugin-s403e9
Open

ADD vector search plugin based on TurboQuant#8994
pubkey wants to merge 5 commits into
masterfrom
claude/vector-search-turboquant-plugin-s403e9

Conversation

@pubkey

@pubkey pubkey commented Aug 20, 2026

Copy link
Copy Markdown
Owner

This PR contains:

  • A NEW FEATURE (the vector-turboquant plugin)
  • IMPROVED DOCS
  • IMPROVED TESTS
  • IMPROVED typings

Describe the problem you have without this PR

RxDB has the vector plugin with exact distance functions, but no index. To run a similarity search you have to fetch every embedding from the storage and compare it with the query. The vector database article measures that at around 700ms for 10k documents, and it grows linearly. Keeping the raw float32 embeddings in memory instead is expensive: 100k documents with 768 dimensions need 293 MB.

This PR adds a vector index that stores the embeddings in a compressed form, so a search does not read the raw vectors at all.

What it does

The plugin implements TurboQuant, a data-oblivious quantizer from Google Research, in the same spirit as turbovec. An embedding is stored with 1, 2 or 4 bits per dimension instead of 32 bits:

  1. Normalize: the length of a vector is stripped off and stored separately.
  2. Rotate: a randomized block Walsh-Hadamard transform turns the coordinates of any dataset into the same normal distribution. It runs in d * log(64) time instead of the d * d of a full random orthogonal matrix and preserves dot products exactly. Blocks of 64 keep the padding at 63 dimensions at most, while padding to the next power of two would waste 33% for the common sizes like 768 or 1536.
  3. Quantize: a Lloyd-Max codebook for the normal distribution, computed with the Lloyd iteration. Because the rotation already normalized the distribution, the same codebook fits every dataset, so there is no training step and no rebuild when the collection grows.
  4. Renormalize: quantization shortens a vector and makes every later score too small. One dot product per vector during encoding gives the correction factor that removes the bias.
  5. Optional per coordinate calibration (the TQ+ step), fitted by minimizing the reconstruction error on a sample.

A search rotates the query into the same space and scores it against the packed codes with a per byte lookup table. The vectors are never decompressed.

API

import { addRxPlugin } from 'rxdb/plugins/core';
import { RxDBVectorTurboQuantPlugin } from 'rxdb/plugins/vector-turboquant';
addRxPlugin(RxDBVectorTurboQuantPlugin);

const vectorIndex = await myCollection.addVectorIndex({
    identifier: 'semantic-search',
    dimensions: 384,
    bitWidth: 4,
    distance: 'cosine',
    embedding: docData => docData.embedding
});

const results = vectorIndex.search(queryVector, 10);
const documents = await vectorIndex.searchDocuments(queryVector, 10);
  • addVectorIndex() reads the collection once and then follows the change stream, so inserts, updates and deletes are reflected in the next search.
  • search() supports cosine, dotProduct and euclidean, plus an allowlist of ids so a vector search can be combined with a normal Mango query.
  • TurboQuantIndex also works standalone, and serializeTurboQuantIndex() writes the whole index into a binary blob so it does not have to be rebuilt on the next start.
  • error$ emits the errors that a user provided embedding function threw while a change event was processed.

Measured results

Recall on 10k random vectors with 384 dimensions and 200 queries. recall@k is the share of queries where the exact nearest neighbor was inside the first k results:

Bit width recall@1 recall@4 recall@10 Memory for 100k x 768
float32 100% 100% 100% 293 MB
4 82.5% 100% 100% 37 MB
2 42.0% 73.0% 88.0% 18 MB
1 13.0% 30.5% 46.5% 9 MB

Random vectors are the hardest case because in high dimensions all of them have nearly the same distance to each other. The relative error of the estimated scores is about 10% at 4 bit, which matches the distortion of the Lloyd-Max quantizer at that bit width. Scanning 100k vectors of 768 dimensions at 4 bit takes about 100ms on a single core.

The Lloyd-Max solver reproduces the published values from J. Max, "Quantizing for minimum distortion" (1960) to four decimals, and the rotation turns a one-hot vector, the worst case for the quantizer, into coordinates with a variance of 1.00 and a kurtosis of 3.02.

Todos

  • Tests
  • Documentation
  • Typings
  • Changelog

41 testcases in test/unit/vector-turboquant.test.ts cover the codebook, the rotation, the index, the calibration, the serialization and the collection integration. They pass against both the memory and the dexie storage. npm run lint and npm run check-types are clean and npm run docs:build succeeds.

New docs page at docs-src/docs/vector-search.md, linked in the sidebar and from the vector database article, the fulltext search page and the RxPipeline page. The outdated sentence in the vector database article saying RxDB has no dedicated vector plugin was updated.

🤖 Generated with Claude Code

https://claude.ai/code/session_013XoBX9R7x2RZvKHUvavxXS


Generated by Claude Code

Adds the `vector-turboquant` plugin that runs approximate nearest
neighbor search over the documents of an RxCollection.

It implements the TurboQuant quantizer (https://arxiv.org/abs/2504.19874),
so an embedding is stored with 1, 2 or 4 bits per dimension instead of
32 bits as float32:

- Normalization: the length of a vector is stored separately.
- Rotation: a randomized block Walsh-Hadamard transform turns the
  coordinates of any dataset into the same normal distribution in
  d * log(64) time and preserves dot products exactly.
- Quantization: a Lloyd-Max codebook for the normal distribution,
  computed with the Lloyd iteration, so there is no training step.
- Length renormalization: one dot product per vector during encoding
  removes the downward bias of the estimated scores.
- Optional per coordinate calibration (TQ+).

`collection.addVectorIndex()` fills the index from the collection and
keeps it in sync with the change stream. `search()` scores the packed
codes with a per byte lookup table and supports `cosine`, `dotProduct`
and `euclidean` plus an allowlist to combine a vector search with a
normal query. `serializeTurboQuantIndex()` writes the whole index into
a binary blob so it does not have to be rebuilt on the next start.

Measured on 10k random vectors with 384 dimensions: recall@4 is 100%
at 4 bit and the index needs 8 times less memory than float32.

Adds testcases for the codebook, the rotation, the index, the
serialization and the collection integration, plus the
https://rxdb.info/vector-search.html docs page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013XoBX9R7x2RZvKHUvavxXS
@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

✅ Verify Test Reproduction: Tests FAILED without the fix (expected)

This confirms the changed tests correctly reproduce the bug that the source changes fix.

This workflow runs the changed tests without the source fix to verify they reproduce the bug.

Show output
...(truncated, showing last 200 of 244 lines)
[1] Successfully compiled 274 files with Babel (4008ms).
[1] npm run build:esm exited with code 0
[2] npm run build:test exited with code 0
[0] Successfully compiled 274 files with Babel (4855ms).
[0] npm run build:cjs exited with code 0
# transpiling DONE (4 CPUs)

 Exception during run: Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/home/runner/work/rxdb/rxdb/plugins/vector-turboquant/index.mjs' imported from /home/runner/work/rxdb/rxdb/test_tmp/unit/vector-turboquant.test.js
    at finalizeResolution (node:internal/modules/esm/resolve:271:11)
    at moduleResolve (node:internal/modules/esm/resolve:865:10)
    at defaultResolve (node:internal/modules/esm/resolve:992:11)
    at #cachedDefaultResolve (node:internal/modules/esm/loader:701:20)
    at #resolveAndMaybeBlockOnLoaderThread (node:internal/modules/esm/loader:721:38)
    at ModuleLoader.resolveSync (node:internal/modules/esm/loader:759:56)
    at #resolve (node:internal/modules/esm/loader:683:17)
    at ModuleLoader.getOrCreateModuleJob (node:internal/modules/esm/loader:603:35)
    at ModuleJob.syncLink (node:internal/modules/esm/module_job:163:33)
    at ModuleJob.link (node:internal/modules/esm/module_job:253:17)
    at new ModuleJob (node:internal/modules/esm/module_job:232:26)
    at #getOrCreateModuleJobAfterResolve (node:internal/modules/esm/loader:572:11)
    at afterResolve (node:internal/modules/esm/loader:607:52)
    at ModuleLoader.getOrCreateModuleJob (node:internal/modules/esm/loader:613:12)
    ... collapsed 6 duplicate lines matching above lines ...
    at node:internal/modules/esm/loader:632:32
    at TracingChannel.tracePromise (node:diagnostics_channel:362:14)
    at ModuleLoader.import (node:internal/modules/esm/loader:628:21)
    at defaultImportModuleDynamicallyForScript (node:internal/modules/esm/utils:239:31)
    at importModuleDynamicallyCallback (node:internal/modules/esm/utils:263:12)
    at exports.doImport (/home/runner/work/rxdb/rxdb/node_modules/mocha/lib/nodejs/esm-utils.js:36:43)
    at formattedImport (/home/runner/work/rxdb/rxdb/node_modules/mocha/lib/nodejs/esm-utils.js:10:28)
    at Object.requireModule [as requireOrImport] (/home/runner/work/rxdb/rxdb/node_modules/mocha/lib/nodejs/esm-utils.js:102:36)
    at exports.loadFilesAsync (/home/runner/work/rxdb/rxdb/node_modules/mocha/lib/nodejs/esm-utils.js:155:34)
    at Mocha.loadFilesAsync (/home/runner/work/rxdb/rxdb/node_modules/mocha/lib/mocha.js:429:19)
    at singleRun (/home/runner/work/rxdb/rxdb/node_modules/mocha/lib/cli/run-helpers.js:174:15)
    at exports.runMocha (/home/runner/work/rxdb/rxdb/node_modules/mocha/lib/cli/run-helpers.js:247:10)
    at exports.handler (/home/runner/work/rxdb/rxdb/node_modules/mocha/lib/cli/run.js:384:11)
    at /home/runner/work/rxdb/rxdb/node_modules/mocha/node_modules/yargs/build/index.cjs:1:8992
    at /home/runner/work/rxdb/rxdb/node_modules/mocha/node_modules/yargs/build/index.cjs:1:4972 {
  code: 'ERR_MODULE_NOT_FOUND',
  url: 'file:///home/runner/work/rxdb/rxdb/plugins/vector-turboquant/index.mjs'
}

=== test:browser:dexie output ===

> rxdb@17.5.0 test:browser:dexie
> npm run transpile && cross-env CI=true DEFAULT_STORAGE=dexie   karma start ./config/karma.conf.cjs --single-run --browsers ChromeHeadless


> rxdb@17.5.0 transpile
> npm run build:version && node scripts/transpile.mjs && cp ./scripts/module_package.json test_tmp/package.json


> rxdb@17.5.0 build:version
> node ./scripts/update-version-variable.mjs

# transpiling.. (this takes some time on first run)
Successfully compiled 1 file with Babel (209ms).
Successfully compiled 1 file with Babel (227ms).
# transpiling DONE (4 CPUs)
staticFilesPath: /home/runner/work/rxdb/rxdb/docs-src/static/files
# Use CI settings.
# Karma effective config: {
  "basePath": "",
  "frameworks": [
    "mocha",
    "webpack",
    "detectBrowsers"
  ],
  "webpack": "[webpack config omitted]",
  "preprocessors": {
    "../test_tmp/unit.test.js": [
      "webpack",
      "sourcemap"
    ]
  },
  "files": [
    "../test_tmp/unit.test.js"
  ],
  "port": 9876,
  "colors": true,
  "autoWatch": false,
  "proxies": {
    "/files": "http://localhost:18001/files"
  },
  "detectBrowsers": {
    "enabled": true,
    "usePhantomJS": false,
    "postDetection": "[function]"
  },
  "plugins": [
    "karma-mocha",
    "karma-webpack",
    "karma-chrome-launcher",
    "karma-safari-launcher",
    "karma-firefox-launcher",
    "karma-opera-launcher",
    "karma-detect-browsers",
    "karma-spec-reporter",
    "karma-sourcemap-loader"
  ],
  "client": {
    "mocha": {
      "bail": true,
      "timeout": 120000
    },
    "env": "[process.env omitted]"
  },
  "browserDisconnectTimeout": 300000,
  "browserDisconnectTolerance": 4,
  "browserNoActivityTimeout": 300000,
  "captureTimeout": 300000,
  "processKillTimeout": 120000,
  "singleRun": true,
  "reporters": [
    "spec"
  ],
  "concurrency": 1,
  "retryLimit": 3,
  "browserConsoleLogOptions": {
    "level": "debug",
    "format": "%b %T: %m",
    "terminal": true
  }
}
Server listening on port: 18001
�[32m21 08 2026 16:30:36.574:INFO [framework.detect-browsers]: �[39mwhich.sync(process.env[browser.ENV_CMD]):  /usr/bin/google-chrome
�[32m21 08 2026 16:30:36.576:INFO [framework.detect-browsers]: �[39mThe following browsers will be used: [ 'ChromeHeadless' ]
Webpack bundling...
asset �[1m�[32mcommons.js�[39m�[22m 10.5 MiB �[1m�[32m[emitted]�[39m�[22m (name: commons) (id hint: commons)
asset �[1m�[32mruntime.js�[39m�[22m 9.7 KiB �[1m�[32m[emitted]�[39m�[22m (name: runtime)
asset �[1m�[32munit.test.3184013999.js�[39m�[22m 1.12 KiB �[1m�[32m[emitted]�[39m�[22m (name: unit.test.3184013999)
Entrypoint �[1munit.test.3184013999�[39m�[22m 10.5 MiB = �[1m�[32mruntime.js�[39m�[22m 9.7 KiB �[1m�[32mcommons.js�[39m�[22m 10.5 MiB �[1m�[32munit.test.3184013999.js�[39m�[22m 1.12 KiB

�[1m�[31mERROR�[39m�[22m in �[1m./test_tmp/unit/vector-turboquant.test.js�[39m�[22m �[1m�[32m4:0-270�[39m�[22m
�[1mModule �[1m�[31mnot found�[39m�[22m�[1m: �[1m�[31mError�[39m�[22m�[1m: Can't resolve '../../plugins/vector-turboquant/index.mjs' in '/home/runner/work/rxdb/rxdb/test_tmp/unit'�[39m�[22m
resolve '../../plugins/vector-turboquant/index.mjs' in '/home/runner/work/rxdb/rxdb/test_tmp/unit'
  using description file: /home/runner/work/rxdb/rxdb/test_tmp/package.json (relative path: ./unit)
    Field 'browser' doesn't contain a valid alias configuration
    using description file: /home/runner/work/rxdb/rxdb/package.json (relative path: ./plugins/vector-turboquant/index.mjs)
      Field 'browser' doesn't contain a valid alias configuration
�[1m�[31m      /home/runner/work/rxdb/rxdb/plugins/vector-turboquant/index.mjs doesn't exist�[39m�[22m
 @ ./test_tmp/unit.test.js 10:0-42

webpack 5.107.2 compiled with �[1m�[31m1 error�[39m�[22m in 3682 ms
�[32m21 08 2026 16:30:41.253:INFO [karma-server]: �[39mKarma v6.4.4 server started at http://localhost:9876/
�[32m21 08 2026 16:30:41.253:INFO [launcher]: �[39mLaunching browsers ChromeHeadless with concurrency 1
�[32m21 08 2026 16:30:41.257:INFO [launcher]: �[39mStarting browser ChromeHeadless
�[32m21 08 2026 16:31:05.352:INFO [Chrome Headless 151.0.0.0 (Linux 0.0.0)]: �[39mConnected on socket wzz44v7YDavZ-SSOAAAB with id 39607997
Chrome Headless 151.0.0.0 (Linux 0.0.0) LOG LOG: �[36m'DEFAULT_STORAGE: dexie'�[39m
Chrome Headless 151.0.0.0 (Linux 0.0.0) LOG LOG: �[36m'# use RxStorage: dexie'�[39m
Chrome Headless 151.0.0.0 (Linux 0.0.0) LOG LOG: �[36m'######## init.test.js ########'�[39m
Chrome Headless 151.0.0.0 (Linux 0.0.0) WARN LOG: �[36m'-------------- RxDB dev-mode warning -------------------------------
you are seeing this because you use the RxDB dev-mode plugin https://rxdb.info/dev-mode.html?console=dev-mode 
This is great in development mode, because it will run many checks to ensure
that you use RxDB correct. If you see this in production mode,
you did something wrong because the dev-mode plugin will decrease the performance.

🤗 Hint: To get the most out of RxDB, check out the Premium Plugins
to get access to faster storages and more professional features: https://rxdb.info/premium/?console=dev-mode 

💬 Need help? The RxDB Discord is the fastest place to reach maintainers: https://rxdb.info/chat/?console=dev-mode 

You can disable this warning by calling disableWarnings() from the dev-mode plugin.
---------------------------------------------------------------------'�[39m
Chrome Headless 151.0.0.0 (Linux 0.0.0) LOG LOG: �[36m'###### PLATFORM: ######'�[39m
Chrome Headless 151.0.0.0 (Linux 0.0.0) LOG LOG: �[36m'USER-AGENT: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36'�[39m
Chrome Headless 151.0.0.0 (Linux 0.0.0) LOG LOG: �[36m'STORAGE: dexie'�[39m
�[31mChrome Headless 151.0.0.0 (Linux 0.0.0) ERROR�[39m
  Uncaught Error: Cannot find module '../../plugins/vector-turboquant/index.mjs'
  at webpack://rxdb/./test_tmp/unit/vector-turboquant.test.js?:7:156
  
  Error: Cannot find module '../../plugins/vector-turboquant/index.mjs'
      at webpackMissingModule (webpack://rxdb/./test_tmp/unit/vector-turboquant.test.js?:7:50)
      at eval (webpack://rxdb/./test_tmp/unit/vector-turboquant.test.js?:7:166)
      at Module../test_tmp/unit/vector-turboquant.test.js (/tmp/_karma_webpack_979420/commons.js:23707:1)
      at __webpack_require__ (/tmp/_karma_webpack_979420/runtime.js:37:42)
      at eval (webpack://rxdb/./test_tmp/unit.test.js?:5:89)
      at Module../test_tmp/unit.test.js (/tmp/_karma_webpack_979420/commons.js:23036:1)
      at __webpack_require__ (/tmp/_karma_webpack_979420/runtime.js:37:42)
      at __webpack_exec__ (unit.test.3184013999.js:24:48)
      at unit.test.3184013999.js:25:55
      at __webpack_require__.O (/tmp/_karma_webpack_979420/runtime.js:74:23)

Chrome Headless 151.0.0.0 (Linux 0.0.0): Executed 0 of 0�[31m ERROR�[39m (1.185 secs / 0 secs)

Chrome Headless 151.0.0.0 (Linux 0.0.0) ERROR LOG: �[36m'init.test.ts: browser uncaught error:'�[39m
Chrome Headless 151.0.0.0 (Linux 0.0.0) ERROR LOG: �[36m'Uncaught Error: Cannot find module '../../plugins/vector-turboquant/index.mjs''�[39m
Chrome Headless 151.0.0.0 (Linux 0.0.0) ERROR LOG: �[36m'Error: Cannot find module '../../plugins/vector-turboquant/index.mjs'
    at webpackMissingModule (webpack://rxdb/./test_tmp/unit/vector-turboquant.test.js?:7:50)
    at eval (webpack://rxdb/./test_tmp/unit/vector-turboquant.test.js?:7:166)
    at Module../test_tmp/unit/vector-turboquant.test.js (http://localhost:9876/absolute/tmp/_karma_webpack_979420/commons.js?fda601be5e16d378a85318148f4289fdae00178f:23707:1)
    at __webpack_require__ (http://localhost:9876/absolute/tmp/_karma_webpack_979420/runtime.js?bbee3e1134bcbcd028f882ecfdf4c5d01b426378:37:42)
    at eval (webpack://rxdb/./test_tmp/unit.test.js?:5:89)
    at Module../test_tmp/unit.test.js (http://localhost:9876/absolute/tmp/_karma_webpack_979420/commons.js?fda601be5e16d378a85318148f4289fdae00178f:23036:1)
    at __webpack_require__ (http://localhost:9876/absolute/tmp/_karma_webpack_979420/runtime.js?bbee3e1134bcbcd028f882ecfdf4c5d01b426378:37:42)
    at __webpack_exec__ (http://localhost:9876/absoluteunit.test.3184013999.js?db447ea27b895b1663771968a3603d9fa35ab3b2:24:48)
    at http://localhost:9876/absoluteunit.test.3184013999.js?db447ea27b895b1663771968a3603d9fa35ab3b2:25:55
    at __webpack_require__.O (http://localhost:9876/absolute/tmp/_karma_webpack_979420/runtime.js?bbee3e1134bcbcd028f882ecfdf4c5d01b426378:74:23)'�[39m
Chrome Headless 151.0.0.0 (Linux 0.0.0) ERROR LOG: �[36m'at webpack://rxdb/./test_tmp/unit/vector-turboquant.test.js?:7:156'�[39m


View full workflow run

The normal CDF of the vector-turboquant plugin uses the rational
approximation of Hart (1968). codespell reads the name as a
misspelling of 'heart', so it is added to the ignore list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013XoBX9R7x2RZvKHUvavxXS
@nickchomey

Copy link
Copy Markdown
Contributor

This looks like a great idea. I wonder if it might benefit from some sort of existing wasm implementation that uses SIMD etc? There's various JS and TS repos in github that implement things like this. A few selected at random:

https://github.com/zlaabsi/turboquant-wasm
https://github.com/a-tokyo/quantvec
https://github.com/teamchong/turboquant-wasm

pubkey commented Aug 20, 2026

Copy link
Copy Markdown
Owner Author

Thanks, that is worth weighing. I checked all three: turboquant-wasm (321 stars), quantvec (10), @zlaabsi/turboquant-wasm (7). They are real and published on npm, so this is a genuine option, but all three are young, and a hard dependency would tie a core plugin to their release cadence.

Where the time actually goes in this implementation, measured on Node.js 22 on a single core:

  • Scanning 100k vectors of 768 dimensions at 4 bit: about 100 ms
  • Storing one vector: about 65 µs, most of it the rotation

SIMD would make the scan several times faster. But in a local-first app the embedding model dominates: transformers.js needs a few hundred milliseconds per document, so at the corpus sizes that fit on a user device the scan is rarely the bottleneck. The allowlist option cuts it down further by restricting the scan to the result of a normal query.

The arguments against bundling WASM into this plugin:

  • RxDB has no WASM dependency today and targets browser, Node.js, Electron, React Native, Capacitor, Deno and Bun. Relaxed SIMD is narrower than that. turboquant-wasm documents Chrome 114+, Firefox 128+, Safari 18+ and Node 20+, and WASM on React Native is still rough. Plain TypeScript runs everywhere RxDB runs.
  • WASM instantiation is async, so search() would either stop being synchronous or would need an explicit init step before the first query.
  • Every consumer pays the bundler setup cost of loading a .wasm asset, which works differently in Vite, webpack, Metro and Deno.

A middle path fits the existing conventions better: keep the dependency-free TypeScript scorer as the default and make the scorer pluggable, the same way the SQLite RxStorage takes the engine from the user through sqliteBasics instead of bundling one. Then anyone who wants SIMD can supply a WASM scorer, and the plugin still works on runtimes without it.

I have not changed anything for this, because taking on a third party dependency for a core plugin is the maintainer's call rather than mine.


Generated by Claude Code

@nickchomey

Copy link
Copy Markdown
Contributor
  1. as mentioned previously, those libraries were selected at random. There may be other, better, more mature ones. Perhaps even something implemented directly in zig or rust that could be used/ported.
  2. It is worth noting that transformers.js uses WASM (by default) or WebGPU

pubkey commented Aug 21, 2026

Copy link
Copy Markdown
Owner Author

Decision on this: the plugin stays pure TypeScript.

The deciding argument is one I had underweighted, and it is stronger than the portability and dependency points I listed above: crossing the JavaScript to WASM boundary costs latency on every call. A search has to copy the query vector in and the results back out, and RxDB optimizes for latency. At the corpus sizes that fit on a user device, that per-call overhead eats into what SIMD wins back inside the kernel.

So no dependency and no change here. Thanks for raising it, the links were worth checking.


Generated by Claude Code

Answers why the scoring loop is plain TypeScript: a WASM kernel with
SIMD runs the inner loop faster, but every search pays the cost of
copying the query into the WASM memory and the results back out,
which competes with what SIMD wins inside the kernel.

Asked in #8994

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013XoBX9R7x2RZvKHUvavxXS
@nickchomey

Copy link
Copy Markdown
Contributor

So no transformers.js then on non-chromium browsers (which don't yet support webgpu)? How will the embedding work without wasm?

The entry implied that WASM support is uneven on Deno, which is wrong,
and it leaned on portability as the main argument. The real argument is
the fixed cost of crossing the JavaScript to WASM boundary on every
search. transformers.js is the opposite case and does run on WASM by
default, because one crossing per document is amortized over a few
hundred milliseconds of compute, so the entry now says that instead.

Raised in #8994

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013XoBX9R7x2RZvKHUvavxXS

pubkey commented Aug 21, 2026

Copy link
Copy Markdown
Owner Author

You are right, and my portability argument was the weak part of that answer. transformers.js runs on WASM by default and only uses WebGPU when you pass device: 'webgpu', so on the browser path a user who computes embeddings locally already has WASM. Nothing changes about transformers.js here, it keeps working exactly as before. I also claimed WASM support is uneven on Deno, which is simply wrong.

What actually decides it is narrower than "WASM is not available", and the two cases are not symmetric:

  • An embedding crosses the boundary once per document and that crossing is amortized over a few hundred milliseconds of model compute. Paying it is obviously worth it, which is why transformers.js does.
  • A search crosses per query for work that is much smaller. At 10k documents of 384 dimensions the whole scan is a few milliseconds, so the fixed marshalling cost is a real fraction of it rather than a rounding error.

So the plugin keeps the scorer on the JavaScript side, while the model next to it stays on WASM. Those are consistent positions, not contradictory ones. React Native is the remaining case where WASM itself is a problem, and it is a smaller part of the argument than I made it sound.

I have corrected the FAQ entry accordingly in 3204e7c: it now leads with the per-call boundary cost, states that transformers.js runs on WASM by default, and drops the Deno claim.

On your earlier point that the three libraries were picked at random and better or more mature ones may exist, including something in Rust or Zig: agreed, and I only evaluated those three. If a mature implementation does turn out to beat the TypeScript scorer end to end, including the marshalling, that is worth revisiting with a benchmark rather than on priors.


Generated by Claude Code

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.

3 participants