feat(memory): support qmd query rerank toggle

Add memory.qmd.rerank as an opt-out for QMD query reranking when searchMode is query.

When set to false, direct QMD query calls pass --no-rerank and the mcporter unified query tool receives rerank:false. Search and vsearch modes keep their existing behavior.

Refs #61834.
This commit is contained in:
Onur Solmaz
2026-06-05 11:18:57 +08:00
committed by GitHub
parent f3abe61b78
commit 0dbf17471b
14 changed files with 244 additions and 4 deletions

View File

@@ -1,4 +1,4 @@
e3b8988a10c61dbf0a78a70bca9ef1ab43c6a58aeaa5ef9f8699f34b6dae4c9d config-baseline.json 60c0700719fd2fe3f7cec4c35da10227b681d87ed1a3876ef830eb6bd80d43f2 config-baseline.json
a2f53abfe6bbe8b1ddfa5548f555704d8ff0cdd48bcb5780d66499bec0b7775a config-baseline.core.json 2ed21fa4a416ac2cec55eb2b6d1b11859aa04b40bd78c6ed9f3eb45b7240261c config-baseline.core.json
3d0f7723873da553f25dfe6892a586d774fa36e447de487eba4dd3e0a012f877 config-baseline.channel.json 0637c9bdcb9517f56049dd786563366877458d35df575328a6b80a890c8bc915 config-baseline.channel.json
e6a1d6f51f0d9c04bd92d51deebfaca8c7917dd28d7998d225c0074e0a095348 config-baseline.plugin.json e6a1d6f51f0d9c04bd92d51deebfaca8c7917dd28d7998d225c0074e0a095348 config-baseline.plugin.json

View File

@@ -70,6 +70,10 @@ present.
`vsearch` and `query`). `search` is BM25-only, so OpenClaw skips semantic `vsearch` and `query`). `search` is BM25-only, so OpenClaw skips semantic
vector readiness probes and embedding maintenance in that mode. If a mode vector readiness probes and embedding maintenance in that mode. If a mode
fails, OpenClaw retries with `qmd query`. 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 - With QMD releases that advertise multi-collection filters, OpenClaw groups
same-source collections into one QMD search invocation. Older QMD releases same-source collections into one QMD search invocation. Older QMD releases
keep the compatible per-collection fallback. keep the compatible per-collection fallback.

View File

@@ -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 | | `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` | | `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` | | `includeDefaultMemory` | `boolean` | `true` | Auto-index `MEMORY.md` + `memory/**/*.md` |
| `paths[]` | `array` | -- | Extra paths: `{ name, path, pattern? }` | | `paths[]` | `array` | -- | Extra paths: `{ name, path, pattern? }` |
| `sessions.enabled` | `boolean` | `false` | Index session transcripts | | `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. `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. 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.
<Note> <Note>

View File

