From 9e6d99eb1e4d13d38e8d7075191e328c8d12b15a Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 03:51:34 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=20optimize=20lexical=20search=20with?= =?UTF-8?q?=20inverted=20index?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implemented LexicalIndex class in src/services/memory/retriever.js. - Added WeakMap caching for indices based on memory array references. - Improved memory array stability in src/services/memory/index.js using a storeVersion counter. - Achieved ~2x speedup for 10k memories and ~9x speedup for 100k memories in benchmarks. - Maintains compatibility with existing textMatchSimilarity scoring. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- reports/performance/retriever-baseline.md | 13 ++++ reports/performance/retriever-optimized.md | 16 +++++ src/services/memory/index.js | 27 ++++++-- src/services/memory/retriever.js | 80 ++++++++++++++++++++-- 4 files changed, 127 insertions(+), 9 deletions(-) create mode 100644 reports/performance/retriever-baseline.md create mode 100644 reports/performance/retriever-optimized.md diff --git a/reports/performance/retriever-baseline.md b/reports/performance/retriever-baseline.md new file mode 100644 index 0000000..4fb6dde --- /dev/null +++ b/reports/performance/retriever-baseline.md @@ -0,0 +1,13 @@ +# Lexical Search Baseline Performance + +Date: Fri Feb 20 03:31:01 UTC 2026 +Environment: Sandbox + +## Configuration +- Number of memories: 10,000 +- Number of queries: 100 +- Search type: Full-scan regex matching + +## Results +Total time for 100 queries over 10000 memories: 1579.75ms +Average time per query: 15.80ms diff --git a/reports/performance/retriever-optimized.md b/reports/performance/retriever-optimized.md new file mode 100644 index 0000000..0bf7da2 --- /dev/null +++ b/reports/performance/retriever-optimized.md @@ -0,0 +1,16 @@ +# Lexical Search Optimized Performance + +Date: Fri Feb 20 03:36:33 UTC 2026 + +## Summary +Implemented an inverted index with a WeakMap cache. The performance boost is significant, especially as the number of memories grows. + +## Comparison (10,000 memories, 100 queries) +- Baseline: 15.80ms / query +- Optimized: 7.50ms / query +- Improvement: 2.1x speedup + +## Comparison (100,000 memories, 10 queries) +- Baseline: 118.91ms / query +- Optimized: 13.41ms / query +- Improvement: 8.8x speedup diff --git a/src/services/memory/index.js b/src/services/memory/index.js index 44b805b..2c71eae 100644 --- a/src/services/memory/index.js +++ b/src/services/memory/index.js @@ -80,9 +80,22 @@ async function saveMemoryStore(store) { const memoryStore = loadMemoryStore(); const cache = createMemoryCache(); +let storeVersion = 0; +let cachedMemoriesArray = null; +let cachedMemoriesVersion = -1; + +function getMemoriesArray() { + if (cachedMemoriesVersion !== storeVersion || !cachedMemoriesArray) { + cachedMemoriesArray = [...memoryStore.values()]; + cachedMemoriesVersion = storeVersion; + } + return cachedMemoriesArray; +} + const memoryRepository = { async insertMemory(memory) { memoryStore.set(memory.id, memory); + storeVersion++; await saveMemoryStore(memoryStore); return memory; }, @@ -91,16 +104,18 @@ const memoryRepository = { if (!existing) return null; const updated = { ...existing, last_seen: lastSeen }; memoryStore.set(id, updated); + storeVersion++; await saveMemoryStore(memoryStore); return updated; }, async listPruneCandidates({ cutoff }) { - return [...memoryStore.values()].filter((memory) => !memory.pinned && memory.last_seen < cutoff); + return getMemoriesArray().filter((memory) => !memory.pinned && memory.last_seen < cutoff); }, async markMerged(id, mergedInto) { const existing = memoryStore.get(id); if (!existing) return false; memoryStore.set(id, { ...existing, merged_into: mergedInto, last_seen: Date.now() }); + storeVersion++; await saveMemoryStore(memoryStore); return true; }, @@ -109,6 +124,7 @@ const memoryRepository = { if (!existing) return null; const updated = { ...existing, pinned, last_seen: Date.now() }; memoryStore.set(id, updated); + storeVersion++; await saveMemoryStore(memoryStore); return updated; }, @@ -116,7 +132,7 @@ const memoryRepository = { return memoryStore.get(id) ?? null; }, async listMemories() { - return [...memoryStore.values()]; + return getMemoriesArray(); }, }; @@ -267,7 +283,7 @@ export async function getRelevantMemories(params) { const source = typeof repository.listMemories === 'function' ? await repository.listMemories(queryParams) - : [...memoryStore.values()]; + : getMemoriesArray(); const ranked = await ranker(source, queryParams); await updateMemoryUsage(repository, ranked.map((memory) => memory.id)); @@ -308,7 +324,7 @@ export async function runPruneCycle(options = {}) { const prunable = dbCandidates.length > 0 ? dbCandidates - : selectPrunableMemories([...memoryStore.values()], { + : selectPrunableMemories(getMemoriesArray(), { retentionMs, now: options.now, }); @@ -333,6 +349,7 @@ export async function runPruneCycle(options = {}) { } if (prunable.length > 0) { + storeVersion++; await saveMemoryStore(memoryStore); } @@ -422,7 +439,7 @@ export async function listMemories(filters = {}, options = {}) { const repository = options.repository ?? memoryRepository; const source = typeof repository.listMemories === 'function' ? await repository.listMemories(filters) - : [...memoryStore.values()]; + : getMemoriesArray(); const filtered = applyMemoryFilters(source, filters) .sort((left, right) => right.last_seen - left.last_seen); diff --git a/src/services/memory/retriever.js b/src/services/memory/retriever.js index 0a69450..5a5e8fc 100644 --- a/src/services/memory/retriever.js +++ b/src/services/memory/retriever.js @@ -48,6 +48,58 @@ function textMatchSimilarity(queryTerms, queryRegexes, memory) { return matchedTerms / queryTerms.length; } +class LexicalIndex { + constructor(memories) { + this.index = new Map(); + this.memoriesById = new Map(); + + for (const memory of memories) { + this.memoriesById.set(memory.id, memory); + const text = `${memory.summary} ${memory.content}`.toLowerCase(); + const terms = text.split(/\W+/).filter(Boolean); + const uniqueTerms = new Set(terms); + + for (const term of uniqueTerms) { + if (!this.index.has(term)) { + this.index.set(term, []); + } + this.index.get(term).push(memory.id); + } + } + } + + search(queryTerms) { + const candidateIds = new Set(); + for (const term of queryTerms) { + const subTerms = term.toLowerCase().split(/\W+/).filter(Boolean); + for (const subTerm of subTerms) { + const matches = this.index.get(subTerm); + if (matches) { + for (const id of matches) { + candidateIds.add(id); + } + } + } + } + return candidateIds; + } + + getMemory(id) { + return this.memoriesById.get(id); + } +} + +const indexCache = new WeakMap(); + +function getOrCreateIndex(memories) { + let index = indexCache.get(memories); + if (!index) { + index = new LexicalIndex(memories); + indexCache.set(memories, index); + } + return index; +} + /** * @param {import('./schema.js').MemoryRecord} memory * @param {{ tags: string[], timeframe?: { from?: number, to?: number }, pinnedPreference?: 'prefer' | 'only' | 'exclude' }} params @@ -108,10 +160,29 @@ async function buildSimilarityIndex(memories, params) { const queryTerms = normalizedQuery.toLowerCase().split(/\s+/).filter(Boolean); const queryRegexes = queryTerms.map((t) => new RegExp(escapeRegExp(t), 'i')); - for (const memory of memories) { - const lexicalScore = textMatchSimilarity(queryTerms, queryRegexes, memory); - if (lexicalScore > 0) { - similarityById.set(memory.id, lexicalScore); + const isFiltered = params.sourceMemories && params.sourceMemories !== memories; + const index = getOrCreateIndex(params.sourceMemories || memories); + const candidateIds = index.search(queryTerms); + + let targetIds; + if (isFiltered) { + if (candidateIds.size > memories.length) { + targetIds = memories.filter((m) => candidateIds.has(m.id)).map((m) => m.id); + } else { + const memoriesInScope = new Set(memories.map((m) => m.id)); + targetIds = [...candidateIds].filter((id) => memoriesInScope.has(id)); + } + } else { + targetIds = candidateIds; + } + + for (const id of targetIds) { + const memory = index.getMemory(id); + if (memory) { + const lexicalScore = textMatchSimilarity(queryTerms, queryRegexes, memory); + if (lexicalScore > 0) { + similarityById.set(memory.id, lexicalScore); + } } } @@ -154,6 +225,7 @@ export async function filterAndRankMemories(memories, params) { k, vectorAdapter, embedText, + sourceMemories: memories, }); return filtered