fix(memory-search): stop hybrid fusion from discounting vector-only multimodal results#92196
Conversation
|
Codex review: needs maintainer review before merge. Reviewed July 13, 2026, 1:03 PM ET / 17:03 UTC. Summary PR surface: Source +22, Tests +235. Total +257 across 3 files. Reproducibility: yes. at high confidence from source and the supplied production-path before/after: the unchanged weighted formula discounts a vector-only media score by vectorWeight. The review did not independently run the latest main SHA with a live embedding provider, so the structured status remains source-reproducible rather than independently reproduced. Review metrics: 3 noteworthy metrics.
Stored data model Root-cause cluster Members:
Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything. Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Risk before merge
Maintainer options:
Next step before merge
Security Review detailsBest possible solution: Land the narrow classifier-backed fusion exception after memory-core owner review, retain the regression matrix for custom weights and mixed signals, and close #44540 once merged. Do we have a high-confidence way to reproduce the issue? Yes at high confidence from source and the supplied production-path before/after: the unchanged weighted formula discounts a vector-only media score by vectorWeight. The review did not independently run the latest main SHA with a live embedding provider, so the structured status remains source-reproducible rather than independently reproduced. Is this the best way to solve the issue? Yes. Applying a narrowly gated exception at the shared fusion boundary, using the existing multimodal path classifier and preserving every sibling scoring case, is more maintainable than persisting modality or changing global weight semantics. AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against 70feb13ce7f5. Label changesLabel justifications:
Evidence reviewedPR surface: Source +22, Tests +235. Total +257 across 3 files. View PR surface stats
What I checked:
Likely related people:
What the crustacean ranks mean
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics. How this review workflow works
Review history (4 earlier review cycles)
|
|
@clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
Addressed both review follow-ups in Preserve keyword-only classified media candidates — the first revision dropped the text weight for every media path, which zeroed a media chunk matched only by keyword (its label tokens) with no vector signal: Real indexed multimodal search proof — added a before/after through a real Run via vitest with isolated module caches per side. Embeddings are a deterministic local stub (no Gemini key in this environment); every indexing/FTS/vector/fusion path is the real code. Full output and limitations are in the PR body. @clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
@codex review |
|
Codex Review: Something went wrong. Try again later by commenting “@codex review”. ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
|
@clawsweeper re-run |
ba87eaf to
3220af9
Compare
|
Addressed the [P1] zero-vector-weight finding in
Real before/after through the production New regression test covers a media candidate present in both result sets under @clawsweeper re-review |
a9d17f0 to
fa443f4
Compare
|
@clawsweeper re-review Addressed the [P2] rank-up move (and the paired [P1] finding at Added the requested coverage under |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
20fed0e to
5cce9c6
Compare
Co-authored-by: yetval <[email protected]>
c02d11d to
156d46a
Compare
|
ClawSweeper status: review started. I am starting a fresh review of this pull request: fix(memory-search): stop hybrid fusion from discounting vector-only multimodal results This is item 1/1 in the current shard. Shard 0/1. This placeholder means the worker is alive and reading the current context. I will edit this same comment with the actual review when the claws are done clicking. Crustacean status: shell secured, claws on keyboard, evidence pebbles being sorted. |
|
Merged via squash.
|
Summary
Hybrid memory search ranks results with
finalScore = vectorWeight * vectorScore + textWeight * textScore.mergeHybridResults(extensions/memory-core/src/memory/hybrid.ts) applied that formula uniformly to every candidate. Image and audio chunks only carry a synthetic label as text, e.g.Image file: media/IMG_0007.png(buildMemoryMultimodalLabel,packages/memory-host-sdk/src/host/multimodal.ts). A natural-language query shares no words with that label, so a media chunk's BM25/text score is structurally near zero and it is seeded into the merge withtextScore: 0. Under the default0.7vector /0.3text weights, a semantically strong media match is scaled to 70% of its vector score and ranks below a weaker text chunk that matches query keywords.The fix makes fusion modality-aware.
mergeHybridResultskeeps the established weighted formula for every candidate, and only for a vector-only classified media chunk does it collapse the score to the vector signal:For a non-text media chunk that has only a vector signal, dropping the near-zero text weight and renormalizing by the remaining vector weight collapses the score to its vector score, on the same
[0,1]scale as text candidates. Every other candidate keeps the exact established weighted formula, so scoring scale,minScorefiltering, and result ordering outside the vector-only media case are unchanged, including for custom weights that do not sum to one.The manager supplies the predicate from the already-known file path, reusing the classifier the sync path uses (
extensions/memory-core/src/memory/manager.ts):classifyMemoryMultimodalPathreturnsnullwhen multimodal search is disabled, so the predicate isfalsefor every path and behavior is identical to today on text-only setups. Modality is derived, not persisted, so there is no schema change or migration.Keyword-only media is preserved (review follow-up)
The first revision dropped the text weight for any media path. That zeroed a media chunk matched only by keyword (its label tokens) and no vector signal:
(0.7*0 + 0*text)/0.7 = 0, dropping it from results. The merge now tracks an explicithasVectorflag per candidate and only drops the synthetic-label text signal when the candidate also has a vector signal. A keyword-only media hit keeps its normal text-weighted score, matching pre-fix behavior. Covered by a new regression test.Zero vector-weight configuration preserved (re-review follow-up)
vectorWeightmay legitimately be0(existing memory tests run{ vectorWeight: 0, textWeight: 1 }). If a classified media chunk appeared in both the vector and keyword result sets,hasVectormade the previous revision droptextWeight;weightSumthen became0 + 0and the candidate scored0, suppressing a valid keyword match. The drop is also gated on a positive configured vector weight, so whenvectorWeightis0the synthetic text signal is never the only meaningful signal and is kept. Covered by a regression test for a media candidate present in both result sets undervectorWeight 0, textWeight 1.Both-signal media keeps its real keyword score (re-review follow-up)
Media labels are indexed and searchable (a manager test expects
search("image")/search("audio")to find media). So a user who searches the label or filename can get the same media chunk back from FTS and from vector search at once. Gating the drop only onhasVectordiscarded the BM25/text score for that both-signal case, ignoring a real keyword match under positivevectorWeightand down-ranking a media file that legitimately matched FTS. The drop is now also gated on the candidate having no keyword signal, so the synthetic-label text weight is dropped only for genuinely vector-only media:const dropMediaTextSignal = entry.hasVector && + !entry.hasKeyword && params.vectorWeight > 0 && params.isNonTextMediaPath?.(entry.path) === true;hasKeywordis tracked the same way ashasVector: it is set when a candidate is present in the keyword (FTS) result set. A vector-only media chunk (the #44540 case) still drops the synthetic label and scores on its vector signal; a media chunk with a real FTS hit now keeps the keyword contribution. Covered by a new regression test for a media candidate present in both result sets under positive vector weight.Normalization limited to vector-only classified media (re-review follow-up)
An earlier revision computed
contentScore = weighted / weightSumfor every candidate.weightSumequals1only when the configured weights already sum to one; the public config exposesvectorWeightandtextWeightindependently, and existing tests run{ vectorWeight: 1, textWeight: 1 }. Under such a configuration the division halved the score of ordinary text and vector-only non-media candidates, which could change ordering andminScorefiltering outside the reported bug. The renormalization is now applied only whendropMediaTextSignalis true; every other candidate retains the established unnormalized weighted formula exactly:Because
dropMediaTextSignalis gated onparams.vectorWeight > 0, collapsing the media score toentry.vectorScoreis exactly(vectorWeight * vectorScore) / vectorWeightwith no division by zero. Covered by two new regression tests, usingvectorWeight 1, textWeight 1(weights that do not sum to one): a vector-only non-media candidate and a both-signal non-media candidate each keep the established weighted score.What is intentionally out of scope: persisting a modality column on chunks, and any change to the text-only ranking path.
Rebase onto current main and the TypeScript LOC ratchet
This branch was rebased onto current
main. The changed-file TypeScript LOC ratchet onmainforbids an oversized legacy file from growing, andextensions/memory-core/src/memory/manager.tsis already 1671 lines, so plumbing the media predicate into the merge call tripped the gate. The merge wrapper's inline parameter type moved tohybrid.ts, which owns the merge contract, and is referenced asManagerHybridMergeParams.manager.tsnow shrinks to 1660 lines. The modality-aware fusion fix is unchanged.Real behavior proof
Behavior addressed: hybrid fusion discounted a vector-only image match by the configured text weight, so a semantically strong media result ranked below a weaker keyword-matching text note.
Real environment tested: the real production memory retrieval path driven on pristine
main(1708faf85b) and on PR headc02d11dc7fwith identical inputs: a realnode:sqlitememory index built with the productionensureMemoryIndexSchema, a real FTS5 index scored by the productionsearchKeywordwith real BM25 ranking, the realclassifyMemoryMultimodalPathmodality classifier wired exactly asMemoryIndexManagerwires it, and the realmergeHybridResultsfusion under the defaultvectorWeight 0.7, textWeight 0.3. The corpus is one image file, one keyword-matching note, and 18 filler documents so BM25 has real IDF.Exact steps or command run after this patch: indexed the corpus, ran the query
underwater coral reef surveythrough the production keyword search and hybrid fusion, then reverted onlyhybrid.tsto its pristinemaincontents and re-ran the identical scenario.Evidence after fix:
Observed result after fix: the image chunk carries a strong vector signal (1.0000) but a structurally zero BM25 signal, because its only indexed text is the synthetic
Image file:label. Before the patch the 0.3 text weight scaled it down to 0.7000 and it lost to the weaker note at 0.9376. After the patch it scores on its vector signal (1.0000) and surfaces first, while the note keeps its identical 0.9376 score, so the ranking flips to put the strongest semantic match on top.What was not tested: no live embedding provider request was made, so the query and chunk vectors are deterministic local values rather than Gemini-served embeddings; the approximate-nearest-neighbour vector index lookup was not exercised, and the vector scores were supplied directly to fusion. Everything else on the retrieval path (schema, FTS5, BM25 ranking, modality classification, fusion) is the real production code. A full production build was not run.
Verification
node:sqliteschema, FTS5/BM25 keyword search, real modality classifier, real fusion), pristinemainvs this patch on identical inputs.extensions/memory-core/src/memory/hybrid.test.ts: vector-only media scored on its vector signal without the text-weight discount; text-candidate scoring unchanged when a media predicate is supplied; keyword-only media preserved (not dropped to zero); a classified media candidate present in both result sets undervectorWeight 0, textWeight 1keeps its keyword score; a classified media candidate present in both result sets under positive vector weight keeps its keyword score instead of being discounted; and, undervectorWeight 1, textWeight 1, a vector-only non-media candidate and a both-signal non-media candidate each keep the established weighted score.node scripts/test-projects.mjs extensions/memory-core/src/memory/hybrid.test.ts(22 passed).node scripts/test-projects.mjs extensions/memory-core/src/memory/manager-search.test.ts(34 passed).Closes #44540