ESC
深入了解 去重与合并

去重与合并

robotmem 通过写入时的多层去重管道防止冗余记忆,并在会话结束时合并相似的记忆。

去重管道

当调用 learn 时,新记忆在存储前会经过三层重复检测。

概览

learn(insight="grip_force=12.5N works best")
         │
         ├── 第 1 层:精确匹配
         │   SHA-256(content)[:16] → content_hash 查找
         │   O(1) — 即时拒绝
         │
         ├── 第 2 层:Jaccard 词元相似度
         │   FTS5 候选项 → 两两词元重叠度
         │   阈值:> 0.70 → 重复
         │
         └── 第 3 层:余弦向量相似度
         │   embed_one(content) → vec_search top-3
         │   阈值:> 0.85 → 重复
         │
         ▼
    is_dup=True → 返回 {status: "duplicate", method, existing_id, similarity}
    is_dup=False → 继续插入

第 1 层:精确匹配

最快的检查 — O(1) 哈希查找:

content_hash = hashlib.sha256(content.encode("utf-8")).hexdigest()[:16]

# 在数据库中检查
existing = conn.execute(
    "SELECT 1 FROM memories WHERE content_hash = ? AND collection = ? "
    "AND status = 'active' LIMIT 1",
    (content_hash, collection),
).fetchone()

第 2 层:Jaccard 词元相似度

用于检测措辞略有不同的近似重复:

def jaccard_similarity(a: str, b: str) -> float:
    tokens_a = set(a.lower().split()) - STOPWORDS
    tokens_b = set(b.lower().split()) - STOPWORDS
    intersection = tokens_a & tokens_b
    union = tokens_a | tokens_b
    return len(intersection) / len(union)

流程: 1. 使用 FTS5 找到与新内容匹配的 top-5 候选项 2. 计算每个候选项的 Jaccard 相似度 3. 阈值:> 0.70 → 标记为重复

停用词同时支持英文和中文过滤:

STOPWORDS = frozenset(
    "的 了 在 是 把 被 给 和 与 从 到 也 都 就 对 又 所 而 且 但 或 "
    "a an the is are was were be to of and in for on with "
    "preference constraint decision observation code config pattern "
    "architecture root_cause tradeoff revert".split()
)

分类相关术语也被包含在停用词中,以防止仅因共享分类关键词而产生的误匹配。

第 3 层:余弦向量相似度

用于检测使用不同词汇的语义重复:

# 仅在 embedder 可用时运行
embedding = await embedder.embed_one(assertion)
vec_results = db_cog.vec_search_memories(
    query_embedding=embedding, collection=collection, limit=3
)
for vr in vec_results:
    cosine_sim = 1.0 - vr["distance"]
    if cosine_sim >= 0.85:
        return DedupResult(is_dup=True, method="cosine", similarity=cosine_sim)

相似(非重复)追踪

中等相似度(Jaccard 0.40-0.70 或余弦 0.40-0.85)的记忆会作为 similar_facts 记录在结果中:

{
    "is_dup": False,
    "similar_facts": [
        {"id": 15, "assertion": "similar content...", "similarity": 0.55}
    ],
    "method": "none",
    "similarity": 0.55
}

这些信息返回给调用方供参考,但不会阻止插入。

会话内余弦去重

额外的去重层在当前会话范围内运作:

def check_session_cosine_dup(assertion, session_id, collection, db_cog, embedder):
    """在同一会话内,检查语义重复"""
    embedding = embedder.embed_one(assertion)
    results = db_cog.vec_search_memories(embedding, collection, limit=20)
    for r in results:
        if r["session_id"] != session_id:
            continue  # 仅检查同一会话
        cosine_sim = 1.0 - r["distance"]
        if cosine_sim >= 0.85:
            return DedupResult(is_dup=True, method="session_cosine")

这能捕获机器人在单个会话中多次记录相同观察的情况。

去重阈值

层级 方法 重复阈值 相似阈值
1 精确哈希 1.0(精确)
2 Jaccard 词元 > 0.70 > 0.40
3 余弦向量 > 0.85 > 0.40
3b 会话内余弦 > 0.85

批量清理

对于已积累重复数据的现有数据库,cleanup_exact_duplicates() 提供了批量清理工具:

ops = cleanup_exact_duplicates(db_cog, collection="default", dry_run=True)
# 返回:[{"old_id": 5, "keep_id": 3, "assertion_preview": "..."}]

# 执行清理
ops = cleanup_exact_duplicates(db_cog, collection="default", dry_run=False)

安全特性: - 试运行模式:在应用前预览更改 - 每次最多 200 个操作:防止失控清理 - 保留最高置信度:在重复项中,保留置信度最高(然后最新)的记忆 - 软删除:重复项标记为 superseded,而非物理删除

