Skip to content

fix(mem_reader): stop duplicating parent sources on every split chunk (#2453) - #2488

Open
RerankerGuo wants to merge 2 commits into
MemTensor:dev-v2.0.36from
RerankerGuo:fix/issue-2453-sources-duplication
Open

RerankerGuo wants to merge 2 commits into
MemTensor:dev-v2.0.36from
RerankerGuo:fix/issue-2453-sources-duplication

Conversation

@RerankerGuo

Copy link
Copy Markdown
Contributor

Summary

MultiModalStructMemReader._split_large_memory_item passed sources=item.metadata.sources or [] to _make_memory_item for every chunk. With N chunks and a parent carrying N original SourceMessages, every chunk ended up with N byte-identical full-text SourceMessage objects — a multiplicative duplication of memory and graph size that scaled with the square of how chunked a message was.

Root cause

src/memos/mem_reader/multi_modal_struct.py line 169 (pre-fix):

split_item = self._make_memory_item(
    value=chunk_text,
    ...
    sources=item.metadata.sources or [],   # ← BUG: parents full sources list onto every chunk
    ...
)

Empirical reproduction from the issue: one 1,456-character message split into 4 chunks produced 15 memory items, all with sources length 4 and all 4 elements byte-identical (length [1456, 1456, 1456, 1456], identical MD5). In a sampled 320-memory production instance, 27.8% of memories showed the same bloat.

Why chunks must not carry the parents sources

Chunks are derived fragments, not original messages. The parent already carries the full source list, so propagating it onto every chunk multiplies the list without adding provenance. Chunk-level provenance is already preserved separately on metadata.internal_info via chunk_index / chunk_total / ingest_batch_id (set immediately after the _make_memory_item call), which gives downstream consumers the same reconstruction ability without the duplication.

Fix

Drop the sources= argument from the _make_memory_item call in _split_large_memory_item. _make_memory_items default is sources=None, which TextualMemoryMetadata normalises to [], so chunks now carry no sources — the same as if they had been created fresh from the raw chunk text.

The parents sources remain unchanged, so downstream aggregation paths that read the parents sources (e.g. _build_aggregated_node which iterates item.metadata.sources to extract roles) continue to work.

Tests

New tests/mem_reader/test_split_sources_no_duplication.py, three red/green cases:

  1. test_split_chunks_do_not_carry_parent_sources — red against unpatched main, green with the fix
  2. test_split_chunks_preserve_provenance_via_internal_info — pins the chunk provenance contract via internal_info.chunk_index / chunk_total / ingest_batch_id
  3. test_split_with_no_parent_sources_yields_no_sources — sanity check for the empty-sources path

Verification

PYTHONPATH=src pytest tests/mem_reader/test_split_sources_no_duplication.py -v
============================== 3 passed in 2.90s ==============================

Ruff:

  • ruff format --check: clean
  • ruff check: 0 new findings (original main had 15 BLE001 errors in this file; my change introduces none)

Out of scope

  • No new dependencies
  • No public API / OpenAPI contract changes
  • No DB schema, migration, or graph model changes
  • No Makefile / pyproject.toml / pre-commit / GitHub workflow changes

Closes #2453

@Memtensor-AI Memtensor-AI added area:memory 记忆存储、检索、更新、召回逻辑 status:in-progress Someone or AI is working on it | 人工或 AI 正在处理 labels Oct 13, 2026
@Memtensor-AI

Memtensor-AI commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

🤖 Open Code Review

Target: PR #2488
Task: da96722dc975485f
Base: dev-v2.0.36
Head: fix/issue-2453-sources-duplication

🔍 OpenCodeReview found 2 issue(s) in this PR.

⚠️ 1 warning(s) occurred during review.


1. src/memos/mem_reader/multi_modal_struct.py (L178)

Dropping sources entirely breaks several downstream consumers that read metadata.sources on these split items, so the fix for #2453 also changes behavior beyond de-duplication:

  1. _build_window_from_items (line ~353) derives roles only from item.metadata.sources and computes memory_type = "UserMemory" if roles == {"user"} else "LongTermMemory". Split chunks now contribute no role, so a user-only large message is reclassified as LongTermMemory once it goes through the window aggregation path.
  2. _process_string_fine calls _determine_prompt_type(sources), which returns "chat" for an empty list (line ~534). A split chunk that came from a file/document source therefore gets the chat prompt instead of the "doc" prompt, and _get_llm_response also loses the source.lang hint and falls back to detect_lang(mem_str).
  3. The save_rawfile block in _process_string_fine (~line 782) locates file_source from sources; with sources=[] no RawFileMemory node is written for split file chunks, silently losing raw-file persistence that was previously retained.

Suggest preserving at least the minimal source information these paths need (e.g. keep the file source for raw-file saving, or propagate roles/lang/prompt_type into internal_info and update those consumers) rather than emptying sources unconditionally. Also note the comment above overstates the guarantee: internal_info.chunk_index/chunk_total/ingest_batch_id only identify the splitter's own chunks and cannot recover which original sources contributed.


2. src/memos/mem_reader/multi_modal_struct.py (L182)

The rationale above says chunk provenance is preserved via internal_info.chunk_index / chunk_total / ingest_batch_id, but those fields do not survive the very next step. _split_large_memory_item results flow straight into _concat_multi_modal_memories, where _build_window_from_items (lines ~325-437) rebuilds a fresh metadata from info (window-level) and only re-attaches ingest_batch_id — chunk_index / chunk_total are dropped. Consumers such as src/memos/dream/enrichment.py read chunk_index / chunk_total from internal_info or sources; with sources=[] on these items both paths are now empty, so is_chunk degrades to False and the chunk signals are never emitted. If the provenance argument is load-bearing for this change, propagate chunk_index / chunk_total through _build_window_from_items (e.g. merge them into the aggregated item's internal_info); otherwise the comment overstates what the code guarantees.

Generated by cloud-assistant via Open Code Review.

@Memtensor-AI

Copy link
Copy Markdown
Collaborator

✅ Automated Test Results: PASSED

All tests passed (3/3 executed). memos_python_core/changed-repo-python: 3/3. Duration: 8s

Branch: fix/issue-2453-sources-duplication

@RerankerGuo

Copy link
Copy Markdown
Contributor Author

Two changes pushed in 7bfc73b.

Build failure. tests/mem_reader/test_project_id_propagation.py::TestMultiModalProjectIdPropagation::test_split_large_memory_item_assigns_shared_ingest_batch_id failed with AssertionError: 1 != 2. Dropping the sources keyword entirely broke the _make_memory_item call-site contract; the broad except in _split_large_memory_item swallowed the resulting TypeError and fell back to the unsplit item, so large items were not chunked at all. Passing an explicit sources=[] keeps the no-duplication behaviour identical while restoring the keyword contract.

OpenCodeReview finding 2. test_split_with_no_parent_sources_yields_no_sources asserted sources == [] for a parent that already had sources == [], so it passed for the old and the new implementation alike. Removed it; test_split_chunks_do_not_carry_parent_sources already pins the invariant with a parent that does carry sources.

OpenCodeReview finding 1 — keeping role/type provenance on the chunks — is not addressed by this push: the chunks still carry no sources, so that part is still open for your call.

RerankerGuo and others added 2 commits October 28, 2026 02:52
…MemTensor#2453)

MultiModalStructMemReader._split_large_memory_item passed
`sources=item.metadata.sources or []` to _make_memory_item for every chunk,
so each chunk item carried the full list of source messages from the parent.
With N chunks and a parent carrying N original SourceMessages, every chunk
ended up with N byte-identical full-text SourceMessage objects — a
multiplicative duplication of memory and graph size that scaled with the
square of how chunked a message was.

Empirically from the issue: one 1,456-character message split into 4 chunks
produced 15 memory items, all with sources length 4 and all 4 elements
byte-identical (length [1456, 1456, 1456, 1456], identical MD5). 27.8%
of memories in a sampled 320-memory production instance showed the same
bloat.

Chunks are derived fragments, not original messages, so they should not
inherit the parent's sources list. Provenance is already preserved through
`internal_info.chunk_index / chunk_total / ingest_batch_id` (set
immediately after _make_memory_item), which gives downstream consumers the
same reconstruction ability without the duplication.

The fix drops the sources= argument from the _make_memory_item call in
_split_large_memory_item. _make_memory_item's default is sources=None,
which is normalised to [] inside TextualMemoryMetadata, so chunks now carry
no sources (the same as if they had been created fresh from the raw chunk
text).

- New tests/mem_reader/test_split_sources_no_duplication.py with three
  red/green cases: chunks must not carry parent sources (red against the
  unpatched code); chunks must still carry internal_info provenance;
  empty parent sources still produce empty chunk sources.
- The parent's sources are preserved on the parent (no change there), so
  downstream aggregation paths that read the parent's sources continue to
  work.

Fixes MemTensor#2453.
… items

Dropping the sources kwarg entirely broke the _make_memory_item call-site
contract: callers intercepting the keyword with a strict signature (e.g.
the existing project-id propagation test whose fake requires sources=)
fail, and the broad except in _split_large_memory_item turns the TypeError
into a silent fall-back to the unsplit item, so large items stopped being
chunked at all.

Passing an explicit empty list keeps the MemTensor#2453 no-duplication behaviour
identical while restoring the stable kwarg contract.

Also drop the vacuous no-parent-sources test case: with an empty parent
sources list the old and new code both produced sources == [], so the
assertion could never distinguish the bug from the fix. The non-vacuous
test_split_chunks_do_not_carry_parent_sources already pins the invariant
with a parent that does carry sources.
@RerankerGuo
RerankerGuo force-pushed the fix/issue-2453-sources-duplication branch from 7bfc73b to b4087c7 Compare October 27, 2026 18:52
@Memtensor-AI

Copy link
Copy Markdown
Collaborator

✅ Automated Test Results: PASSED

All tests passed (2/2 executed). memos_python_core/changed-repo-python: 2/2. Duration: 23s

Branch: fix/issue-2453-sources-duplication

@Memtensor-AI Memtensor-AI added status:ready Ready for implementation; waiting for assignee or AI dispatch | 可进入实现,等待认领或派发 and removed status:in-progress Someone or AI is working on it | 人工或 AI 正在处理 labels Oct 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:memory 记忆存储、检索、更新、召回逻辑 status:ready Ready for implementation; waiting for assignee or AI dispatch | 可进入实现,等待认领或派发

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants