Published @mastra/libsql@1.25.1
npm · vector_distance_cos(embedding, '${vectorStr}')
—
@mastra/libsql · brute-force vector search
When query() can't use the DiskANN index, it inlines the query vector into the SQL as a JSON string:
vector_distance_cos(embedding, '[0.12, …]'). libSQL converts that string to a vector on every call — twice per
scanned row. The fix binds it once as vector32(?). This page runs both versions of the library side by side, in your
browser, on the same libSQL database.
@mastra/libsql@1.25.1npm · vector_distance_cos(embedding, '${vectorStr}')
—
beshkenadze/mastra@be58813fork build · vector_distance_cos(embedding, vector32(?))
—
LibSQLVector.createIndex(), then drops chunks_vector_idx — as an app does when
DiskANN inserts get too expensive (≈534 KiB written per insert at 2,560 dims). Other ways into the same code path:
:memory: URLs, and filters that leave fewer than topK vector_top_k() candidates.{ i, bucket: i % 10 }.query({ indexName, queryVector, topK: 10, filter? }) on each, interleaved, after one warm-up round. Shows the median.The database is libSQL compiled to WebAssembly (@libsql/libsql-wasm-experimental@0.0.3) running in a Web Worker.
Wasm computes distances more slowly than native libSQL, so the gap here is smaller than on a server. Native numbers are below.
@libsql/client 0.18.0)5,000 vectors × 2,560 dims, file database without chunks_vector_idx, median of 3 runs, macOS arm64:
| query | 1.25.1 | fix | results | |
|---|---|---|---|---|
| no filter | 7,323 ms | 52 ms | ×142 | identical |
filter: { bucket: 3 } | 793 ms | 24 ms | ×33 | identical |
filter: { bucket: { $in: [1, 2] } }, minScore: 0.02 | 3,743 ms | 33 ms | ×115 | identical |
includeVector: true | 7,082 ms | 143 ms | ×49 | identical |
In our app (10.7k chunks × 2,560 dims) one search took 20–28 s. Script: compare-patched.mjs (output); a 40-line standalone repro: issue-snippet.mjs (output).
EXPLAIN of the brute-force statement. With the inlined literal, both vector_distance_cos calls (for
WHERE score > ? and for the result column) sit inside the row loop and receive ~50 KB of text to parse each time.
With vector32(?), SQLite evaluates the conversion once, behind a Once guard:
-- literal Rewind Column embedding Function vector_distance_cos(2) ← parses '[…]' Column embedding Function vector_distance_cos(2) ← parses '[…]' again Next
-- vector32(?) Rewind Column embedding Once → Function vector32(1) ← once per statement Function vector_distance_cos(2) Column embedding Once → Function vector32(1) Function vector_distance_cos(2) Next
queryWithIndex() already binds the vector as vector32(?); the brute-force branch now does the same.
Filter SQL uses only anonymous ? placeholders and comes after the vector, so bindings become
[vector, ...filterValues, minScore, topK]. A regression test drops _vector_idx on a file database and
checks the SQL and the filter/minScore/includeVector bindings.