diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index c62fedc58970..a06314937ba7 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -e3b8988a10c61dbf0a78a70bca9ef1ab43c6a58aeaa5ef9f8699f34b6dae4c9d config-baseline.json -a2f53abfe6bbe8b1ddfa5548f555704d8ff0cdd48bcb5780d66499bec0b7775a config-baseline.core.json -3d0f7723873da553f25dfe6892a586d774fa36e447de487eba4dd3e0a012f877 config-baseline.channel.json +60c0700719fd2fe3f7cec4c35da10227b681d87ed1a3876ef830eb6bd80d43f2 config-baseline.json +2ed21fa4a416ac2cec55eb2b6d1b11859aa04b40bd78c6ed9f3eb45b7240261c config-baseline.core.json +0637c9bdcb9517f56049dd786563366877458d35df575328a6b80a890c8bc915 config-baseline.channel.json e6a1d6f51f0d9c04bd92d51deebfaca8c7917dd28d7998d225c0074e0a095348 config-baseline.plugin.json diff --git a/docs/concepts/memory-qmd.md b/docs/concepts/memory-qmd.md index b73b0ba1d45e..f363378ea9d3 100644 --- a/docs/concepts/memory-qmd.md +++ b/docs/concepts/memory-qmd.md @@ -70,6 +70,10 @@ present. `vsearch` and `query`). `search` is BM25-only, so OpenClaw skips semantic vector readiness probes and embedding maintenance in that mode. If a mode fails, OpenClaw retries with `qmd query`. +- When `searchMode` is `query`, set `memory.qmd.rerank` to `false` to use QMD's + hybrid query path without the reranker. OpenClaw passes `--no-rerank` to the + direct QMD CLI path and `rerank: false` to QMD's MCP query tool. This option + requires QMD 2.1 or newer. - With QMD releases that advertise multi-collection filters, OpenClaw groups same-source collections into one QMD search invocation. Older QMD releases keep the compatible per-collection fallback. diff --git a/docs/reference/memory-config.md b/docs/reference/memory-config.md index bb0666be7fb3..94b93f9628da 100644 --- a/docs/reference/memory-config.md +++ b/docs/reference/memory-config.md @@ -467,6 +467,7 @@ Set `memory.backend = "qmd"` to enable. All QMD settings live under `memory.qmd` | ------------------------ | --------- | -------- | ------------------------------------------------------------------------------------- | | `command` | `string` | `qmd` | QMD executable path; set an absolute path when service `PATH` differs from your shell | | `searchMode` | `string` | `search` | Search command: `search`, `vsearch`, `query` | +| `rerank` | `boolean` | -- | Set to `false` with `searchMode: "query"` and QMD 2.1+ to skip QMD reranking | | `includeDefaultMemory` | `boolean` | `true` | Auto-index `MEMORY.md` + `memory/**/*.md` | | `paths[]` | `array` | -- | Extra paths: `{ name, path, pattern? }` | | `sessions.enabled` | `boolean` | `false` | Index session transcripts | @@ -475,6 +476,8 @@ Set `memory.backend = "qmd"` to enable. All QMD settings live under `memory.qmd` `searchMode: "search"` is lexical/BM25-only. OpenClaw does not run semantic vector readiness probes or QMD embedding maintenance for that mode, including during `memory status --deep`; `vsearch` and `query` continue to require QMD vector readiness and embeddings. +`rerank: false` only changes QMD `query` mode and requires QMD 2.1 or newer. In direct CLI mode OpenClaw passes `--no-rerank`; in mcporter-backed MCP mode it passes `rerank: false` to QMD's unified query tool. Leave it unset to use QMD's default query reranking behavior. + OpenClaw prefers current QMD collection and MCP query shapes, but keeps older QMD releases working by trying compatible collection pattern flags and older MCP tool names when needed. When QMD advertises support for multiple collection filters, same-source collections are searched with one QMD process; older QMD builds keep the per-collection compatibility path. Same-source means durable memory collections are grouped together, while session transcript collections remain a separate group so source diversification still has both inputs. diff --git a/extensions/memory-core/src/memory/qmd-manager.test.ts b/extensions/memory-core/src/memory/qmd-manager.test.ts index 7b3b0ab978f4..4a6b3f63833e 100644 --- a/extensions/memory-core/src/memory/qmd-manager.test.ts +++ b/extensions/memory-core/src/memory/qmd-manager.test.ts @@ -2362,6 +2362,98 @@ describe("QmdMemoryManager", () => { await manager.close(); }); + it("passes --no-rerank to direct qmd query when query reranking is disabled", async () => { + cfg = { + ...cfg, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: false, + searchMode: "query", + rerank: false, + update: { interval: "0s", debounceMs: 60_000, onBoot: false }, + paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }], + }, + }, + } as OpenClawConfig; + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "query") { + const child = createMockChild({ autoClose: false }); + emitAndClose(child, "stdout", "[]"); + return child; + } + return createMockChild(); + }); + + const { manager, resolved } = await createManager(); + const maxResults = resolved.qmd?.limits.maxResults; + if (!maxResults) { + throw new Error("qmd maxResults missing"); + } + + await expect( + manager.search("test", { sessionKey: "agent:main:slack:dm:u123" }), + ).resolves.toStrictEqual([]); + + const queryCalls = spawnMock.mock.calls + .map((call: unknown[]) => call[1] as string[]) + .filter((args: string[]) => args[0] === "query"); + expect(queryCalls).toEqual([ + ["query", "test", "--json", "-n", String(maxResults), "--no-rerank", "-c", "workspace-main"], + ]); + await manager.close(); + }); + + it("does not pass --no-rerank to direct query fallback from search mode", async () => { + cfg = { + ...cfg, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: false, + searchMode: "search", + rerank: false, + update: { interval: "0s", debounceMs: 60_000, onBoot: false }, + paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }], + }, + }, + } as OpenClawConfig; + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "search") { + const child = createMockChild({ autoClose: false }); + emitAndClose(child, "stderr", "unknown flag: --json", 2); + return child; + } + if (args[0] === "query") { + const child = createMockChild({ autoClose: false }); + emitAndClose(child, "stdout", "[]"); + return child; + } + return createMockChild(); + }); + + const { manager, resolved } = await createManager(); + const maxResults = resolved.qmd?.limits.maxResults; + if (!maxResults) { + throw new Error("qmd maxResults missing"); + } + + await expect( + manager.search("test", { sessionKey: "agent:main:slack:dm:u123" }), + ).resolves.toStrictEqual([]); + + const searchAndQueryCalls = spawnMock.mock.calls + .map((call: unknown[]) => call[1]) + .filter( + (args): args is string[] => Array.isArray(args) && ["search", "query"].includes(args[0]), + ); + expect(searchAndQueryCalls).toEqual([ + ["search", "test", "--json", "-n", String(maxResults), "-c", "workspace-main"], + ["query", "test", "--json", "-n", String(maxResults), "-c", "workspace-main"], + ]); + await manager.close(); + }); + it("queues a forced sync behind an in-flight update", async () => { cfg = { ...cfg, @@ -2888,6 +2980,50 @@ describe("QmdMemoryManager", () => { expect(callArgs).not.toHaveProperty("query"); expect(callArgs).not.toHaveProperty("minScore"); expect(callArgs).not.toHaveProperty("collection"); + expect(callArgs).not.toHaveProperty("rerank"); + emitAndClose(child, "stdout", JSON.stringify({ results: [] })); + return child; + } + emitAndClose(child, "stdout", "[]"); + return child; + }); + + const { manager } = await createManager(); + await manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" }); + await manager.close(); + }); + + it("passes rerank false to QMD 1.1+ query tool via mcporter when query reranking is disabled", async () => { + cfg = { + ...cfg, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: false, + searchMode: "query", + rerank: false, + update: { interval: "0s", debounceMs: 60_000, onBoot: false }, + paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }], + mcporter: { enabled: true, serverName: "qmd", startDaemon: false }, + }, + }, + } as OpenClawConfig; + + spawnMock.mockImplementation((cmd: string, args: string[]) => { + const child = createMockChild({ autoClose: false }); + if (isMcporterCommand(cmd) && args[0] === "call") { + expect(args[1]).toBe("qmd.query"); + const callArgs = JSON.parse(args[args.indexOf("--args") + 1]); + expect(callArgs).toMatchObject({ + searches: [ + { type: "lex", query: "hello" }, + { type: "vec", query: "hello" }, + { type: "hyde", query: "hello" }, + ], + limit: expect.any(Number), + collections: ["workspace-main"], + rerank: false, + }); emitAndClose(child, "stdout", JSON.stringify({ results: [] })); return child; } @@ -3261,6 +3397,52 @@ describe("QmdMemoryManager", () => { await manager.close(); }); + it('passes rerank false when explicit mcporter search tool override is "query"', async () => { + cfg = { + ...cfg, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: false, + searchMode: "query", + searchTool: "query", + rerank: false, + update: { interval: "0s", debounceMs: 60_000, onBoot: false }, + paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }], + mcporter: { enabled: true, serverName: "qmd", startDaemon: false }, + }, + }, + } as OpenClawConfig; + + spawnMock.mockImplementation((cmd: string, args: string[]) => { + const child = createMockChild({ autoClose: false }); + if (isMcporterCommand(cmd) && args[0] === "call") { + expect(args[1]).toBe("qmd.query"); + const callArgs = JSON.parse(args[args.indexOf("--args") + 1]); + expect(callArgs).toMatchObject({ + searches: [ + { type: "lex", query: "hello" }, + { type: "vec", query: "hello" }, + { type: "hyde", query: "hello" }, + ], + collections: ["workspace-main"], + rerank: false, + }); + expect(callArgs).not.toHaveProperty("query"); + expect(callArgs).not.toHaveProperty("minScore"); + expect(callArgs).not.toHaveProperty("collection"); + emitAndClose(child, "stdout", JSON.stringify({ results: [] })); + return child; + } + emitAndClose(child, "stdout", "[]"); + return child; + }); + + const { manager } = await createManager(); + await manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" }); + await manager.close(); + }); + it('reuses the cached v1 tool across collections when the explicit mcporter override is "query"', async () => { cfg = { ...cfg, diff --git a/extensions/memory-core/src/memory/qmd-manager.ts b/extensions/memory-core/src/memory/qmd-manager.ts index 5045ad0f4271..0cb3a5774f37 100644 --- a/extensions/memory-core/src/memory/qmd-manager.ts +++ b/extensions/memory-core/src/memory/qmd-manager.ts @@ -2198,6 +2198,14 @@ export class QmdMemoryManager implements MemorySearchManager { callArgs.collection = params.collection; } } + if ( + useUnifiedQueryTool && + params.searchCommand === "query" && + this.qmd.searchMode === "query" && + this.qmd.rerank === false + ) { + callArgs.rerank = false; + } let result: { stdout: string }; try { @@ -3266,7 +3274,11 @@ export class QmdMemoryManager implements MemorySearchManager { ): string[] { const normalizedQuery = command === "search" ? normalizeHanBm25Query(query) : query; if (command === "query") { - return ["query", normalizedQuery, "--json", "-n", String(limit)]; + const args = ["query", normalizedQuery, "--json", "-n", String(limit)]; + if (this.qmd.searchMode === "query" && this.qmd.rerank === false) { + args.push("--no-rerank"); + } + return args; } return [command, normalizedQuery, "--json", "-n", String(limit)]; } diff --git a/packages/memory-host-sdk/src/host/backend-config.test.ts b/packages/memory-host-sdk/src/host/backend-config.test.ts index 98b914e075a2..138981836fef 100644 --- a/packages/memory-host-sdk/src/host/backend-config.test.ts +++ b/packages/memory-host-sdk/src/host/backend-config.test.ts @@ -508,6 +508,23 @@ describe("resolveMemoryBackendConfig", () => { expect(requireQmdConfig(resolved).searchMode).toBe("vsearch"); }); + it("resolves qmd rerank override", () => { + const cfg = { + agents: { defaults: { workspace: "/tmp/memory-test" } }, + memory: { + backend: "qmd", + qmd: { + searchMode: "query", + rerank: false, + }, + }, + } as OpenClawConfig; + const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); + const qmd = requireQmdConfig(resolved); + expect(qmd.searchMode).toBe("query"); + expect(qmd.rerank).toBe(false); + }); + it("resolves qmd mcporter search tool override", () => { const cfg = { agents: { defaults: { workspace: "/tmp/memory-test" } }, diff --git a/packages/memory-host-sdk/src/host/backend-config.ts b/packages/memory-host-sdk/src/host/backend-config.ts index 6548c92f0164..be6f2ba0ee9b 100644 --- a/packages/memory-host-sdk/src/host/backend-config.ts +++ b/packages/memory-host-sdk/src/host/backend-config.ts @@ -78,6 +78,7 @@ export type ResolvedQmdConfig = { command: string; mcporter: ResolvedQmdMcporterConfig; searchMode: MemoryQmdSearchMode; + rerank?: boolean; searchTool?: string; collections: ResolvedQmdCollection[]; sessions: ResolvedQmdSessionConfig; @@ -444,6 +445,7 @@ export function resolveMemoryBackendConfig(params: { command, mcporter: resolveMcporterConfig(qmdCfg?.mcporter), searchMode: resolveSearchMode(qmdCfg?.searchMode), + rerank: qmdCfg?.rerank, searchTool: resolveSearchTool(qmdCfg?.searchTool), collections, includeDefaultMemory, diff --git a/packages/memory-host-sdk/src/host/config-utils.ts b/packages/memory-host-sdk/src/host/config-utils.ts index f5f8b7449dcb..7753ea80712a 100644 --- a/packages/memory-host-sdk/src/host/config-utils.ts +++ b/packages/memory-host-sdk/src/host/config-utils.ts @@ -91,6 +91,7 @@ export type MemoryQmdConfig = { command?: string; mcporter?: MemoryQmdMcporterConfig; searchMode?: MemoryQmdSearchMode; + rerank?: boolean; searchTool?: string; includeDefaultMemory?: boolean; paths?: MemoryQmdIndexPath[]; diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index 3bc618add8e7..75dc1a9d6e25 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -44,6 +44,7 @@ const TARGET_KEYS = [ "memory.citations", "memory.backend", "memory.qmd.searchMode", + "memory.qmd.rerank", "memory.qmd.searchTool", "memory.qmd.scope", "memory.qmd.includeDefaultMemory", diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index d25cdec3a44a..bfd6b99409cf 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1277,6 +1277,8 @@ export const FIELD_HELP: Record = { "Automatically starts the mcporter daemon when mcporter-backed QMD mode is enabled (default: true). Keep enabled unless process lifecycle is managed externally by your service supervisor.", "memory.qmd.searchMode": 'Selects the QMD retrieval path: "query" uses standard query flow, "search" uses search-oriented retrieval, and "vsearch" emphasizes vector retrieval. Keep default unless tuning relevance quality.', + "memory.qmd.rerank": + 'Controls QMD query reranking. Set to false with searchMode "query" and QMD 2.1+ to skip QMD reranking for faster hybrid results; leave unset for QMD defaults.', "memory.qmd.searchTool": "Overrides the exact mcporter tool name used for QMD searches while preserving `searchMode` as the semantic retrieval mode. Use this only when your QMD MCP server exposes a custom tool such as `hybrid_search` and keep it unset for the normal built-in tool mapping.", "memory.qmd.includeDefaultMemory": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 03947211776e..462a31df30fb 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -516,6 +516,7 @@ export const FIELD_LABELS: Record = { "memory.qmd.mcporter.serverName": "QMD MCPorter Server Name", "memory.qmd.mcporter.startDaemon": "QMD MCPorter Start Daemon", "memory.qmd.searchMode": "QMD Search Mode", + "memory.qmd.rerank": "QMD Rerank", "memory.qmd.searchTool": "QMD Search Tool Override", "memory.qmd.includeDefaultMemory": "QMD Include Default Memory", "memory.qmd.paths": "QMD Extra Paths", diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 44da1b4a7937..7f629faac78b 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -131,6 +131,19 @@ describe("config schema", () => { expect(res.generatedAt.trim().length).toBeGreaterThan(0); }); + it("accepts qmd query rerank override", () => { + const result = OpenClawSchema.safeParse({ + memory: { + backend: "qmd", + qmd: { + searchMode: "query", + rerank: false, + }, + }, + }); + expect(result.success).toBe(true); + }); + it("includes MCP SSE header schema under mcp.servers entries", () => { const schema = baseSchema.schema as { properties?: Record; diff --git a/src/config/types.memory.ts b/src/config/types.memory.ts index 47f62e4c0f8b..4237314b38fb 100644 --- a/src/config/types.memory.ts +++ b/src/config/types.memory.ts @@ -25,6 +25,7 @@ export type MemoryQmdConfig = { command?: string; mcporter?: MemoryQmdMcporterConfig; searchMode?: MemoryQmdSearchMode; + rerank?: boolean; searchTool?: string; includeDefaultMemory?: boolean; paths?: MemoryQmdIndexPath[]; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 2415d8620bb5..fc7109b12b31 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -220,6 +220,7 @@ const MemoryQmdSchema = z command: z.string().optional(), mcporter: MemoryQmdMcporterSchema.optional(), searchMode: z.union([z.literal("query"), z.literal("search"), z.literal("vsearch")]).optional(), + rerank: z.boolean().optional(), searchTool: z.string().trim().min(1).optional(), includeDefaultMemory: z.boolean().optional(), paths: z.array(MemoryQmdPathSchema).optional(),