合并

end_session 时,robotmem 通过贪心 Jaccard 聚类合并会话中的冗余记忆。

算法

end_session(session_id="abc-123")
         │
    1. 查询可合并的记忆
       ├── 相同会话 + collection
       ├── status = 'active'
       ├── category 不在 (constraint, postmortem, gotcha) 中
       ├── confidence < 0.95
       └── perception_type 为 NULL
         │
    2. 如果少于 3 条记忆则跳过
         │
    3. 按 category 分组
         │
    4. 在每组内:两两计算 Jaccard 相似度
       ├── > 0.50 阈值 → 候选对
       └── 贪心聚类(簇内所有对必须超过阈值)
         │
    5. 每个簇:选择代表
       ├── 优先级:confidence DESC
       ├── 次优先:access_count DESC
       └── 次优先:created_at DESC
         │
    6. 非代表 → status = 'superseded'
       └── superseded_by = representative.id

受保护的记忆

以下记忆永远不会被合并,即使相似:

保护条件 原因
分类 constraint 安全规则绝不能被合并
分类 postmortem 教训具有独立价值
分类 gotcha 每个陷阱都有独特的上下文
置信度 >= 0.95 高置信度记忆太有价值不能合并
类型 perception 传感器数据应单独保留

贪心聚类

聚类算法确保高质量的簇:

for i, a in enumerate(mems):
    cluster = [a]
    for j in range(i+1, len(mems)):
        b = mems[j]
        # 全对约束:b 必须与簇内每个成员相似
        sim_ok = all(
            jaccard_similarity(b["content"], c["content"]) > 0.50
            for c in cluster
        )
        if sim_ok:
            cluster.append(b)

这比单链接聚类更严格 — 簇内的每一对都必须超过阈值,防止"链式漂移":即 A→B→C 有链接但 A 和 C 实际上不相似。

代表选择

在每个簇内,代表的选择依据:

  1. 最高置信度 — 最可靠的记忆存活
  2. 最高访问次数 — 最常被召回的 = 最有用的
  3. 最新的created_at DESC)— 最新的信息

合并响应

{
    "merged_groups": 2,
    "superseded_count": 3,
    "compression_ratio": 0.20,
    "avg_similarity": 0.65
}
字段 描述
merged_groups 发现的簇数量
superseded_count 被标记为 superseded 的记忆总数
compression_ratio superseded_count / total_consolidatable
avg_similarity 簇内平均 Jaccard 相似度

示例

给定会话中的 5 条记忆:

#1: "grip_force=12.5N works for cups"        (category=observation, confidence=0.85)
#2: "12.5N grip force optimal for cylinders" (category=observation, confidence=0.80)
#3: "force 12.5N best for cup grasping"      (category=observation, confidence=0.90)
#4: "red objects need 15N force"             (category=observation, confidence=0.85)
#5: "always calibrate before grasping"       (category=constraint, confidence=0.95)

结果: - #5 受保护(constraint 分类 + 高置信度) - #1、#2、#3 聚为一簇(两两 Jaccard > 0.50) - #3 被选为代表(最高置信度:0.90) - #1、#2 → supersededsuperseded_by = 3 - #4 保持独立(相似度不足以加入簇)

时间衰减

同样由 end_session 触发,时间衰减会降低近期未被访问的记忆的置信度:

公式

confidence_new = confidence × (1 - decay_rate) ^ days_since_last_access

参数

参数 默认值 描述
decay_rate 0.01 每日衰减率(1%)
min_interval_days 1.0 仅在上次访问 > 1 天前时衰减
置信度下限 0.05 低于此阈值停止衰减

基准时间

衰减参考点是 last_accessed(由 recall 命中时更新),回退到 created_at

julianday('now') - julianday(COALESCE(last_accessed, created_at))

这意味着: - 经常被召回的记忆保持高置信度(每次召回重置时钟) - 未使用的记忆逐渐淡化 - 从未被召回的记忆从创建日期开始衰减

衰减曲线

对于 confidence=0.90decay_rate=0.01 的记忆:

距上次访问天数 置信度
0 0.900
7 0.839
30 0.664
90 0.365
180 0.148
365 0.024

在默认的 min_confidence=0.3 召回过滤器下,该记忆在约 93 天未被召回后将不再出现在搜索结果中。

数据库操作

所有合并和去重操作使用安全的数据库原语:

操作 原语 失败时行为
标记记忆为 superseded safe_db_transaction 原子性:全部成功或全部回滚
时间衰减批处理 safe_db_transaction 原子性:全部成功或全部回滚
清理重复项 safe_db_transaction 每批次原子
更新记忆访问时间 safe_db_transaction 每批次原子

失败的事务会被记录到日志,但永远不会导致服务器崩溃。