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"] },
|
||||
tags: { type: "array", items: { type: "string" }, description: "Boost results with these tags." },
|
||||
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"],
|
||||
},
|
||||
@@ -743,7 +750,7 @@ const memorySearch: ToolDef = {
|
||||
const parsed = MemorySearchInput.safeParse(withDefaultProject(args, ctx));
|
||||
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 projectId = requestedKey ? await resolveProjectId(ctx, requestedKey) : null;
|
||||
if (requestedKey && !projectId) {
|
||||
@@ -753,7 +760,7 @@ const memorySearch: ToolDef = {
|
||||
const result = await searchMemories(
|
||||
ctx.userId,
|
||||
query,
|
||||
{ scope, projectKey: requestedKey, tags, groupNames: ctx.groups },
|
||||
{ scope, projectKey: requestedKey, tags, groupNames: ctx.groups, minScore },
|
||||
limit,
|
||||
);
|
||||
|
||||
|
||||
@@ -29,6 +29,12 @@ export interface SearchFilters {
|
||||
* shared visibility) — pass through `UserContext.groups`.
|
||||
*/
|
||||
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 {
|
||||
@@ -93,7 +99,7 @@ export async function searchMemories(
|
||||
filters: SearchFilters = {},
|
||||
limit = 20,
|
||||
): Promise<SearchResult> {
|
||||
const { scope, projectKey, tags, groupNames = [] } = filters;
|
||||
const { scope, projectKey, tags, groupNames = [], minScore } = filters;
|
||||
const projectId = projectKey
|
||||
? await resolveProjectIdForKey(userId, groupNames, projectKey)
|
||||
: null;
|
||||
@@ -178,7 +184,11 @@ export async function searchMemories(
|
||||
fts.forEach((h, i) => accum(h.id, i + 1, "ftsRank"));
|
||||
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)
|
||||
.slice(0, limit)
|
||||
.map(([id, rank]) => ({
|
||||
|
||||
Reference in New Issue
Block a user