@mastra/libsql · brute-force vector search

LibSQLVector.query() re-parses the query vector for every row

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.

Published @mastra/libsql@1.25.1

npm · vector_distance_cos(embedding, '${vectorStr}')

—

SQL that was sent

            

          

What the demo does

  1. Creates an index with 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.
  2. Upserts random vectors (seeded PRNG) with metadata { i, bucket: i % 10 }.
  3. Opens two fresh stores on the same database — one from the published package, one from the fork — and calls query({ indexName, queryVector, topK: 10, filter? }) on each, interleaved, after one warm-up round. Shows the median.
  4. Checks that both return the same ids in the same order, with bit-identical scores and metadata, and shows the SQL each store sent.

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.

Native libSQL (Node.js, @libsql/client 0.18.0)

5,000 vectors × 2,560 dims, file database without chunks_vector_idx, median of 3 runs, macOS arm64:

query1.25.1fixresults
no filter7,323 ms52 ms×142identical
filter: { bucket: 3 }793 ms24 ms×33identical
filter: { bucket: { $in: [1, 2] } }, minScore: 0.023,743 ms33 ms×115identical
includeVector: true7,082 ms143 ms×49identical

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).

Why it's slow

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

The fix

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.