Files
Hy-Memory/scripts/dedupe_graph_edges.py
2026-10-16 16:26:27 +08:00

134 lines
5.1 KiB
Python
Raw Permalink 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.
#!/usr/bin/env python3
"""清理图里 `RELATED_TO` 的重复边(同一对节点 + 同一 relation_type 保留权重最大的一条)。
## 为什么会有重复
旧版本的 `add_edge` 使用 `CREATE` 而不是 `MERGE`,同一对节点之间可能会
**反复**建同类型边:
- `UPDATE` 是原地更新(`node_id` 不变),一条记忆被合并多次就会多次参与图投影。
两条老节点若再次同批出现,`co_occurs` 就会重复建。
- System 2(ultra)的 agent 循环可能对同一对节点反复调用 `add_edge`。
大型长期运行数据库中可能累积大量平行边,尤其是 System 2 写入的无类型 `related`。
## 影响
重复边**不会放大打分** —— 读侧 `build_adjacency` 会把同一对节点之间的平行边折叠成
最强的那条。真实代价是吃掉 `get_related_edges` 的 `max_edges` 预算:Cypher 的 `LIMIT`
先截断、去重在 Python 侧做,于是重复边挤掉了本该返回的真实边。
当前 `add_edge` 使用 `MERGE`(`ON MATCH` 取较大权重),新数据不会再重复。
这个脚本用来清历史数据。
## 用法
# 先看有多少重复(不改数据)
python scripts/dedupe_graph_edges.py --dry-run
# 实际清理,每批 5000 对
python scripts/dedupe_graph_edges.py --batch 5000
# 只清某个 relation_type
python scripts/dedupe_graph_edges.py --relation-type related
连接参数从 env 读:`NEO4J_URL` / `NEO4J_USERNAME` / `NEO4J_PASSWORD` / `NEO4J_DATABASE`。
仅支持 Neo4j —— Kuzu 是嵌入式单机库,重建成本远低于原地去重。
"""
import argparse
import asyncio
import os
import sys
def _args():
p = argparse.ArgumentParser(description="清理 RELATED_TO 重复边")
p.add_argument("--dry-run", action="store_true", help="只统计,不修改")
p.add_argument("--batch", type=int, default=5000, help="每批处理多少对(默认 5000)")
p.add_argument("--relation-type", default=None, help="只处理指定 relation_type")
p.add_argument("--max-batches", type=int, default=1000, help="安全上限")
return p.parse_args()
async def main() -> int:
a = _args()
try:
from neo4j import AsyncGraphDatabase
except ImportError:
print("需要 neo4j driver: pip install neo4j", file=sys.stderr)
return 2
url = os.getenv("NEO4J_URL") or "bolt://localhost:7687"
user = os.getenv("NEO4J_USERNAME") or "neo4j"
pwd = os.getenv("NEO4J_PASSWORD") or ""
db = os.getenv("NEO4J_DATABASE") or "neo4j"
rt_filter = "AND r.relation_type = $rt" if a.relation_type else ""
params = {"rt": a.relation_type} if a.relation_type else {}
driver = AsyncGraphDatabase.driver(url, auth=(user, pwd))
try:
async with driver.session(database=db) as s:
# ── 统计 ──
res = await s.run(
f"""
MATCH (x:Memory)-[r:RELATED_TO]->(y:Memory)
WHERE true {rt_filter}
WITH x.node_id AS s1, y.node_id AS t1,
r.relation_type AS rt, count(*) AS dup
WHERE dup > 1
RETURN count(*) AS dup_pairs, sum(dup - 1) AS removable, max(dup) AS worst
""",
params,
)
row = await res.single()
dup_pairs = int((row or {}).get("dup_pairs") or 0)
removable = int((row or {}).get("removable") or 0)
worst = int((row or {}).get("worst") or 0)
print(f"重复对: {dup_pairs} 可删边: {removable} 最严重一对: {worst} 份")
if a.dry_run or not dup_pairs:
if a.dry_run:
print("--dry-run:未修改任何数据")
return 0
# ── 分批删除:每对只留权重最大的一条 ──
total = 0
for i in range(a.max_batches):
res = await s.run(
f"""
MATCH (x:Memory)-[r:RELATED_TO]->(y:Memory)
WHERE true {rt_filter}
WITH x.node_id AS s1, y.node_id AS t1,
r.relation_type AS rt, collect(r) AS rels
WHERE size(rels) > 1
WITH rels LIMIT $batch
// 权重降序,保留第一条
WITH [e IN rels | e] AS es
UNWIND es AS e
WITH es, e ORDER BY coalesce(e.weight, 0) DESC
WITH es, collect(e) AS sorted
WITH sorted[1..] AS extras
UNWIND extras AS victim
DELETE victim
RETURN count(*) AS removed
""",
{**params, "batch": a.batch},
)
row = await res.single()
removed = int((row or {}).get("removed") or 0)
total += removed
print(f" 批 {i + 1}: 删除 {removed} 条(累计 {total})")
if removed == 0:
break
print(f"完成:共删除 {total} 条重复边")
finally:
await driver.close()
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))