Skip to content

Commit 8ad671b

Browse files
committed
fix(memory): bound per-keyword hybrid FTS recall
1 parent b0c9d71 commit 8ad671b

4 files changed

Lines changed: 42 additions & 130 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
1212

1313
### Fixes
1414

15+
- Memory: bound provider-backed hybrid keyword fan-out while still recalling split multi-word matches from separate chunks. Carries forward #39555; refs #57711. Thanks @yangzi33 and @ckybit.
1516
- Control UI/WebChat: keep large attachment payloads out of Lit state and optimistic chat messages, using object URL previews plus send-time payload serialization so PDF/image uploads no longer trigger `RangeError: Maximum call stack size exceeded`. Fixes #73360; refs #54378 and #63432. Thanks @hejunhui-73, @Ansub, and @christianhernandez3-afk.
1617
- Agents/models: keep per-agent primary models strict when `fallbacks` is omitted, so probe-only custom providers are not tried as hidden fallback candidates unless the agent explicitly opts in. Fixes #73332. Thanks @haumanto.
1718
- Gateway/models: add `models.pricing.enabled` so offline or restricted-network installs can skip startup OpenRouter and LiteLLM pricing-catalog fetches while keeping explicit model costs working. Fixes #53639. Thanks @callebtc, @palewire, and @rjdjohnston.

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

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,16 @@ describe("memory index", () => {
406406
await fs.mkdir(memoryDir, { recursive: true });
407407
await fs.writeFile(path.join(memoryDir, "alpha-note.md"), "# Alpha\nAlpha protocol notes.");
408408
await fs.writeFile(path.join(memoryDir, "beta-note.md"), "# Beta\nBeta release notes.");
409+
await fs.writeFile(path.join(memoryDir, "gamma-note.md"), "# Gamma\nGamma rollout notes.");
410+
await fs.writeFile(path.join(memoryDir, "delta-note.md"), "# Delta\nDelta incident notes.");
411+
await fs.writeFile(
412+
path.join(memoryDir, "epsilon-note.md"),
413+
"# Epsilon\nEpsilon roadmap notes.",
414+
);
415+
await fs.writeFile(path.join(memoryDir, "zeta-note.md"), "# Zeta\nZeta archive notes.");
416+
await fs.writeFile(path.join(memoryDir, "eta-note.md"), "# Eta\nEta integration notes.");
417+
await fs.writeFile(path.join(memoryDir, "theta-note.md"), "# Theta\nTheta release notes.");
418+
await fs.writeFile(path.join(memoryDir, "iota-note.md"), "# Iota\nIota migration notes.");
409419

410420
const manager = await getFreshManager(
411421
createCfg({
@@ -420,11 +430,34 @@ describe("memory index", () => {
420430
}
421431

422432
await manager.sync({ reason: "test" });
423-
const results = await manager.search("alpha beta", { maxResults: 10 });
433+
let ftsMatchQueries = 0;
434+
const db = (
435+
manager as unknown as {
436+
db: { prepare: (sql: string) => unknown };
437+
}
438+
).db;
439+
const prepare = db.prepare.bind(db);
440+
db.prepare = ((sql: string) => {
441+
if (sql.includes("FROM chunks_fts") && sql.includes("MATCH ?")) {
442+
ftsMatchQueries += 1;
443+
}
444+
return prepare(sql);
445+
}) as typeof db.prepare;
446+
447+
const results = await (async () => {
448+
try {
449+
return await manager.search("alpha beta gamma delta epsilon zeta eta theta iota", {
450+
maxResults: 10,
451+
});
452+
} finally {
453+
db.prepare = prepare;
454+
}
455+
})();
424456
const resultPaths = results.map((result) => result.path);
425457

426458
expect(resultPaths.some((entry) => entry.endsWith("memory/alpha-note.md"))).toBe(true);
427459
expect(resultPaths.some((entry) => entry.endsWith("memory/beta-note.md"))).toBe(true);
460+
expect(ftsMatchQueries).toBeLessThanOrEqual(8);
428461
} finally {
429462
await manager.close?.();
430463
}

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -552,13 +552,12 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
552552
return results.map((entry) => entry as MemorySearchResult & { id: string; textScore: number });
553553
}
554554

555-
private resolveHybridKeywordTerms(query: string): string[] {
555+
private resolveHybridKeywordTerms(query: string, limit = HYBRID_KEYWORD_FANOUT_LIMIT): string[] {
556+
const keywordLimit = Math.max(1, Math.floor(limit));
556557
const keywords = extractKeywords(query, {
557558
ftsTokenizer: this.settings.store.fts.tokenizer,
558559
});
559-
const ranked = keywords
560-
.toSorted((a, b) => b.length - a.length)
561-
.slice(0, HYBRID_KEYWORD_FANOUT_LIMIT);
560+
const ranked = keywords.toSorted((a, b) => b.length - a.length).slice(0, keywordLimit);
562561
return ranked.length > 0 ? ranked : [query];
563562
}
564563

@@ -568,8 +567,11 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
568567
options?: { boostFallbackRanking?: boolean; includeFullQuery?: boolean },
569568
sourceFilterList?: MemorySource[],
570569
): Promise<Array<MemorySearchResult & { id: string; textScore: number }>> {
571-
const terms = this.resolveHybridKeywordTerms(query);
572570
const includeFullQuery = options?.includeFullQuery ?? true;
571+
const keywordLimit = includeFullQuery
572+
? HYBRID_KEYWORD_FANOUT_LIMIT - 1
573+
: HYBRID_KEYWORD_FANOUT_LIMIT;
574+
const terms = this.resolveHybridKeywordTerms(query, keywordLimit);
573575
const searchTerms = includeFullQuery
574576
? [query, ...terms.filter((term) => term !== query)]
575577
: terms;

src/memory/manager.hybrid-keyword-multi.test.ts

Lines changed: 0 additions & 124 deletions
This file was deleted.

0 commit comments

Comments
 (0)