diff --git a/apps/web/lib/mcp/tools.ts b/apps/web/lib/mcp/tools.ts index 618b88c..120d790 100644 --- a/apps/web/lib/mcp/tools.ts +++ b/apps/web/lib/mcp/tools.ts @@ -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, ); diff --git a/apps/web/lib/memories.ts b/apps/web/lib/memories.ts index 9e17397..f4835f2 100644 --- a/apps/web/lib/memories.ts +++ b/apps/web/lib/memories.ts @@ -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 { - 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]) => ({ diff --git a/packages/schemas/src/index.ts b/packages/schemas/src/index.ts index f1d4392..7984e2d 100644 --- a/packages/schemas/src/index.ts +++ b/packages/schemas/src/index.ts @@ -90,6 +90,15 @@ export const MemorySearchInput = z.object({ scope: MemoryScope.optional(), tags: z.array(z.string()).optional(), 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;