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:
2026-05-17 17:04:00 -07:00
co-authored by Claude Opus 4.7
parent a44b834a78
commit 1b234069e3
3 changed files with 30 additions and 4 deletions
+12 -2
View File
@@ -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]) => ({