From ed126e6db72a5aede151bcee98eff8813475c599 Mon Sep 17 00:00:00 2001 From: yuanrengu Date: Fri, 15 May 2026 15:53:00 +0800 Subject: [PATCH] perf: replace N+1 metadata queries with batch lookup in vector search searchL1Vector and searchL0Vector previously issued one metadata query per vec0 KNN result (N individual stmtGetMeta.get() calls). With topK=20 and a buffer of 10, this meant up to 30 round-trips per search. Replace with a single `SELECT ... WHERE record_id IN (?, ...)` query via new batchGetL1Meta/batchGetL0Meta helpers, then look up results from a Map. This reduces SQLite round-trips from O(N) to O(1) per vector search while preserving the existing fault-tolerant behavior. Co-Authored-By: Claude Opus 4.7 Signed-off-by: yuanrengu --- src/core/store/sqlite.ts | 157 ++++++++++++++++++++++++++++++--------- 1 file changed, 121 insertions(+), 36 deletions(-) diff --git a/src/core/store/sqlite.ts b/src/core/store/sqlite.ts index 6252419..203b3b6 100644 --- a/src/core/store/sqlite.ts +++ b/src/core/store/sqlite.ts @@ -1140,32 +1140,27 @@ export class VectorStore implements IMemoryStore { if (rows.length === 0) return []; - const results: VectorSearchResult[] = []; - - for (const { record_id, distance } of rows) { - // sqlite-vec returns null distance for zero vectors (cosine undefined when ‖v‖=0). - // Skip these — they are placeholder vectors from embedding-service-unavailable fallback. + // Filter out null/NaN distances (legacy zero-vector placeholders) first, + // then batch-fetch metadata in a single query to avoid N+1 round-trips. + const validRows = rows.filter(({ record_id, distance }) => { if (distance == null || Number.isNaN(distance)) { this.logger?.warn( `${TAG} [L1-search] record_id=${record_id} has null/NaN distance (likely zero vector) — skipping`, ); - continue; + return false; } + return true; + }); + + if (validRows.length === 0) return []; + + // Batch metadata lookup: single query instead of N individual get() calls + const metaMap = this.batchGetL1Meta(validRows.map((r) => r.record_id)); + + const results: VectorSearchResult[] = []; - const meta = this.stmtGetMeta.get(record_id) as - | { - content: string; - type: string; - priority: number; - scene_name: string; - session_key: string; - session_id: string; - timestamp_str: string; - timestamp_start: string; - timestamp_end: string; - metadata_json: string; - } - | undefined; + for (const { record_id, distance } of validRows) { + const meta = metaMap.get(record_id); if (!meta) { this.logger?.warn(`${TAG} [L1-search] record_id=${record_id} has vector but NO metadata (orphan)`); @@ -1274,6 +1269,97 @@ export class VectorStore implements IMemoryStore { } } + /** + * Batch-fetch L1 metadata for a list of record IDs in a single query. + * Returns a Map keyed by record_id for O(1) lookup in the caller. + * + * **Fault-tolerant**: returns an empty Map on failure. + */ + private batchGetL1Meta(recordIds: string[]): Map { + const empty = new Map(); + if (recordIds.length === 0) return empty; + try { + const placeholders = recordIds.map(() => "?").join(","); + const rows = this.db.prepare(` + SELECT record_id, content, type, priority, scene_name, + session_key, session_id, timestamp_str, timestamp_start, timestamp_end, metadata_json + FROM l1_records WHERE record_id IN (${placeholders}) + `).all(...recordIds) as Array<{ + record_id: string; + content: string; + type: string; + priority: number; + scene_name: string; + session_key: string; + session_id: string; + timestamp_str: string; + timestamp_start: string; + timestamp_end: string; + metadata_json: string; + }>; + const map = new Map(); + for (const row of rows) map.set(row.record_id, row); + return map; + } catch (err) { + this.logger?.warn( + `${TAG} batchGetL1Meta failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`, + ); + return empty; + } + } + + /** + * Batch-fetch L0 metadata for a list of record IDs in a single query. + * Returns a Map keyed by record_id for O(1) lookup in the caller. + * + * **Fault-tolerant**: returns an empty Map on failure. + */ + private batchGetL0Meta(recordIds: string[]): Map { + const empty = new Map(); + if (recordIds.length === 0) return empty; + try { + const placeholders = recordIds.map(() => "?").join(","); + const rows = this.db.prepare(` + SELECT record_id, session_key, session_id, role, message_text, recorded_at, timestamp + FROM l0_conversations WHERE record_id IN (${placeholders}) + `).all(...recordIds) as Array<{ + record_id: string; + session_key: string; + session_id: string; + role: string; + message_text: string; + recorded_at: string; + timestamp: number; + }>; + const map = new Map(); + for (const row of rows) map.set(row.record_id, row); + return map; + } catch (err) { + this.logger?.warn( + `${TAG} batchGetL0Meta failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`, + ); + return empty; + } + } + /** * Get the total number of L1 records in the store. * @@ -1573,28 +1659,27 @@ export class VectorStore implements IMemoryStore { if (rows.length === 0) return []; - const results: L0VectorSearchResult[] = []; - - for (const { record_id, distance } of rows) { - // sqlite-vec returns null distance for zero vectors (cosine undefined when ‖v‖=0). - // Skip these — they are placeholder vectors from embedding-service-unavailable fallback. + // Filter out null/NaN distances (legacy zero-vector placeholders) first, + // then batch-fetch metadata in a single query to avoid N+1 round-trips. + const validRows = rows.filter(({ record_id, distance }) => { if (distance == null || Number.isNaN(distance)) { this.logger?.warn( `${TAG} [L0-search] record_id=${record_id} has null/NaN distance (likely zero vector) — skipping`, ); - continue; + return false; } + return true; + }); + + if (validRows.length === 0) return []; + + // Batch metadata lookup: single query instead of N individual get() calls + const metaMap = this.batchGetL0Meta(validRows.map((r) => r.record_id)); + + const results: L0VectorSearchResult[] = []; - const meta = this.stmtL0GetMeta.get(record_id) as - | { - session_key: string; - session_id: string; - role: string; - message_text: string; - recorded_at: string; - timestamp: number; - } - | undefined; + for (const { record_id, distance } of validRows) { + const meta = metaMap.get(record_id); if (!meta) { this.logger?.warn(`${TAG} [L0-search] record_id=${record_id} has vector but NO metadata (orphan)`);