Merge pull request #356 from PR0M3TH3AN/perf-lexical-search-index-1433868811411746305

⚡ Performance Optimization: Inverted Index for Lexical Search
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-02-19 22:52:33 -05:00
commit be849ceae8
4 files changed
+127 -9

No files matched your search

+13
View File
@@ -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
@@ -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
+22 -5
View File
@@ -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);
+76 -4
View File
@@ -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