@@ -2362,6 +2362,98 @@ describe("QmdMemoryManager", () => {
await manager.close(); 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 () => { it("queues a forced sync behind an in-flight update", async () => {
cfg = { cfg = {
...cfg, ...cfg,
@@ -2888,6 +2980,50 @@ describe("QmdMemoryManager", () => {
expect(callArgs).not.toHaveProperty("query"); expect(callArgs).not.toHaveProperty("query");
expect(callArgs).not.toHaveProperty("minScore"); expect(callArgs).not.toHaveProperty("minScore");
expect(callArgs).not.toHaveProperty("collection"); 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: [] })); emitAndClose(child, "stdout", JSON.stringify({ results: [] }));
return child; return child;
} }
@@ -3261,6 +3397,52 @@ describe("QmdMemoryManager", () => {
await manager.close(); 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 () => { it('reuses the cached v1 tool across collections when the explicit mcporter override is "query"', async () => {
cfg = { cfg = {
...cfg, ...cfg,

View File

@@ -2198,6 +2198,14 @@ export class QmdMemoryManager implements MemorySearchManager {
callArgs.collection = params.collection; callArgs.collection = params.collection;
} }
} }
if (
useUnifiedQueryTool &&
params.searchCommand === "query" &&
this.qmd.searchMode === "query" &&
this.qmd.rerank === false
) {
callArgs.rerank = false;
}
let result: { stdout: string }; let result: { stdout: string };
try { try {
@@ -3266,7 +3274,11 @@ export class QmdMemoryManager implements MemorySearchManager {
): string[] { ): string[] {
const normalizedQuery = command === "search" ? normalizeHanBm25Query(query) : query; const normalizedQuery = command === "search" ? normalizeHanBm25Query(query) : query;
if (command === "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)]; return [command, normalizedQuery, "--json", "-n", String(limit)];
} }

View File

@@ -508,6 +508,23 @@ describe("resolveMemoryBackendConfig", () => {
expect(requireQmdConfig(resolved).searchMode).toBe("vsearch"); 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", () => { it("resolves qmd mcporter search tool override", () => {
const cfg = { const cfg = {
agents: { defaults: { workspace: "/tmp/memory-test" } }, agents: { defaults: { workspace: "/tmp/memory-test" } },

View File

@@ -78,6 +78,7 @@ export type ResolvedQmdConfig = {
command: string; command: string;
mcporter: ResolvedQmdMcporterConfig; mcporter: ResolvedQmdMcporterConfig;
searchMode: MemoryQmdSearchMode; searchMode: MemoryQmdSearchMode;
rerank?: boolean;
searchTool?: string; searchTool?: string;
collections: ResolvedQmdCollection[]; collections: ResolvedQmdCollection[];
sessions: ResolvedQmdSessionConfig; sessions: ResolvedQmdSessionConfig;
@@ -444,6 +445,7 @@ export function resolveMemoryBackendConfig(params: {
command, command,
mcporter: resolveMcporterConfig(qmdCfg?.mcporter), mcporter: resolveMcporterConfig(qmdCfg?.mcporter),
searchMode: resolveSearchMode(qmdCfg?.searchMode), searchMode: resolveSearchMode(qmdCfg?.searchMode),
rerank: qmdCfg?.rerank,
searchTool: resolveSearchTool(qmdCfg?.searchTool), searchTool: resolveSearchTool(qmdCfg?.searchTool),
collections, collections,
includeDefaultMemory, includeDefaultMemory,

View File

@@ -91,6 +91,7 @@ export type MemoryQmdConfig = {
command?: string; command?: string;
mcporter?: MemoryQmdMcporterConfig; mcporter?: MemoryQmdMcporterConfig;
searchMode?: MemoryQmdSearchMode; searchMode?: MemoryQmdSearchMode;
rerank?: boolean;
searchTool?: string; searchTool?: string;
includeDefaultMemory?: boolean; includeDefaultMemory?: boolean;
paths?: MemoryQmdIndexPath[]; paths?: MemoryQmdIndexPath[];

View File

@@ -44,6 +44,7 @@ const TARGET_KEYS = [
"memory.citations", "memory.citations",
"memory.backend", "memory.backend",
"memory.qmd.searchMode", "memory.qmd.searchMode",
"memory.qmd.rerank",
"memory.qmd.searchTool", "memory.qmd.searchTool",
"memory.qmd.scope", "memory.qmd.scope",
"memory.qmd.includeDefaultMemory", "memory.qmd.includeDefaultMemory",

View File

@@ -1277,6 +1277,8 @@ export const FIELD_HELP: Record<string, string> = {
"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.", "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": "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.', '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": "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.", "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": "memory.qmd.includeDefaultMemory":

View File

@@ -516,6 +516,7 @@ export const FIELD_LABELS: Record<string, string> = {
"memory.qmd.mcporter.serverName": "QMD MCPorter Server Name", "memory.qmd.mcporter.serverName": "QMD MCPorter Server Name",
"memory.qmd.mcporter.startDaemon": "QMD MCPorter Start Daemon", "memory.qmd.mcporter.startDaemon": "QMD MCPorter Start Daemon",
"memory.qmd.searchMode": "QMD Search Mode", "memory.qmd.searchMode": "QMD Search Mode",
"memory.qmd.rerank": "QMD Rerank",
"memory.qmd.searchTool": "QMD Search Tool Override", "memory.qmd.searchTool": "QMD Search Tool Override",
"memory.qmd.includeDefaultMemory": "QMD Include Default Memory", "memory.qmd.includeDefaultMemory": "QMD Include Default Memory",
"memory.qmd.paths": "QMD Extra Paths", "memory.qmd.paths": "QMD Extra Paths",

View File

@@ -131,6 +131,19 @@ describe("config schema", () => {
expect(res.generatedAt.trim().length).toBeGreaterThan(0); 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", () => { it("includes MCP SSE header schema under mcp.servers entries", () => {
const schema = baseSchema.schema as { const schema = baseSchema.schema as {
properties?: Record<string, unknown>; properties?: Record<string, unknown>;

View File

@@ -25,6 +25,7 @@ export type MemoryQmdConfig = {
command?: string; command?: string;
mcporter?: MemoryQmdMcporterConfig; mcporter?: MemoryQmdMcporterConfig;
searchMode?: MemoryQmdSearchMode; searchMode?: MemoryQmdSearchMode;
rerank?: boolean;
searchTool?: string; searchTool?: string;
includeDefaultMemory?: boolean; includeDefaultMemory?: boolean;
paths?: MemoryQmdIndexPath[]; paths?: MemoryQmdIndexPath[];

View File

@@ -220,6 +220,7 @@ const MemoryQmdSchema = z
command: z.string().optional(), command: z.string().optional(),
mcporter: MemoryQmdMcporterSchema.optional(), mcporter: MemoryQmdMcporterSchema.optional(),
searchMode: z.union([z.literal("query"), z.literal("search"), z.literal("vsearch")]).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(), searchTool: z.string().trim().min(1).optional(),
includeDefaultMemory: z.boolean().optional(), includeDefaultMemory: z.boolean().optional(),
paths: z.array(MemoryQmdPathSchema).optional(), paths: z.array(MemoryQmdPathSchema).optional(),