Skip to content

Commit c461225

Browse files
committed
fix(memory-core): preserve hybrid fallback ordering
1 parent fa4cf8a commit c461225

2 files changed

Lines changed: 113 additions & 11 deletions

File tree

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

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1819,7 +1819,6 @@ describe("memory index", () => {
18191819

18201820
it("preserves keyword-only hybrid hits alongside strict vector results", async () => {
18211821
const cfg = createCfg({
1822-
storePath: path.join(workspaceDir, "index-hybrid-keyword-with-strict-vector.sqlite"),
18231822
minScore: 0.35,
18241823
hybrid: { enabled: true, vectorWeight: 0.7, textWeight: 0.3 },
18251824
});
@@ -1871,6 +1870,97 @@ describe("memory index", () => {
18711870
}
18721871
});
18731872

1873+
it("keeps overlapping keyword-backed hybrid hits when all scores are below minScore", async () => {
1874+
const cfg = createCfg({
1875+
minScore: 0.35,
1876+
hybrid: { enabled: true, vectorWeight: 0.7, textWeight: 0.3 },
1877+
});
1878+
const manager = await getFreshManager(cfg);
1879+
try {
1880+
const overlappingResult = {
1881+
path: "memory/overlap.md",
1882+
startLine: 3,
1883+
endLine: 4,
1884+
source: "memory" as const,
1885+
snippet: "overlap keyword vector",
1886+
score: 0.2,
1887+
vectorScore: 0.1,
1888+
textScore: 0.5,
1889+
};
1890+
const selectHybridSearchResults = Reflect.get(manager, "selectHybridSearchResults") as (
1891+
merged: Array<typeof overlappingResult>,
1892+
keywordResults: Array<typeof overlappingResult & { id: string }>,
1893+
maxResults: number,
1894+
minScore: number,
1895+
) => Array<typeof overlappingResult>;
1896+
1897+
const results = selectHybridSearchResults.call(
1898+
manager,
1899+
[overlappingResult],
1900+
[{ ...overlappingResult, id: "overlap" }],
1901+
10,
1902+
0.35,
1903+
);
1904+
1905+
expect(results.map((result) => result.path)).toStrictEqual(["memory/overlap.md"]);
1906+
expect(results[0]?.vectorScore).toBeGreaterThan(0);
1907+
expect(results[0]?.textScore).toBeGreaterThan(0);
1908+
} finally {
1909+
await manager.close?.();
1910+
}
1911+
});
1912+
1913+
it("preserves hybrid merger order when retaining keyword-only hits", async () => {
1914+
const cfg = createCfg({
1915+
minScore: 0.35,
1916+
hybrid: { enabled: true, vectorWeight: 0.7, textWeight: 0.3 },
1917+
});
1918+
const manager = await getFreshManager(cfg);
1919+
try {
1920+
const keywordOnlyResult = {
1921+
path: "memory/keyword-first.md",
1922+
startLine: 2,
1923+
endLine: 2,
1924+
source: "memory" as const,
1925+
snippet: "keyword first",
1926+
score: 0.3,
1927+
vectorScore: 0,
1928+
textScore: 1,
1929+
};
1930+
const strictVectorResult = {
1931+
path: "memory/vector-second.md",
1932+
startLine: 1,
1933+
endLine: 1,
1934+
source: "memory" as const,
1935+
snippet: "strict vector second",
1936+
score: 0.63,
1937+
vectorScore: 0.9,
1938+
textScore: 0,
1939+
};
1940+
const selectHybridSearchResults = Reflect.get(manager, "selectHybridSearchResults") as (
1941+
merged: Array<typeof keywordOnlyResult>,
1942+
keywordResults: Array<typeof keywordOnlyResult & { id: string }>,
1943+
maxResults: number,
1944+
minScore: number,
1945+
) => Array<typeof keywordOnlyResult>;
1946+
1947+
const results = selectHybridSearchResults.call(
1948+
manager,
1949+
[keywordOnlyResult, strictVectorResult],
1950+
[{ ...keywordOnlyResult, id: "keyword-only" }],
1951+
10,
1952+
0.35,
1953+
);
1954+
1955+
expect(results.map((result) => result.path)).toStrictEqual([
1956+
"memory/keyword-first.md",
1957+
"memory/vector-second.md",
1958+
]);
1959+
} finally {
1960+
await manager.close?.();
1961+
}
1962+
});
1963+
18741964
it("reports vector availability after probe", async () => {
18751965
const cfg = createCfg({ vectorEnabled: true });
18761966
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
@@ -917,23 +917,35 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
917917
return strict.slice(0, maxResults);
918918
}
919919

920-
// Hybrid weighting can put valid keyword-only FTS hits below minScore.
921-
// Preserve those lexical hits even when another vector result is strict.
922920
const keywordKeys = new Set(keywordResults.map((entry) => memoryResultRangeKey(entry)));
923-
const keywordOnly = merged.filter(
924-
(entry) => entry.vectorScore === 0 && keywordKeys.has(memoryResultRangeKey(entry)),
925-
);
921+
if (strict.length === 0) {
922+
// Keep the original relaxed keyword-backed fallback for overlapping
923+
// hybrid hits whose weighted score falls below minScore.
924+
const relaxedMinScore = 0;
925+
return this.selectScoredResults(
926+
merged.filter((entry) => keywordKeys.has(memoryResultRangeKey(entry))),
927+
maxResults,
928+
minScore,
929+
relaxedMinScore,
930+
);
931+
}
932+
933+
// Hybrid weighting can put valid keyword-only FTS hits below minScore.
934+
// Preserve those lexical hits without disturbing mergeHybridResults order.
926935
const seen = new Set<string>();
927-
const deduped: HybridMergedSearchResult[] = [];
928-
for (const entry of [...strict, ...keywordOnly]) {
936+
const selected: HybridMergedSearchResult[] = [];
937+
for (const entry of merged) {
929938
const key = memoryResultRangeKey(entry);
930939
if (seen.has(key)) {
931940
continue;
932941
}
933-
seen.add(key);
934-
deduped.push(entry);
942+
const keywordOnly = entry.vectorScore === 0 && keywordKeys.has(key);
943+
if (entry.score >= minScore || keywordOnly) {
944+
seen.add(key);
945+
selected.push(entry);
946+
}
935947
}
936-
return deduped.toSorted((a, b) => b.score - a.score).slice(0, maxResults);
948+
return selected.slice(0, maxResults);
937949
}
938950

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

0 commit comments

Comments
 (0)