Skip to content

Commit a39adae

Browse files
yangzi33vincentkoc
authored andcommitted
fix(memory): use per-keyword FTS search in hybrid mode to fix multi-word zero-hit queries #39484)
1 parent c788aa0 commit a39adae

2 files changed

Lines changed: 86 additions & 19 deletions

File tree

extensions/memory-core/src/memory/index.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ describe("memory index", () => {
152152
let memoryDir = "";
153153
let indexVectorPath = "";
154154
let indexMainPath = "";
155+
let indexHybridKeywordsPath = "";
155156
let indexMultimodalPath = "";
156157

157158
const managersForCleanup = new Set<MemoryIndexManager>();
@@ -162,6 +163,7 @@ describe("memory index", () => {
162163
memoryDir = path.join(workspaceDir, "memory");
163164
indexMainPath = path.join(workspaceDir, "index-main.sqlite");
164165
indexVectorPath = path.join(workspaceDir, "index-vector.sqlite");
166+
indexHybridKeywordsPath = path.join(workspaceDir, "index-hybrid-keywords.sqlite");
165167
indexMultimodalPath = path.join(workspaceDir, "index-multimodal.sqlite");
166168
});
167169

@@ -399,6 +401,35 @@ describe("memory index", () => {
399401
);
400402
});
401403

404+
it("finds provider-backed hybrid keyword matches when multi-word terms are split across files", async () => {
405+
await fs.rm(memoryDir, { recursive: true, force: true });
406+
await fs.mkdir(memoryDir, { recursive: true });
407+
await fs.writeFile(path.join(memoryDir, "alpha-note.md"), "# Alpha\nAlpha protocol notes.");
408+
await fs.writeFile(path.join(memoryDir, "beta-note.md"), "# Beta\nBeta release notes.");
409+
410+
const manager = await getFreshManager(
411+
createCfg({
412+
storePath: indexHybridKeywordsPath,
413+
minScore: 0,
414+
hybrid: { enabled: true, vectorWeight: 0.7, textWeight: 0.3 },
415+
}),
416+
);
417+
try {
418+
if (!manager.status().fts?.available) {
419+
return;
420+
}
421+
422+
await manager.sync({ reason: "test" });
423+
const results = await manager.search("alpha beta", { maxResults: 10 });
424+
const resultPaths = results.map((result) => result.path);
425+
426+
expect(resultPaths.some((entry) => entry.endsWith("memory/alpha-note.md"))).toBe(true);
427+
expect(resultPaths.some((entry) => entry.endsWith("memory/beta-note.md"))).toBe(true);
428+
} finally {
429+
await manager.close?.();
430+
}
431+
});
432+
402433
it("reports vector availability after probe", async () => {
403434
const cfg = createCfg({ storePath: indexVectorPath, vectorEnabled: true });
404435
const manager = await getPersistentManager(cfg);

extensions/memory-core/src/memory/manager.ts

Lines changed: 55 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ const VECTOR_TABLE = "chunks_vec";
6262
const FTS_TABLE = "chunks_fts";
6363
const EMBEDDING_CACHE_TABLE = "embedding_cache";
6464
const MEMORY_INDEX_MANAGER_CACHE_KEY = Symbol.for("openclaw.memoryIndexManagerCache");
65+
const HYBRID_KEYWORD_FANOUT_LIMIT = 8;
6566
export const EMBEDDING_PROBE_CACHE_TTL_MS = 30_000;
6667
const log = createSubsystemLogger("memory");
6768
type MemoryIndexManagerPurpose = "default" | "status" | "cli";
@@ -384,24 +385,14 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
384385
const resultSets =
385386
fullQueryResults.length > 0
386387
? [fullQueryResults]
387-
: await Promise.all(
388-
// Fallback: broaden recall for conversational queries when the
389-
// exact AND query is too strict to return any results.
390-
(() => {
391-
const keywords = extractKeywords(cleaned, {
392-
ftsTokenizer: this.settings.store.fts.tokenizer,
393-
});
394-
const searchTerms = keywords.length > 0 ? keywords : [cleaned];
395-
return searchTerms.map((term) =>
396-
this.searchKeyword(
397-
term,
398-
candidates,
399-
{ boostFallbackRanking: true },
400-
sourceFilterList,
401-
).catch(() => []),
402-
);
403-
})(),
404-
);
388+
: [
389+
await this.searchKeywordsMulti(
390+
cleaned,
391+
candidates,
392+
{ boostFallbackRanking: true, includeFullQuery: false },
393+
sourceFilterList,
394+
),
395+
];
405396

406397
// Merge and deduplicate results, keeping highest score for each chunk
407398
const seenIds = new Map<string, (typeof resultSets)[0][0]>();
@@ -427,7 +418,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
427418
// If FTS isn't available, hybrid mode cannot use keyword search; degrade to vector-only.
428419
const keywordResults =
429420
hybrid.enabled && this.fts.enabled && this.fts.available
430-
? await this.searchKeyword(cleaned, candidates, undefined, sourceFilterList).catch(() => [])
421+
? await this.searchKeywordsMulti(cleaned, candidates, undefined, sourceFilterList)
431422
: [];
432423

433424
const queryVec = await this.embedQueryWithTimeout(cleaned);
@@ -561,6 +552,51 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
561552
return results.map((entry) => entry as MemorySearchResult & { id: string; textScore: number });
562553
}
563554

555+
private resolveHybridKeywordTerms(query: string): string[] {
556+
const keywords = extractKeywords(query, {
557+
ftsTokenizer: this.settings.store.fts.tokenizer,
558+
});
559+
const ranked = keywords
560+
.toSorted((a, b) => b.length - a.length)
561+
.slice(0, HYBRID_KEYWORD_FANOUT_LIMIT);
562+
return ranked.length > 0 ? ranked : [query];
563+
}
564+
565+
private async searchKeywordsMulti(
566+
query: string,
567+
limit: number,
568+
options?: { boostFallbackRanking?: boolean; includeFullQuery?: boolean },
569+
sourceFilterList?: MemorySource[],
570+
): Promise<Array<MemorySearchResult & { id: string; textScore: number }>> {
571+
const terms = this.resolveHybridKeywordTerms(query);
572+
const includeFullQuery = options?.includeFullQuery ?? true;
573+
const searchTerms = includeFullQuery
574+
? [query, ...terms.filter((term) => term !== query)]
575+
: terms;
576+
const resultSets = await Promise.all(
577+
searchTerms.map((term) =>
578+
this.searchKeyword(
579+
term,
580+
limit,
581+
{ boostFallbackRanking: options?.boostFallbackRanking },
582+
sourceFilterList,
583+
).catch(() => []),
584+
),
585+
);
586+
587+
const byId = new Map<string, MemorySearchResult & { id: string; textScore: number }>();
588+
for (const results of resultSets) {
589+
for (const result of results) {
590+
const existing = byId.get(result.id);
591+
if (!existing || result.score > existing.score) {
592+
byId.set(result.id, result);
593+
}
594+
}
595+
}
596+
597+
return [...byId.values()].toSorted((a, b) => b.score - a.score).slice(0, limit);
598+
}
599+
564600
private mergeHybridResults(params: {
565601
vector: Array<MemorySearchResult & { id: string }>;
566602
keyword: Array<MemorySearchResult & { id: string; textScore: number }>;

0 commit comments

Comments
 (0)