Skip to content

Commit 0f5dd47

Browse files
committed
fix(memory-core): preserve hybrid fallback ordering
1 parent ece616c commit 0f5dd47

2 files changed

Lines changed: 115 additions & 10 deletions

File tree

extensions/memory-core/src/memory/index.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1519,6 +1519,99 @@ describe("memory index", () => {
15191519
}
15201520
});
15211521

1522+
it("keeps overlapping keyword-backed hybrid hits when all scores are below minScore", async () => {
1523+
const cfg = createCfg({
1524+
storePath: path.join(workspaceDir, "index-hybrid-overlap-relaxed-fallback.sqlite"),
1525+
minScore: 0.35,
1526+
hybrid: { enabled: true, vectorWeight: 0.7, textWeight: 0.3 },
1527+
});
1528+
const manager = await getFreshManager(cfg);
1529+
try {
1530+
const overlappingResult = {
1531+
path: "memory/overlap.md",
1532+
startLine: 3,
1533+
endLine: 4,
1534+
source: "memory" as const,
1535+
snippet: "overlap keyword vector",
1536+
score: 0.2,
1537+
vectorScore: 0.1,
1538+
textScore: 0.5,
1539+
};
1540+
const selectHybridSearchResults = Reflect.get(manager, "selectHybridSearchResults") as (
1541+
merged: Array<typeof overlappingResult>,
1542+
keywordResults: Array<typeof overlappingResult & { id: string }>,
1543+
maxResults: number,
1544+
minScore: number,
1545+
) => Array<typeof overlappingResult>;
1546+
1547+
const results = selectHybridSearchResults.call(
1548+
manager,
1549+
[overlappingResult],
1550+
[{ ...overlappingResult, id: "overlap" }],
1551+
10,
1552+
0.35,
1553+
);
1554+
1555+
expect(results.map((result) => result.path)).toStrictEqual(["memory/overlap.md"]);
1556+
expect(results[0]?.vectorScore).toBeGreaterThan(0);
1557+
expect(results[0]?.textScore).toBeGreaterThan(0);
1558+
} finally {
1559+
await manager.close?.();
1560+
}
1561+
});
1562+
1563+
it("preserves hybrid merger order when retaining keyword-only hits", async () => {
1564+
const cfg = createCfg({
1565+
storePath: path.join(workspaceDir, "index-hybrid-keyword-preserves-merge-order.sqlite"),
1566+
minScore: 0.35,
1567+
hybrid: { enabled: true, vectorWeight: 0.7, textWeight: 0.3 },
1568+
});
1569+
const manager = await getFreshManager(cfg);
1570+
try {
1571+
const keywordOnlyResult = {
1572+
path: "memory/keyword-first.md",
1573+
startLine: 2,
1574+
endLine: 2,
1575+
source: "memory" as const,
1576+
snippet: "keyword first",
1577+
score: 0.3,
1578+
vectorScore: 0,
1579+
textScore: 1,
1580+
};
1581+
const strictVectorResult = {
1582+
path: "memory/vector-second.md",
1583+
startLine: 1,
1584+
endLine: 1,
1585+
source: "memory" as const,
1586+
snippet: "strict vector second",
1587+
score: 0.63,
1588+
vectorScore: 0.9,
1589+
textScore: 0,
1590+
};
1591+
const selectHybridSearchResults = Reflect.get(manager, "selectHybridSearchResults") as (
1592+
merged: Array<typeof keywordOnlyResult>,
1593+
keywordResults: Array<typeof keywordOnlyResult & { id: string }>,
1594+
maxResults: number,
1595+
minScore: number,
1596+
) => Array<typeof keywordOnlyResult>;
1597+
1598+
const results = selectHybridSearchResults.call(
1599+
manager,
1600+
[keywordOnlyResult, strictVectorResult],
1601+
[{ ...keywordOnlyResult, id: "keyword-only" }],
1602+
10,
1603+
0.35,
1604+
);
1605+
1606+
expect(results.map((result) => result.path)).toStrictEqual([
1607+
"memory/keyword-first.md",
1608+
"memory/vector-second.md",
1609+
]);
1610+
} finally {
1611+
await manager.close?.();
1612+
}
1613+
});
1614+
15221615
it("reports vector availability after probe", async () => {
15231616
const cfg = createCfg({ storePath: indexVectorPath, vectorEnabled: true });
15241617
const manager = await getPersistentManager(cfg);

extensions/memory-core/src/memory/manager.ts

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -841,23 +841,35 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
841841
return strict.slice(0, maxResults);
842842
}
843843

844-
// Hybrid weighting can put valid keyword-only FTS hits below minScore.
845-
// Preserve those lexical hits even when another vector result is strict.
846844
const keywordKeys = new Set(keywordResults.map((entry) => memoryResultRangeKey(entry)));
847-
const keywordOnly = merged.filter(
848-
(entry) => entry.vectorScore === 0 && keywordKeys.has(memoryResultRangeKey(entry)),
849-
);
845+
if (strict.length === 0) {
846+
// Keep the original relaxed keyword-backed fallback for overlapping
847+
// hybrid hits whose weighted score falls below minScore.
848+
const relaxedMinScore = 0;
849+
return this.selectScoredResults(
850+
merged.filter((entry) => keywordKeys.has(memoryResultRangeKey(entry))),
851+
maxResults,
852+
minScore,
853+
relaxedMinScore,
854+
);
855+
}
856+
857+
// Hybrid weighting can put valid keyword-only FTS hits below minScore.
858+
// Preserve those lexical hits without disturbing mergeHybridResults order.
850859
const seen = new Set<string>();
851-
const deduped: HybridMergedSearchResult[] = [];
852-
for (const entry of [...strict, ...keywordOnly]) {
860+
const selected: HybridMergedSearchResult[] = [];
861+
for (const entry of merged) {
853862
const key = memoryResultRangeKey(entry);
854863
if (seen.has(key)) {
855864
continue;
856865
}
857-
seen.add(key);
858-
deduped.push(entry);
866+
const keywordOnly = entry.vectorScore === 0 && keywordKeys.has(key);
867+
if (entry.score >= minScore || keywordOnly) {
868+
seen.add(key);
869+
selected.push(entry);
870+
}
859871
}
860-
return deduped.toSorted((a, b) => b.score - a.score).slice(0, maxResults);
872+
return selected.slice(0, maxResults);
861873
}
862874

863875
private selectScoredResults<T extends MemorySearchResult & { score: number }>(

0 commit comments

Comments
 (0)