Skip to content

Commit 0e64b72

Browse files
committed
fix(memory-search): limit hybrid renormalization to vector-only classified media
1 parent b0e5ae7 commit 0e64b72

2 files changed

Lines changed: 71 additions & 12 deletions

File tree

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

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -739,4 +739,62 @@ describe("memory hybrid helpers", () => {
739739
expect(image?.textScore).toBeCloseTo(0.8);
740740
expect(image?.score ?? 0).toBeGreaterThan(0.7 * 0.95);
741741
});
742+
743+
it("keeps the established weighted score for vector-only non-media candidates when weights do not sum to one", async () => {
744+
const notePath = "memory/notes.md";
745+
const merged = await mergeHybridResults({
746+
vectorWeight: 1,
747+
textWeight: 1,
748+
isNonTextMediaPath: (path) => path.endsWith(".png"),
749+
vector: [
750+
{
751+
id: "note",
752+
path: notePath,
753+
startLine: 1,
754+
endLine: 2,
755+
source: "memory",
756+
snippet: "vec-note",
757+
vectorScore: 0.6,
758+
},
759+
],
760+
keyword: [],
761+
});
762+
763+
const note = merged.find((r) => r.path === notePath);
764+
expect(note?.score).toBeCloseTo(1 * 0.6);
765+
});
766+
767+
it("keeps the established weighted score for both-signal non-media candidates when weights do not sum to one", async () => {
768+
const notePath = "memory/notes.md";
769+
const merged = await mergeHybridResults({
770+
vectorWeight: 1,
771+
textWeight: 1,
772+
isNonTextMediaPath: (path) => path.endsWith(".png"),
773+
vector: [
774+
{
775+
id: "note",
776+
path: notePath,
777+
startLine: 1,
778+
endLine: 2,
779+
source: "memory",
780+
snippet: "vec-note",
781+
vectorScore: 0.6,
782+
},
783+
],
784+
keyword: [
785+
{
786+
id: "note",
787+
path: notePath,
788+
startLine: 1,
789+
endLine: 2,
790+
source: "memory",
791+
snippet: "kw-note",
792+
textScore: 0.9,
793+
},
794+
],
795+
});
796+
797+
const note = merged.find((r) => r.path === notePath);
798+
expect(note?.score).toBeCloseTo(1 * 0.6 + 1 * 0.9);
799+
});
742800
});

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

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -166,23 +166,24 @@ export async function mergeHybridResults(params: {
166166
: entry.pathScore;
167167
// Make fusion modality-aware: non-text media (image/audio) only carries a
168168
// synthetic label as text, so its keyword signal is structurally near zero.
169-
// Drop the text weight for such candidates and renormalize the remaining
170-
// weights so the score collapses to the vector signal on the same [0,1]
171-
// scale as text candidates. Only drop the signal when the candidate also has
172-
// a vector signal, so a keyword-only media hit keeps its text-weighted score.
173-
// Text candidates are unchanged: their weights already sum to 1, so dividing
174-
// by weightSum is a no-op. Gate the drop on a positive configured vector
175-
// weight so a valid keyword match is never removed when vectorWeight is 0.
169+
// For such candidates drop the text weight and renormalize by the remaining
170+
// vector weight so the score collapses to the vector signal on the same
171+
// [0,1] scale as text candidates. Only drop the signal when the candidate
172+
// has a vector signal but no keyword signal, so a keyword-only or both-signal
173+
// media hit keeps its text-weighted score. Gate the drop on a positive
174+
// configured vector weight so a valid keyword match is never removed when
175+
// vectorWeight is 0.
176176
const dropMediaTextSignal =
177177
entry.hasVector &&
178178
!entry.hasKeyword &&
179179
params.vectorWeight > 0 &&
180180
params.isNonTextMediaPath?.(entry.path) === true;
181-
const effectiveTextWeight = dropMediaTextSignal ? 0 : params.textWeight;
182-
const weightSum = params.vectorWeight + effectiveTextWeight;
183-
const weightedContent =
184-
params.vectorWeight * entry.vectorScore + effectiveTextWeight * keywordScore;
185-
const contentScore = weightSum > 0 ? weightedContent / weightSum : 0;
181+
// Renormalize only for the vector-only media case. Every other candidate
182+
// keeps the established unnormalized weighted formula, so custom weights
183+
// that do not sum to one preserve their score scale and result ordering.
184+
const contentScore = dropMediaTextSignal
185+
? entry.vectorScore
186+
: params.vectorWeight * entry.vectorScore + params.textWeight * keywordScore;
186187
const hasWeightedContentRelevance = contentScore > 0;
187188
// With decay enabled, reserve the lower half of an exact tier for path
188189
// identity and the upper half for content relevance. This lets recency beat

0 commit comments

Comments
 (0)