Memory: keep keyword hits when hybrid vector misses

This commit is contained in:
Vignesh Natarajan
2026-02-28 14:18:24 -08:00
parent 0929c233d8
commit f57b4669e1
3 changed files with 43 additions and 2 deletions

View File

@@ -81,6 +81,7 @@ Docs: https://docs.openclaw.ai
- Web UI/Assistant text: strip internal `<relevant-memories>...</relevant-memories>` 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.

View File

@@ -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);

View File

@@ -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(