diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d05f853c8f..4c7729f34da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,7 @@ Docs: https://docs.openclaw.ai - Web UI/Assistant text: strip internal `...` scaffolding from rendered assistant messages (while preserving code-fence literals), preventing memory-context leakage in chat output for models that echo internal blocks. (#29851) Thanks @Valkster70. - Dashboard/Sessions: allow authenticated Control UI clients to delete and patch sessions while still blocking regular webchat clients from session mutation RPCs, fixing Dashboard session delete failures. (#21264) Thanks @jskoiz. - TUI/Session model status: clear stale runtime model identity when model overrides change so `/model` updates are reflected immediately in `sessions.patch` responses and `sessions.list` status surfaces. (#28619) Thanks @lejean2000. +- Memory/Hybrid recall: when strict hybrid scoring yields no hits, preserve keyword-backed matches using a text-weight floor so freshly indexed lexical canaries no longer disappear behind `minScore` filtering. (#29112) Thanks @ceo-nada. - Podman/Quadlet setup: fix `sed` escaping and UID mismatch in Podman Quadlet setup. (#26414) Thanks @KnHack and @vincentkoc. - Browser/Navigate: resolve the correct `targetId` in navigate responses after renderer swaps. (#25326) Thanks @stone-jin and @vincentkoc. - Agents/Ollama discovery: skip Ollama discovery when explicit models are configured. (#28827) Thanks @Kansodata and @vincentkoc. diff --git a/src/memory/index.test.ts b/src/memory/index.test.ts index 9174805105d..861862d4f5c 100644 --- a/src/memory/index.test.ts +++ b/src/memory/index.test.ts @@ -98,6 +98,7 @@ describe("memory index", () => { model?: string; vectorEnabled?: boolean; cacheEnabled?: boolean; + minScore?: number; hybrid?: { enabled: boolean; vectorWeight?: number; textWeight?: number }; }): TestCfg { return { @@ -112,7 +113,7 @@ describe("memory index", () => { chunking: { tokens: 4000, overlap: 0 }, sync: { watch: false, onSessionStart: false, onSearch: true }, query: { - minScore: 0, + minScore: params.minScore ?? 0, hybrid: params.hybrid ?? { enabled: false }, }, cache: params.cacheEnabled ? { enabled: true } : undefined, @@ -367,6 +368,25 @@ describe("memory index", () => { expect(results[0]?.path).toContain("memory/2026-01-12.md"); }); + it("preserves keyword-only hybrid hits when minScore exceeds text weight", async () => { + const cfg = createCfg({ + storePath: indexMainPath, + minScore: 0.35, + hybrid: { enabled: true, vectorWeight: 0.7, textWeight: 0.3 }, + }); + const manager = await getPersistentManager(cfg); + + const status = manager.status(); + if (!status.fts?.available) { + return; + } + + await manager.sync({ reason: "test" }); + const results = await manager.search("zebra"); + expect(results.length).toBeGreaterThan(0); + expect(results[0]?.path).toContain("memory/2026-01-12.md"); + }); + it("reports vector availability after probe", async () => { const cfg = createCfg({ storePath: indexVectorPath, vectorEnabled: true }); const manager = await getPersistentManager(cfg); diff --git a/src/memory/manager.ts b/src/memory/manager.ts index 708e9e7b2c7..36460df87ad 100644 --- a/src/memory/manager.ts +++ b/src/memory/manager.ts @@ -311,8 +311,28 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem mmr: hybrid.mmr, temporalDecay: hybrid.temporalDecay, }); + const strict = merged.filter((entry) => entry.score >= minScore); + if (strict.length > 0 || keywordResults.length === 0) { + return strict.slice(0, maxResults); + } - return merged.filter((entry) => entry.score >= minScore).slice(0, maxResults); + // Hybrid defaults can produce keyword-only matches with max score equal to + // textWeight (for example 0.3). If minScore is higher (for example 0.35), + // these exact lexical hits get filtered out even when they are the only + // relevant results. + const relaxedMinScore = Math.min(minScore, hybrid.textWeight); + const keywordKeys = new Set( + keywordResults.map( + (entry) => `${entry.source}:${entry.path}:${entry.startLine}:${entry.endLine}`, + ), + ); + return merged + .filter( + (entry) => + keywordKeys.has(`${entry.source}:${entry.path}:${entry.startLine}:${entry.endLine}`) && + entry.score >= relaxedMinScore, + ) + .slice(0, maxResults); } private async searchVector(