From be5881377e524bb5d29e12608e1992d9c9796de5 Mon Sep 17 00:00:00 2001 From: Aleksandr Beshkenadze Date: Thu, 8 Oct 2026 19:33:02 +0300 Subject: [PATCH] fix(libsql): bind the query vector in brute-force vector queries LibSQLVector.query() inlined the query vector into the brute-force SQL as a JSON text literal. libSQL re-parses that literal on every vector_distance_cos() call, i.e. at least once per scanned row, which dominated the scan cost for high-dimensional embeddings. Bind it once as vector32(?) like queryWithIndex() already does. --- .changeset/libsql-bind-query-vector.md | 5 ++ stores/libsql/src/vector/index.test.ts | 85 ++++++++++++++++++++++++++ stores/libsql/src/vector/index.ts | 10 +-- 3 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 .changeset/libsql-bind-query-vector.md diff --git a/.changeset/libsql-bind-query-vector.md b/.changeset/libsql-bind-query-vector.md new file mode 100644 index 0000000000..12b97f278b --- /dev/null +++ b/.changeset/libsql-bind-query-vector.md @@ -0,0 +1,5 @@ +--- +'@mastra/libsql': patch +--- + +Fixed slow `LibSQLVector.query()` full-table scans. When a query does not use the DiskANN index (in-memory databases, tables without `_vector_idx`, or a metadata filter that leaves fewer than `topK` DiskANN candidates), the query vector was inlined into the SQL as a JSON string and libSQL re-parsed it for every row. It is now bound once as `vector32(?)`. Results are unchanged; on 5,000 vectors with 2,560 dimensions a query drops from about 7 s to about 50 ms. diff --git a/stores/libsql/src/vector/index.test.ts b/stores/libsql/src/vector/index.test.ts index 547af94156..da67ff8020 100644 --- a/stores/libsql/src/vector/index.test.ts +++ b/stores/libsql/src/vector/index.test.ts @@ -212,6 +212,91 @@ describe('LibSQLVector - Store Specific', () => { }); }); + describe('brute-force query without a DiskANN index', () => { + const bruteForceIndexName = 'bruteforce_test'; + const tmpDir = path.join(os.tmpdir(), `libsql-bruteforce-test-${Date.now()}`); + let fileDb: LibSQLVector; + + beforeAll(async () => { + fs.mkdirSync(tmpDir, { recursive: true }); + const url = `file:${path.join(tmpDir, 'test.db')}`; + + const setupDb = new LibSQLVector({ url, id: 'libsql-bruteforce-setup' }); + await setupDb.createIndex({ indexName: bruteForceIndexName, dimension: 1536, metric: 'cosine' }); + await setupDb.upsert({ + indexName: bruteForceIndexName, + vectors: [createVector(0), createVector(100), createVector(500), createVector(1000)], + metadata: [ + { name: 'vec1', category: 'a' }, + { name: 'vec2', category: 'b' }, + { name: 'vec3', category: 'a' }, + { name: 'vec4', category: 'b' }, + ], + }); + await setupDb.close(); + + // Without `_vector_idx`, query() has to use the brute-force scan. + const client = createClient({ url }); + await client.execute(`DROP INDEX ${bruteForceIndexName}_vector_idx`); + client.close(); + + fileDb = new LibSQLVector({ url, id: 'libsql-bruteforce-test' }); + }); + + afterAll(async () => { + await fileDb.close(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should rank results by cosine similarity', async () => { + const results = await fileDb.query({ + indexName: bruteForceIndexName, + queryVector: createVector(0), + topK: 2, + }); + + expect(results.length).toBe(2); + expect(results[0]!.metadata.name).toBe('vec1'); + expect(results[0]!.score).toBeCloseTo(1, 5); + expect(results[1]!.score).toBeLessThanOrEqual(results[0]!.score); + }); + + it('should bind the query vector as a parameter instead of inlining it into the SQL', async () => { + const queryVector = createVector(0).map((value, i) => value + (i % 7) / 1000); + const vectorLiteral = `[${queryVector.join(',')}]`; + const turso = (fileDb as unknown as { turso: ReturnType }).turso; + const executeSpy = vi.spyOn(turso, 'execute'); + + try { + const results = await fileDb.query({ + indexName: bruteForceIndexName, + queryVector, + topK: 10, + filter: { category: { $eq: 'a' } }, + minScore: 0.5, + includeVector: true, + }); + + const vectorQueries = executeSpy.mock.calls + .map(([stmt]) => stmt as { sql: string; args: unknown[] }) + .filter(stmt => stmt.sql.includes('vector_distance_cos')); + expect(vectorQueries.length).toBe(1); + expect(vectorQueries[0]!.sql).not.toContain('vector_top_k'); + expect(vectorQueries[0]!.sql).toContain('vector32(?)'); + expect(vectorQueries[0]!.sql).not.toContain(vectorLiteral); + expect(vectorQueries[0]!.args).toContain(vectorLiteral); + + // Filter values, minScore and topK must still bind to the right placeholders. + expect(results.length).toBe(1); + expect(results[0]!.metadata).toEqual({ name: 'vec1', category: 'a' }); + expect(results[0]!.score).toBeGreaterThan(0.5); + expect(results[0]!.vector).toEqual(createVector(0)); + } finally { + executeSpy.mockRestore(); + } + }); + }); + describe('minScore parameter', () => { it('should respect minimum score threshold', async () => { // First query without minScore to get all results diff --git a/stores/libsql/src/vector/index.ts b/stores/libsql/src/vector/index.ts index 20167ff6a1..7ac59eade9 100644 --- a/stores/libsql/src/vector/index.ts +++ b/stores/libsql/src/vector/index.ts @@ -313,14 +313,15 @@ export class LibSQLVector extends MastraVector { const translatedFilter = this.transformFilter(filter); const { sql: filterQuery, values: filterValues } = buildFilterQuery(translatedFilter); - filterValues.push(minScore); - filterValues.push(topK); + // Bind the query vector as a parameter. Inlining it as a JSON text literal + // makes libsql re-parse the whole vector for every scanned row, which + // dominates the cost of the brute-force scan for large dimensions. const query = ` WITH vector_scores AS ( SELECT vector_id as id, - (1-vector_distance_cos(embedding, '${vectorStr}')) as score, + (1-vector_distance_cos(embedding, vector32(?))) as score, metadata ${includeVector ? ', vector_extract(embedding) as embedding' : ''} FROM ${parsedIndexName} @@ -332,9 +333,10 @@ export class LibSQLVector extends MastraVector { ORDER BY score DESC LIMIT ?`; + const args: InValue[] = [vectorStr, ...filterValues, minScore, topK]; const result = await this.turso.execute({ sql: query, - args: filterValues, + args, }); return result.rows.map(({ id, score, metadata, embedding }) => ({ -- 2.55.0