Pre-submission checklist | 提交前检查
Bug Description | 问题描述
Neo4jCommunityGraphDB (and Neo4jGraphDB) serialize every dict-typed metadata value into a JSON string before writing, but the read path only deserializes sources. Fields such as internal_info and info therefore come back as str instead of dict, and TextualMemoryItem — which declares them as dict | None — raises ValidationError during recall.
This is the same class of bug already reported for the Postgres backend in #2229 and #2270 (pgvector embedding returned as string breaking TextualMemoryItem), but on the Neo4j write/read path and affecting a different field.
Root cause
Write path — src/memos/graph_dbs/neo4j_community.py:
metadata = _sanitize_neo4j_metadata(metadata) # stringifies every dict
if metadata.get("sources"):
for idx in range(len(metadata["sources"])):
metadata["sources"][idx] = json.dumps(metadata["sources"][idx])
_sanitize_neo4j_value() in src/memos/graph_dbs/neo4j.py:
if isinstance(value, dict):
return json.dumps(value, ensure_ascii=False, sort_keys=True)
So internal_info: dict becomes internal_info: str.
Read path — _parse_node() and _parse_nodes() in neo4j_community.py only reverse the sources transform. There is no equivalent for internal_info or info. The same asymmetry exists in neo4j.py.
Failure point — internal_info is listed among the returned fields in src/memos/memories/textual/tree_text_memory/retrieve/recall.py, and src/memos/memories/textual/item.py:157 declares internal_info: dict | None. A str value fails validation and recall raises.
Why this is not always visible
internal_info is None for simple conversational memories, and None skips the dict branch in the sanitizer. It is only populated on paths such as mem_reader/multi_modal_struct.py:173 and :426 (document chunking) and dream/enrichment.py:156. A plain POST /product/add with a short chat message therefore works fine; the failure surfaces once documents are ingested or Dream is enabled.
Note that api/handlers/search_handler.py:242, dream/search.py:130 and _coerce_json_dict() in dream/contextualization.py:214 already contain defensive handling for internal_info, suggesting the problem has been worked around downstream rather than fixed at the storage boundary.
Suggested fix
Make the read path symmetric with the write path, in both _parse_node() and _parse_nodes() in neo4j_community.py and the equivalent location in neo4j.py:
for _f in ("internal_info", "info"):
_v = node.get(_f)
if isinstance(_v, str) and _v.startswith("{") and _v.endswith("}"):
try:
node[_f] = json.loads(_v)
except (ValueError, TypeError):
node[_f] = None
A more durable fix would avoid the hardcoded field list — deriving the set of dict-typed fields from the metadata model, or recording which keys were serialized at write time — so future dict fields are handled automatically.
How to Reproduce | 如何重现
-
Deploy with NEO4J_BACKEND=neo4j-community, Neo4j 5.26.6 Community + Qdrant v1.15.3 as vec_db.
-
Ingest content that goes through the chunking path so internal_info is populated (document/file ingestion rather than a one-line chat message).
-
Inspect the stored payload — internal_info is a JSON string:
curl -s 'http://:6333/collections/neo4j_vec_db/points/scroll'
-H 'Content-Type: application/json'
-d '{"limit":2,"with_payload":true}'
-
Call POST /product/search — recall raises ValidationError on TextualMemoryItem.metadata.internal_info (expected dict, got str).
For comparison, sources in the same payload is also stored as a JSON string but is correctly restored on read, which makes the asymmetry visible:
"sources": ["{"type": "chat", "role": "user", ...}"]
Environment | 环境信息
- Python version: 3.11.16 (inside official docker/Dockerfile image)
- Operating System: Ubuntu 24.04 (Docker host), Debian trixie (container base)
- MemOS version: built from
main via docker/Dockerfile (not a pip release)
- Graph DB: Neo4j 5.26.6 Community, NEO4J_BACKEND=neo4j-community, MOS_NEO4J_SHARED_DB=true
- Vector DB: Qdrant v1.15.3 (external server, collection
neo4j_vec_db)
- Embedder: bge-m3 via Ollama OpenAI-compatible endpoint, EMBEDDING_DIMENSION=1024
Additional Context | 其他信息
Related issues sharing the same underlying pattern (value serialized on write, not deserialized on read, TextualMemoryItem validation fails) on a different backend and field:
Since the same failure mode has now appeared on both the Postgres and Neo4j paths, it may be worth addressing the serialize/deserialize contract at the storage boundary rather than patching each field individually.
Willingness to Implement | 实现意愿
Pre-submission checklist | 提交前检查
Bug Description | 问题描述
Neo4jCommunityGraphDB(andNeo4jGraphDB) serialize everydict-typed metadata value into a JSON string before writing, but the read path only deserializessources. Fields such asinternal_infoandinfotherefore come back asstrinstead ofdict, andTextualMemoryItem— which declares them asdict | None— raisesValidationErrorduring recall.This is the same class of bug already reported for the Postgres backend in #2229 and #2270 (pgvector embedding returned as string breaking
TextualMemoryItem), but on the Neo4j write/read path and affecting a different field.Root cause
Write path —
src/memos/graph_dbs/neo4j_community.py:_sanitize_neo4j_value()insrc/memos/graph_dbs/neo4j.py:So
internal_info: dictbecomesinternal_info: str.Read path —
_parse_node()and_parse_nodes()inneo4j_community.pyonly reverse thesourcestransform. There is no equivalent forinternal_infoorinfo. The same asymmetry exists inneo4j.py.Failure point —
internal_infois listed among the returned fields insrc/memos/memories/textual/tree_text_memory/retrieve/recall.py, andsrc/memos/memories/textual/item.py:157declaresinternal_info: dict | None. Astrvalue fails validation and recall raises.Why this is not always visible
internal_infoisNonefor simple conversational memories, andNoneskips thedictbranch in the sanitizer. It is only populated on paths such asmem_reader/multi_modal_struct.py:173and:426(document chunking) anddream/enrichment.py:156. A plainPOST /product/addwith a short chat message therefore works fine; the failure surfaces once documents are ingested or Dream is enabled.Note that
api/handlers/search_handler.py:242,dream/search.py:130and_coerce_json_dict()indream/contextualization.py:214already contain defensive handling forinternal_info, suggesting the problem has been worked around downstream rather than fixed at the storage boundary.Suggested fix
Make the read path symmetric with the write path, in both
_parse_node()and_parse_nodes()inneo4j_community.pyand the equivalent location inneo4j.py:A more durable fix would avoid the hardcoded field list — deriving the set of
dict-typed fields from the metadata model, or recording which keys were serialized at write time — so futuredictfields are handled automatically.How to Reproduce | 如何重现
Deploy with NEO4J_BACKEND=neo4j-community, Neo4j 5.26.6 Community + Qdrant v1.15.3 as vec_db.
Ingest content that goes through the chunking path so internal_info is populated (document/file ingestion rather than a one-line chat message).
Inspect the stored payload — internal_info is a JSON string:
curl -s 'http://:6333/collections/neo4j_vec_db/points/scroll'
-H 'Content-Type: application/json'
-d '{"limit":2,"with_payload":true}'
Call POST /product/search — recall raises ValidationError on TextualMemoryItem.metadata.internal_info (expected dict, got str).
For comparison,
sourcesin the same payload is also stored as a JSON string but is correctly restored on read, which makes the asymmetry visible:"sources": ["{"type": "chat", "role": "user", ...}"]
Environment | 环境信息
mainviadocker/Dockerfile(not a pip release)neo4j_vec_db)Additional Context | 其他信息
Related issues sharing the same underlying pattern (value serialized on write, not deserialized on read,
TextualMemoryItemvalidation fails) on a different backend and field:Since the same failure mode has now appeared on both the Postgres and Neo4j paths, it may be worth addressing the serialize/deserialize contract at the storage boundary rather than patching each field individually.
Willingness to Implement | 实现意愿