Skip to content

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

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

RerankerGuo wants to merge 1 commit 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#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.
@Memtensor-AI Memtensor-AI added area:memory 记忆存储、检索、更新、召回逻辑 status:in-progress Someone or AI is working on it | 人工或 AI 正在处理 labels Oct 13, 2026
@Memtensor-AI

Copy link
Copy Markdown
Collaborator

🤖 Open Code Review

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

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


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

Dropping sources entirely is broader than the duplication fix requires, and the justification in this comment doesn't hold: the parent item is not kept — _split_large_memory_item returns only the chunks (line 195, return split_items if split_items else [item]), so the parent's sources are discarded rather than merely deduplicated. Provenance via internal_info.chunk_index/chunk_total/ingest_batch_id cannot substitute for sources, because downstream code reads role/type from sources:

  • _build_window_from_items (line 352-382) derives roles from item.metadata.sources and then
    memory_type = "UserMemory" if roles == {"user"} else "LongTermMemory". A long user message split into ≥2 chunks now yields roles == set() → the window is misclassified as LongTermMemory instead of UserMemory (regression, and UserMemory is handled specially downstream).
  • _process_string_fine calls _determine_prompt_type(sources); with sources empty it returns "chat", so chunked file/document sources lose the "doc" prompt (line 535-548). The file_source lookup (line 781-793) also returns None, so save_rawfile stops producing RawFileMemory for split chunks and _relate_preceding_following_rawfile_memories has nothing to link.
  • _get_llm_response no longer finds lang on sources and falls back to detect_lang(mem_str) (line 465-476).

Suggest keeping lightweight provenance instead of an empty list — e.g. attach a single (deduplicated) source per chunk, or preserve at least role/type (and file_info for file sources) so the role-based typing and prompt/file-chunk logic above keep working while still avoiding the N×N copy.


2. tests/mem_reader/test_split_sources_no_duplication.py (L112-L121)

This test cannot distinguish the old buggy behaviour from the fixed one: with an empty parent sources=[], the old code's sources=item.metadata.sources or [] evaluates to [] or [] = [], which is exactly what the fix produces too (sources kwarg omitted → default None or [] = []). Both paths produce metadata.sources == [], so the assertion passes for the wrong implementation and gives false confidence that the empty-parent edge case is covered.

To make the test meaningful, consider also verifying that none of the original parent's SourceMessage identity is present (even for the empty-parent case there is nothing to check, confirming this variant is vacuous), or simply remove this test case since test_split_chunks_do_not_carry_parent_sources already validates the no-sources invariant via a parent that does have sources.

Alternatively, build the parent with at least one source and assert chunk.metadata.sources == [] (or is None) to confirm sources are dropped — that is the actual contract being guarded.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants