Files
Hy-Memory/hy_memory/data/graph_store_base.py
T
2026-10-16 16:26:27 +08:00

660 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Agent Memory V2 - GraphStore 抽象基类
定义图存储层的统一接口(Provider 模式),允许切换不同的图数据库后端。
当前支持:
- Kuzu (嵌入式)
- Neo4j (客户端-服务端)
图 Schema:
节点类型:
User — 用户根节点
Memory — 记忆节点 (L6_SCHEMA / L7_INTENTION)
embedding FLOAT[dims] — 内容向量 V_con (HNSW 索引)
beh_embedding FLOAT[dims] — 行为向量 V_beh (HNSW 索引, 可 NULL)
Topic — 主题标签(LLM tags,≠ 正文 NER 实体)
Entity — 正文 NER 实体(GPE/ORG/PRODUCT…;不含 PERSON)
VdbRef — VDB 影子节点 (evidence 引用)
边类型:
HAS_MEMORY : User → Memory
TAGGED_WITH : Memory → Topic
MENTIONS : Memory → Entity
RELATED_TO : Memory ↔ Memory (双向)
DERIVED_FROM : Memory → VdbRef (evidence 关系)
CROSS_ABSTRACTS_TO : Memory → Memory (单向, L6 basic → L6 core)
"""
from abc import ABC, abstractmethod
from typing import Optional, List, Dict, Any, Sequence, Tuple
from ..models.memory import MemoryNode, MemoryLayer, MemoryStatus
from ..config import MemoryConfig
async def embed_text_for_graph(embed_service, text: str) -> Optional[List[float]]:
"""给图层用的 embedding 调用,兼容 `embed_queued` / `embed` 两种方法名。
历史上两个后端各写了一个:Kuzu 用 `embed_queued`、Neo4j 用 `embed`。真实
`EmbedService` 两个都有,所以都能跑;但任何只实现其中一个的实现(测试 fake、
精简版 embed service)会让另一个后端的 tag 归一化**静默退化成字符串精确匹配** ——
而不归一化,tag 图就会碎成一堆同义孤岛。这里统一入口,消掉这个隐患。
"""
if embed_service is None or not text:
return None
for name in ("embed_queued", "embed"):
fn = getattr(embed_service, name, None)
if fn is None:
continue
return await fn(text)
raise AttributeError(
"embed_service 需要提供 embed_queued() 或 embed() 之一"
)
def make_entity_id(isolation_key: str, label: str, name: str) -> str:
"""确定性 Entity 主键:isolation + label + casefold(name)。"""
import uuid as _uuid
norm = (name or "").strip().casefold()
lab = (label or "").strip().upper()
return (
f"ent_{_uuid.uuid5(_uuid.NAMESPACE_URL, f'{isolation_key}:{lab}:{norm}').hex[:12]}"
)
def normalize_entity_name(name: str) -> str:
return (name or "").strip().casefold()
class GraphStoreBase(ABC):
"""
图存储抽象基类
所有图数据库后端必须实现此接口。
"""
def __init__(self, config: MemoryConfig):
self.config = config
_supports_batch_topic_tags: bool = False
_supports_batch_memory_nodes: bool = False
_supports_batch_entity_mentions: bool = False
_supports_batch_related_edges: bool = False
_supports_batch_temporal_edges: bool = False
# ================================================================
# 生命周期
# ================================================================
@abstractmethod
async def initialize(self) -> None:
"""初始化图数据库连接和 Schema"""
...
@abstractmethod
async def close(self) -> None:
"""关闭连接"""
...
@abstractmethod
async def get_stats(self) -> Dict[str, Any]:
"""获取图统计信息"""
...
# ================================================================
# User 节点管理
# ================================================================
@abstractmethod
async def ensure_user_node(
self,
isolation_key: str,
user_id: str = "",
agent_id: str = "",
) -> None:
"""确保 User 节点存在"""
...
# ================================================================
# Memory 节点 CRUD
# ================================================================
@abstractmethod
async def upsert_memory_node(self, node: MemoryNode) -> str:
"""创建或更新 Memory 节点 + User→Memory 边,返回 node_id"""
...
async def upsert_memory_nodes_batch(
self, nodes: Sequence[MemoryNode],
) -> List[str]:
"""Batch-compatible Memory projection fallback.
Backends with a native bulk primitive can override this method and set
``_supports_batch_memory_nodes``. The default deliberately preserves
the old per-node failure boundary for other and third-party backends.
"""
out: List[str] = []
for node in nodes:
node_id = await self.upsert_memory_node(node)
if node_id:
out.append(node_id)
return out
@abstractmethod
async def get_node(self, node_id: str) -> Optional[MemoryNode]:
"""获取单个 Memory 节点"""
...
@abstractmethod
async def get_nodes_by_ids(self, node_ids: List[str]) -> List[MemoryNode]:
"""批量获取 Memory 节点"""
...
@abstractmethod
async def get_all_nodes(
self,
isolation_key: str,
layer: Optional[MemoryLayer] = None,
status: Optional[MemoryStatus] = None,
limit: int = 100,
) -> List[MemoryNode]:
"""按条件获取节点列表"""
...
async def get_nodes_by_user(
self,
user_id: str,
layer: Optional[MemoryLayer] = None,
status: Optional[MemoryStatus] = None,
limit: int = 100,
) -> List[MemoryNode]:
"""Return nodes across every historical agent scope for one user.
User-level profile artifacts use this to discover and retire legacy
agent-scoped catalog/supplement nodes. Third-party backends may keep the
safe empty fallback until they implement a native user_id query.
"""
return []
@abstractmethod
async def get_profile(self, isolation_key: str) -> Optional[MemoryNode]:
"""获取 L5 Identity Profile 节点"""
...
@abstractmethod
async def update_node(self, node_id: str, updates: Dict[str, Any]) -> bool:
"""更新节点属性"""
...
@abstractmethod
async def delete_node(self, node_id: str) -> bool:
"""删除 Memory 节点及其关联边"""
...
@abstractmethod
async def delete_all_nodes(self, isolation_key: str) -> int:
"""删除某隔离键下所有 Memory 节点,返回删除数量"""
...
async def clear_projection(
self,
user_id: str,
agent_id: Optional[str] = None,
*,
layer: str = "l2_fact",
) -> Dict[str, int]:
"""清掉某用户的 **pro 投影**(默认只删 `l2_fact`),供重建前使用。
为什么需要它:图是可重建的派生物,但重建**不会删旧节点**。改了投影规则之后
(例如 Topic 隔离键从 `user::agent::session` 收窄成 `user::agent`),旧的
session 粒度 Topic 仍挂着 `TAGGED_WITH` 边留在库里,`get_all_topics` 会同时
返回新旧两套,效果打折。
⚠️ 必须按 layer 收窄:一个用户的 Memory 节点里还有 System 2 / ultra 写的
`l6_schema`,无条件删会把那部分数据一起毁掉。
同时清掉因此变成孤儿的 Topic / Entity 节点(没有任何 Memory 挂着的)。
默认返回 0(未实现该能力的后端不做任何事)。
"""
return {"memories": 0, "topics": 0, "entities": 0}
@abstractmethod
async def delete_by_metadata(
self,
user_id: str,
agent_id: Optional[str] = None,
session_id: Optional[str] = None,
) -> int:
"""
按 metadata 字段组合删除 Memory 节点。
粒度:
- 仅 user_id → 删除该用户所有记忆
- user_id + agent_id → 删除该用户指定 agent 下所有记忆
- user_id + agent_id + session_id → 删除精确 session 的记忆
Returns:
删除的节点数量
"""
...
# ================================================================
# 边操作
# ================================================================
@abstractmethod
async def add_edge(
self,
source_id: str,
target_id: str,
edge_type: str,
properties: Optional[Dict[str, Any]] = None,
) -> bool:
"""添加 Memory→Memory 关系边 (RELATED_TO)"""
...
async def add_edges_batch(
self,
edges: Sequence[
Tuple[str, str, str, Optional[Dict[str, Any]]]
],
) -> List[bool]:
"""Batch-compatible fallback for Memory→Memory edges."""
return [
bool(await self.add_edge(source_id, target_id, edge_type, properties))
for source_id, target_id, edge_type, properties in edges
]
@abstractmethod
async def add_topic_tag(
self,
memory_node_id: str,
topic_name: str,
isolation_key: str,
) -> str:
"""记忆节点关联主题 (Memory→Topic),返回 topic_id"""
...
async def add_topic_tags_batch(
self,
assignments: List[Tuple[str, str, str]],
*,
embed_service=None,
similarity_threshold: float = 0.85,
) -> List[Tuple[str, str]]:
"""Batch-compatible fallback for Memory→Topic projection.
Backends can override this to share topic lookup/embedding and reduce
storage round trips. The fallback preserves the existing semantics.
"""
out: List[Tuple[str, str]] = []
for memory_node_id, topic_name, isolation_key in assignments:
topic_id = await self.add_topic_tag(
memory_node_id,
topic_name,
isolation_key,
embed_service=embed_service,
similarity_threshold=similarity_threshold,
)
if topic_id:
out.append((memory_node_id, topic_id))
return out
async def add_topic_path(
self,
memory_node_id: str,
topic_path: List[str],
isolation_key: str,
subject: str,
*,
node_types: Optional[List[str]] = None,
role: str = "subject",
embed_service=None,
) -> List[str]:
"""Attach a memory to a typed, subject-scoped structural path.
Implementations create explicit hierarchy nodes and `SUBTOPIC_OF` edges,
then attach the memory only to the most-specific leaf. The default no-op
keeps third-party GraphStore implementations backward compatible.
"""
return []
async def clear_hierarchy_memberships(self, memory_node_id: str) -> int:
"""Remove direct `MEMBER_OF` edges before re-projecting an updated L2."""
return 0
async def prune_hierarchy_topics(self, isolation_key: str) -> int:
"""Delete empty hierarchy leaves, then empty ancestors, to convergence."""
return 0
async def get_hierarchy_topics(
self, isolation_key: str, *, subject: str = "",
) -> List[Dict[str, Any]]:
"""Return hierarchy nodes with path/depth/member-count metadata."""
return []
async def get_hierarchy_topic_memories(
self, hierarchy_topic_id: str, *, limit: int = 200,
) -> List[Dict[str, Any]]:
"""Return active canonical L2 members directly assigned to one node."""
return []
async def get_hierarchy_memberships(
self, isolation_key: str,
) -> List[Dict[str, str]]:
"""Return direct leaf memberships for one cross-session topic scope."""
return []
# ================================================================
# Entity(正文 NER)+ MENTIONS
# ================================================================
async def add_entity_mention(
self,
memory_node_id: str,
name: str,
label: str,
isolation_key: str,
) -> str:
"""Memory -[:MENTIONS]-> Entity。返回 entity_id;失败返回空串。
Entity ≠ Topic:Topic 来自 LLM tags;Entity 来自正文 spaCy NER。
默认 no-op(未实现的后端不影响主路径)。
"""
return ""
async def replace_entity_mentions_batch(
self,
assignments: Sequence[
Tuple[str, Sequence[Tuple[str, str]], str]
],
) -> List[Tuple[str, str]]:
"""Replace MENTIONS edges for several memories.
Each item is ``(memory_node_id, [(name, label), ...], isolation_key)``.
Empty entity lists still clear stale mentions, matching update-time
projection semantics. The default is the original clear-then-add
sequence so non-batch backends retain their existing behavior.
"""
out: List[Tuple[str, str]] = []
for memory_node_id, entities, isolation_key in assignments:
await self.clear_entity_mentions(memory_node_id)
for name, label in entities:
entity_id = await self.add_entity_mention(
memory_node_id, name, label, isolation_key,
)
if entity_id:
out.append((memory_node_id, entity_id))
return out
async def clear_entity_mentions(self, memory_node_id: str) -> int:
"""删掉某 Memory 的全部 MENTIONS 边(UPDATE 投影前重建用)。返回删边数。"""
return 0
async def find_memories_by_entities(
self,
isolation_key: str,
entities: Sequence[Tuple[str, str]],
limit: int = 100,
) -> List[Dict[str, Any]]:
"""按实体名(casefold)反查挂 MENTIONS 的 active Memory。
entities: [(name, label), ...];label 仅作同组过滤提示(可空)。
返回 [{node_id, entity_name, entity_label, match_score}],match_score∈(0,1]。
"""
return []
# ================================================================
# Topic 检索 (Tag 收敛 + Tag 桥接召回)
# ================================================================
async def get_all_topics(
self,
isolation_key: str,
) -> List[Dict[str, Any]]:
"""获取某 isolation_key 下的所有 Topic 节点
返回 [{topic_id, name, embedding, degree}]:
- `embedding` 可能为 None(旧数据 / 写入时未传 embed_service)
- `degree` = 挂在该 Topic 上的 active 记忆数,画像聚合用
(Topic 度数 + 新鲜度排序 ≈ "用户是什么样的人",零 LLM 成本)
两个后端必须返回同样的键,否则依赖 `degree` 的能力会在某个后端上静默失效。
"""
return [] # 默认 no-op,子类覆盖
async def tag_bridge_search(
self,
topic_ids: List[str],
limit: int = 20,
) -> List[Dict[str, Any]]:
"""Tag 桥接召回:从 Topic 节点反向查找关联的 Memory 节点
即 MATCH (t:Topic)<-[:TAGGED_WITH]-(m:Memory) WHERE m.status = 'active'
返回 [{node_id, content, layer, confidence, topic_name, custom_json}]
"""
return [] # 默认 no-op,子类覆盖
# ================================================================
# V3: VdbRef (shadow node) + DERIVED_FROM edge
# ================================================================
async def ensure_vdbref(self, node_id: str, layer: str) -> None:
"""确保 VdbRef 影子节点存在(不存在则创建,已存在则跳过)"""
... # 默认 no-op,子类覆盖
async def add_derived_from(self, memory_node_id: str, vdbref_node_id: str) -> bool:
"""添加 DERIVED_FROM 边: Memory → VdbRef (evidence 关系)"""
return False # 默认 no-op
async def find_referencing_memories(
self,
vdb_node_ids: List[str],
limit: int = 50,
include_sources: Optional[List[str]] = None,
exclude_sources: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
"""反向查找: 给定 VDB node_id 列表,找到 Graph 中引用它们的 Memory 节点
即 MATCH (v:VdbRef)<-[:DERIVED_FROM]-(m:Memory)
返回 [{node_id, content, layer, confidence, evidence_vdb_id}]
"""
return [] # 默认 no-op
async def get_evidence_vdbrefs(self, memory_node_id: str) -> List[Dict[str, Any]]:
"""获取一个 Graph Memory 节点的所有 DERIVED_FROM evidence VdbRef
返回 [{node_id, layer}]
"""
return [] # 默认 no-op
# ================================================================
# 图检索
# ================================================================
@abstractmethod
async def expand_from_anchors(
self,
anchor_ids: List[str],
hop: int = 1,
max_nodes: int = 50,
) -> List[Dict[str, Any]]:
"""从锚点节点出发,沿图边扩展 N 跳"""
...
async def get_topic_memories(
self, topic_id: str, *, limit: int = 200,
) -> List[Dict[str, Any]]:
"""列出某个 Topic 下的 active 记忆,按 memory_at 正序。
给 Topic-scoped summary 和时序脊用。
刻意不复用 `tag_bridge_search` —— 那条是 legacy reader 的召回通道,
而 tag 共属已决定不作为遍历边(τ=0,见 docs/pro_graph_design.md §3.0)。
⚠️ **图里没有事件时间**:Kuzu 的 `Memory` 表只有 `created_at`(落库时间),
没有 `memory_at`,而新增列对已有 DB 不生效。所以时间一律以 VDB 为准 ——
调用方拿到 node_id 后回 VDB 取 `memory_at` 再排序
(见 `pipelines/_graph/projector.resolve_memory_times`)。
Returns:
[{"node_id", "content"}, ...],按 created_at 正序
"""
return [] # 默认 no-op,子类覆盖
async def add_temporal_edge(
self, prev_id: str, next_id: str, topic_id: str = "",
) -> bool:
"""建一条时序脊边(TEMPORAL_NEXT,**有向**:prev → next)。
`topic_id` 为空串表示**全局脊**(跨话题,按事件时间连相邻两条);
非空表示 Topic 内脊。两者共存:Topic 内脊表达"这个主题怎么演变",
全局脊表达"那几天前后发生了什么"。
⚠️ 时间关系刻意**不建成"同天两两相连"的团**:一天 20 条记忆就是 190 条边、
度数 19,一跳把当天全拉进来、两跳把三天全拉进来 —— 和 tag hub 淹没结果同类。
全局脊每条只连时间最近的前一条,度数恒为 2,同天记忆仍可达但候选量由跳数预算控制。
与 `supersedes` 的区别:`supersedes` 表达矛盾取代(旧的不再成立),
`TEMPORAL_NEXT` 表达递进(旧的仍然成立,只是更早)。
"""
return False # 默认 no-op,子类覆盖
async def add_temporal_edges_batch(
self, edges: Sequence[Tuple[str, str, str]],
) -> List[bool]:
"""Batch-compatible fallback for directed TEMPORAL_NEXT edges."""
return [
bool(await self.add_temporal_edge(prev_id, next_id, topic_id))
for prev_id, next_id, topic_id in edges
]
async def get_related_edges(
self,
node_ids: List[str],
*,
max_edges: int = 2000,
relation_types: Optional[List[str]] = None,
include_temporal: bool = True,
include_sources: Optional[List[str]] = None,
exclude_sources: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
"""取出所有一端落在 node_ids 里的 RELATED_TO 边(无向,去重后每对只返回一次)。
这是读侧图重排的**唯一图查询原语**,一次查询同时服务三种用法:
- 用法 A(池内支持度传播):过滤出两端都在召回池里的边
- 用法 B(阈值松弛):取另一端不在池里的边 → 候选
- 用法 C(关系路径):用同一批边在 Python 侧建邻接、找 ≤2 跳路径
返回里带上 target 的 status / layer,省掉一次回查:读侧必须能过滤
SHADOW(判重逻辑删除)节点,否则被删掉的记忆会从图里绕回来。
`include_temporal=True` 时把 TEMPORAL_NEXT 也一起返回,
`relation_type="temporal_next"` + `directed=True`(方向为 source→target,
即"更早 → 更晚")。A/B 把它当普通边用(按 τ 先验加权),C 用它的方向。
Returns:
[{"source_id", "target_id", "relation_type", "weight",
"target_status", "target_layer", "directed"}, ...]
"""
return [] # 默认 no-op,子类覆盖
async def expand_with_tags(
self,
anchor_ids: List[str],
hop: int = 2,
max_nodes: int = 500,
isolation_key: str = "",
) -> List[Dict[str, Any]]:
"""从锚点出发,沿 RELATED_TO + TAGGED_WITH(双向) 展开 N 跳。
路径包括:
- Memory -[RELATED_TO]- Memory(双向)
- Memory -[TAGGED_WITH]-> Topic <-[TAGGED_WITH]- Memory(经 Topic 桥接)
isolation_key 非空时只展开属于同一 isolation_key 的节点。
返回 [{node_id, content, layer, confidence, hop, source}]
"""
return [] # 默认 no-op,子类覆盖
# ================================================================
# 向量检索 (Graph HNSW)
# ================================================================
async def vector_search(
self,
query_embedding: List[float],
isolation_key: str,
layers: Optional[List[str]] = None,
limit: int = 10,
score_threshold: float = 0.0,
user_id: str = "",
include_sources: Optional[List[str]] = None,
exclude_sources: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
"""V_con 向量检索 Graph Memory 节点
使用 embedding 列的 HNSW 索引做 ANN 检索。
当 isolation_key 为空但 user_id 非空时,执行跨 agent 搜索
(匹配 isolation_key 以 user_id + "::" 为前缀的所有节点)。
返回 [{node_id, content, layer, confidence, score}]
"""
return [] # 默认 no-op,子类覆盖
async def beh_vector_search(
self,
query_beh_embedding: List[float],
isolation_key: str,
limit: int = 10,
) -> List[Dict[str, Any]]:
"""V_beh 向量检索 L6 basic 节点 (sweeper 用)
使用 beh_embedding 列的 HNSW 索引做 ANN 检索。
返回 [{node_id, content, embedding, beh_distance}]
"""
return [] # 默认 no-op,子类覆盖
async def update_embedding(
self,
node_id: str,
embedding: Optional[List[float]] = None,
beh_embedding: Optional[List[float]] = None,
) -> bool:
"""更新节点的 embedding / beh_embedding 向量属性"""
return False # 默认 no-op
# ================================================================
# 跨域归纳 (Cross-Domain Schema Induction)
# ================================================================
async def add_cross_abstracts_to(
self,
basic_id: str,
core_id: str,
) -> bool:
"""添加 CROSS_ABSTRACTS_TO 边: L6 basic → L6 core (单向)"""
return False # 默认 no-op
async def find_cores_from_basics(
self,
basic_ids: List[str],
max_hops: int = 2,
) -> List[Dict[str, Any]]:
"""从 L6 basic 出发,沿 RELATED_TO / CROSS_ABSTRACTS_TO 找 L6 core 节点
返回 [{node_id, content, confidence}],已去重
"""
return [] # 默认 no-op
async def get_cross_abstracts_targets(
self,
basic_id: str,
) -> List[str]:
"""查询某个 L6 basic 已有的 CROSS_ABSTRACTS_TO 边指向的 core node_id 列表"""
return [] # 默认 no-op