feat(memory.search): optional minScore RRF threshold
Closes the long-standing backlog item from abfa5463. memory.search now accepts an optional `minScore` parameter (Zod range 0..1) that drops hits below the given Reciprocal Rank Fusion score. Default behavior is unchanged — when minScore is unset, every fused result is returned, same as today. The original design memo suggested defaulting to 0.020, but that would exclude valid pure-semantic matches (one ranker at rank 1 = 1/61 ≈ 0.0164). Real-world Phase 2 testing surfaced exactly that case (the "expose TLS" → HAProxy memory hit). Shipping unfiltered-by-default and exposing the knob lets specific callers opt into stricter filtering (e.g. ~0.025 to require two rankers to fire at rank 1) without penalising legitimate semantic-only hits for the rest. Tool description updated; per-source rank breakdown remains the primary confidence signal for the model. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -736,6 +736,13 @@ const memorySearch: ToolDef = {
|
|||||||
scope: { type: "string", enum: ["project", "user"] },
|
scope: { type: "string", enum: ["project", "user"] },
|
||||||
tags: { type: "array", items: { type: "string" }, description: "Boost results with these tags." },
|
tags: { type: "array", items: { type: "string" }, description: "Boost results with these tags." },
|
||||||
limit: { type: "integer", minimum: 1, maximum: 50, default: 10 },
|
limit: { type: "integer", minimum: 1, maximum: 50, default: 10 },
|
||||||
|
minScore: {
|
||||||
|
type: "number",
|
||||||
|
minimum: 0,
|
||||||
|
maximum: 1,
|
||||||
|
description:
|
||||||
|
"Optional minimum Reciprocal Rank Fusion score for a hit to be returned. Default unset = no extra filter (every fused result returned). Set ~0.025 to require at least two rankers (vector + FTS, or +tag) to fire at rank 1, filtering out weak vector-only matches. The per-source ranks in each result are still the primary way to judge confidence.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
required: ["query"],
|
required: ["query"],
|
||||||
},
|
},
|
||||||
@@ -743,7 +750,7 @@ const memorySearch: ToolDef = {
|
|||||||
const parsed = MemorySearchInput.safeParse(withDefaultProject(args, ctx));
|
const parsed = MemorySearchInput.safeParse(withDefaultProject(args, ctx));
|
||||||
if (!parsed.success) return err(parsed.error.message);
|
if (!parsed.success) return err(parsed.error.message);
|
||||||
|
|
||||||
const { query, scope, tags, limit } = parsed.data;
|
const { query, scope, tags, limit, minScore } = parsed.data;
|
||||||
const requestedKey = projectKeyOrDefault(ctx, parsed.data.project);
|
const requestedKey = projectKeyOrDefault(ctx, parsed.data.project);
|
||||||
const projectId = requestedKey ? await resolveProjectId(ctx, requestedKey) : null;
|
const projectId = requestedKey ? await resolveProjectId(ctx, requestedKey) : null;
|
||||||
if (requestedKey && !projectId) {
|
if (requestedKey && !projectId) {
|
||||||
@@ -753,7 +760,7 @@ const memorySearch: ToolDef = {
|
|||||||
const result = await searchMemories(
|
const result = await searchMemories(
|
||||||
ctx.userId,
|
ctx.userId,
|
||||||
query,
|
query,
|
||||||
{ scope, projectKey: requestedKey, tags, groupNames: ctx.groups },
|
{ scope, projectKey: requestedKey, tags, groupNames: ctx.groups, minScore },
|
||||||
limit,
|
limit,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ export interface SearchFilters {
|
|||||||
* shared visibility) — pass through `UserContext.groups`.
|
* shared visibility) — pass through `UserContext.groups`.
|
||||||
*/
|
*/
|
||||||
groupNames?: string[];
|
groupNames?: string[];
|
||||||
|
/**
|
||||||
|
* Minimum RRF score a hit must clear. Default `undefined` = no extra
|
||||||
|
* filter (current behavior — every fused result is returned). Set to
|
||||||
|
* e.g. 0.025 to require at least two rankers to fire at rank 1.
|
||||||
|
*/
|
||||||
|
minScore?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchHit {
|
export interface SearchHit {
|
||||||
@@ -93,7 +99,7 @@ export async function searchMemories(
|
|||||||
filters: SearchFilters = {},
|
filters: SearchFilters = {},
|
||||||
limit = 20,
|
limit = 20,
|
||||||
): Promise<SearchResult> {
|
): Promise<SearchResult> {
|
||||||
const { scope, projectKey, tags, groupNames = [] } = filters;
|
const { scope, projectKey, tags, groupNames = [], minScore } = filters;
|
||||||
const projectId = projectKey
|
const projectId = projectKey
|
||||||
? await resolveProjectIdForKey(userId, groupNames, projectKey)
|
? await resolveProjectIdForKey(userId, groupNames, projectKey)
|
||||||
: null;
|
: null;
|
||||||
@@ -178,7 +184,11 @@ export async function searchMemories(
|
|||||||
fts.forEach((h, i) => accum(h.id, i + 1, "ftsRank"));
|
fts.forEach((h, i) => accum(h.id, i + 1, "ftsRank"));
|
||||||
tag.forEach((h, i) => accum(h.id, i + 1, "tagRank"));
|
tag.forEach((h, i) => accum(h.id, i + 1, "tagRank"));
|
||||||
|
|
||||||
const hits = [...scores.entries()]
|
let entries = [...scores.entries()];
|
||||||
|
if (typeof minScore === "number" && minScore > 0) {
|
||||||
|
entries = entries.filter(([, r]) => r.rrfScore >= minScore);
|
||||||
|
}
|
||||||
|
const hits = entries
|
||||||
.sort(([, a], [, b]) => b.rrfScore - a.rrfScore)
|
.sort(([, a], [, b]) => b.rrfScore - a.rrfScore)
|
||||||
.slice(0, limit)
|
.slice(0, limit)
|
||||||
.map(([id, rank]) => ({
|
.map(([id, rank]) => ({
|
||||||
|
|||||||
@@ -90,6 +90,15 @@ export const MemorySearchInput = z.object({
|
|||||||
scope: MemoryScope.optional(),
|
scope: MemoryScope.optional(),
|
||||||
tags: z.array(z.string()).optional(),
|
tags: z.array(z.string()).optional(),
|
||||||
limit: z.number().int().min(1).max(50).default(10),
|
limit: z.number().int().min(1).max(50).default(10),
|
||||||
|
/**
|
||||||
|
* Minimum Reciprocal Rank Fusion score a result must clear to be
|
||||||
|
* returned. Useful for stricter "high-confidence only" filtering — set
|
||||||
|
* higher than 1/(60+1)≈0.0164 to exclude single-ranker-rank-1 matches
|
||||||
|
* (semantic-only hits with no FTS/tag corroboration), or to ~0.03 to
|
||||||
|
* require at least two rankers to fire at rank 1. Omit / set 0 for the
|
||||||
|
* unfiltered default.
|
||||||
|
*/
|
||||||
|
minScore: z.number().min(0).max(1).optional(),
|
||||||
});
|
});
|
||||||
export type MemorySearchInput = z.infer<typeof MemorySearchInput>;
|
export type MemorySearchInput = z.infer<typeof MemorySearchInput>;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user