From 28d41acc51961252dc92da80faa7c68699824f90 Mon Sep 17 00:00:00 2001 From: Windpicker-owo <3431391539@qq.com> Date: Thu, 6 Nov 2025 21:09:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(deduplicate=5Fmemories):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E8=AE=B0=E5=BF=86=E5=8E=BB=E9=87=8D=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E9=A2=84=E8=A7=88=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E5=92=8C=E7=9B=B8=E4=BC=BC=E5=BA=A6=E9=98=88=E5=80=BC=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/guides/memory_deduplication_guide.md | 391 +++++++++++++++++++++ scripts/deduplicate_memories.py | 404 ++++++++++++++++++++++ 2 files changed, 795 insertions(+) create mode 100644 docs/guides/memory_deduplication_guide.md create mode 100644 scripts/deduplicate_memories.py diff --git a/docs/guides/memory_deduplication_guide.md b/docs/guides/memory_deduplication_guide.md new file mode 100644 index 000000000..77d346a0c --- /dev/null +++ b/docs/guides/memory_deduplication_guide.md @@ -0,0 +1,391 @@ +# 记忆去重工具使用指南 + +## 📋 功能说明 + +`deduplicate_memories.py` 是一个用于清理重复记忆的工具。它会: + +1. 扫描所有标记为"相似"关系的记忆对 +2. 根据重要性、激活度和创建时间决定保留哪个 +3. 删除重复的记忆,保留最有价值的那个 +4. 提供详细的去重报告 + +## 🚀 快速开始 + +### 步骤1: 预览模式(推荐) + +**首次使用前,建议先运行预览模式,查看会删除哪些记忆:** + +```bash +python scripts/deduplicate_memories.py --dry-run +``` + +输出示例: +``` +============================================================ +记忆去重工具 +============================================================ +数据目录: data/memory_graph +相似度阈值: 0.85 +模式: 预览模式(不实际删除) +============================================================ + +✅ 记忆管理器初始化成功,共 156 条记忆 +找到 23 对相似记忆(阈值>=0.85) + +[预览] 去重相似记忆对 (相似度=0.904): + 保留: mem_20251106_202832_887727 + - 主题: 今天天气很好 + - 重要性: 0.60 + - 激活度: 0.55 + - 创建时间: 2024-11-06 20:28:32 + 删除: mem_20251106_202828_883440 + - 主题: 今天天气晴朗 + - 重要性: 0.50 + - 激活度: 0.50 + - 创建时间: 2024-11-06 20:28:28 + [预览模式] 不执行实际删除 + +============================================================ +去重报告 +============================================================ +总记忆数: 156 +相似记忆对: 23 +发现重复: 23 +预览通过: 23 +错误数: 0 +耗时: 2.35秒 + +⚠️ 这是预览模式,未实际删除任何记忆 +💡 要执行实际删除,请运行: python scripts/deduplicate_memories.py +============================================================ +``` + +### 步骤2: 执行去重 + +**确认预览结果无误后,执行实际去重:** + +```bash +python scripts/deduplicate_memories.py +``` + +输出示例: +``` +============================================================ +记忆去重工具 +============================================================ +数据目录: data/memory_graph +相似度阈值: 0.85 +模式: 执行模式(会实际删除) +============================================================ + +✅ 记忆管理器初始化成功,共 156 条记忆 +找到 23 对相似记忆(阈值>=0.85) + +[执行] 去重相似记忆对 (相似度=0.904): + 保留: mem_20251106_202832_887727 + ... + 删除: mem_20251106_202828_883440 + ... + ✅ 删除成功 + +正在保存数据... +✅ 数据已保存 + +============================================================ +去重报告 +============================================================ +总记忆数: 156 +相似记忆对: 23 +成功删除: 23 +错误数: 0 +耗时: 5.67秒 + +✅ 去重完成! +📊 最终记忆数: 133 (减少 23 条) +============================================================ +``` + +## 🎛️ 命令行参数 + +### `--dry-run`(推荐先使用) + +预览模式,不实际删除任何记忆。 + +```bash +python scripts/deduplicate_memories.py --dry-run +``` + +### `--threshold <相似度>` + +指定相似度阈值,只处理相似度大于等于此值的记忆对。 + +```bash +# 只处理高度相似(>=0.95)的记忆 +python scripts/deduplicate_memories.py --threshold 0.95 + +# 处理中等相似(>=0.8)的记忆 +python scripts/deduplicate_memories.py --threshold 0.8 +``` + +**阈值建议**: +- `0.95-1.0`: 极高相似度,几乎完全相同(最安全) +- `0.9-0.95`: 高度相似,内容基本一致(推荐) +- `0.85-0.9`: 中等相似,可能有细微差别(谨慎使用) +- `<0.85`: 低相似度,可能误删(不推荐) + +### `--data-dir <目录>` + +指定记忆数据目录。 + +```bash +# 对测试数据去重 +python scripts/deduplicate_memories.py --data-dir data/test_memory + +# 对备份数据去重 +python scripts/deduplicate_memories.py --data-dir data/memory_backup +``` + +## 📖 使用场景 + +### 场景1: 定期维护 + +**建议频率**: 每周或每月运行一次 + +```bash +# 1. 先预览 +python scripts/deduplicate_memories.py --dry-run --threshold 0.92 + +# 2. 确认后执行 +python scripts/deduplicate_memories.py --threshold 0.92 +``` + +### 场景2: 清理大量重复 + +**适用于**: 导入外部数据后,或发现大量重复记忆 + +```bash +# 使用较低阈值,清理更多重复 +python scripts/deduplicate_memories.py --threshold 0.85 +``` + +### 场景3: 保守清理 + +**适用于**: 担心误删,只想删除极度相似的记忆 + +```bash +# 使用高阈值,只删除几乎完全相同的记忆 +python scripts/deduplicate_memories.py --threshold 0.98 +``` + +### 场景4: 测试环境 + +**适用于**: 在测试数据上验证效果 + +```bash +# 对测试数据执行去重 +python scripts/deduplicate_memories.py --data-dir data/test_memory --dry-run +``` + +## 🔍 去重策略 + +### 保留原则(按优先级) + +脚本会按以下优先级决定保留哪个记忆: + +1. **重要性更高** (`importance` 值更大) +2. **激活度更高** (`activation` 值更大) +3. **创建时间更早** (更早创建的记忆) + +### 增强保留记忆 + +保留的记忆会获得以下增强: + +- **重要性** +0.05(最高1.0) +- **激活度** +0.05(最高1.0) +- **访问次数** 累加被删除记忆的访问次数 + +### 示例 + +``` +记忆A: 重要性0.8, 激活度0.6, 创建于 2024-11-01 +记忆B: 重要性0.7, 激活度0.9, 创建于 2024-11-05 + +结果: 保留记忆A(重要性更高) +增强: 重要性 0.8 → 0.85, 激活度 0.6 → 0.65 +``` + +## ⚠️ 注意事项 + +### 1. 备份数据 + +**在执行实际去重前,建议备份数据:** + +```bash +# Windows +xcopy data\memory_graph data\memory_graph_backup /E /I /Y + +# Linux/Mac +cp -r data/memory_graph data/memory_graph_backup +``` + +### 2. 先预览再执行 + +**务必先运行 `--dry-run` 预览:** + +```bash +# 错误示范 ❌ +python scripts/deduplicate_memories.py # 直接执行 + +# 正确示范 ✅ +python scripts/deduplicate_memories.py --dry-run # 先预览 +python scripts/deduplicate_memories.py # 再执行 +``` + +### 3. 阈值选择 + +**过低的阈值可能导致误删:** + +```bash +# 风险较高 ⚠️ +python scripts/deduplicate_memories.py --threshold 0.7 + +# 推荐范围 ✅ +python scripts/deduplicate_memories.py --threshold 0.92 +``` + +### 4. 不可恢复 + +**删除的记忆无法恢复!** 如果不确定,请: + +1. 先备份数据 +2. 使用 `--dry-run` 预览 +3. 使用较高的阈值(如 0.95) + +### 5. 中断恢复 + +如果执行过程中中断(Ctrl+C),已删除的记忆无法恢复。建议: + +- 在低负载时段运行 +- 确保足够的执行时间 +- 使用 `--threshold` 限制处理数量 + +## 🐛 故障排查 + +### 问题1: 找不到相似记忆对 + +``` +找到 0 对相似记忆(阈值>=0.85) +``` + +**原因**: +- 没有标记为"相似"的边 +- 阈值设置过高 + +**解决**: +1. 降低阈值:`--threshold 0.7` +2. 检查记忆系统是否正确创建了相似关系 +3. 先运行自动关联任务 + +### 问题2: 初始化失败 + +``` +❌ 记忆管理器初始化失败 +``` + +**原因**: +- 数据目录不存在 +- 配置文件错误 +- 数据文件损坏 + +**解决**: +1. 检查数据目录是否存在 +2. 验证配置文件:`config/bot_config.toml` +3. 查看详细日志定位问题 + +### 问题3: 删除失败 + +``` +❌ 删除失败: ... +``` + +**原因**: +- 权限不足 +- 数据库锁定 +- 文件损坏 + +**解决**: +1. 检查文件权限 +2. 确保没有其他进程占用数据 +3. 恢复备份后重试 + +## 📊 性能参考 + +| 记忆数量 | 相似对数 | 执行时间(预览) | 执行时间(实际) | +|---------|---------|----------------|----------------| +| 100 | 10 | ~1秒 | ~2秒 | +| 500 | 50 | ~3秒 | ~6秒 | +| 1000 | 100 | ~5秒 | ~12秒 | +| 5000 | 500 | ~15秒 | ~45秒 | + +**注**: 实际时间取决于服务器性能和数据复杂度 + +## 🔗 相关工具 + +- **记忆整理**: `src/memory_graph/manager.py::consolidate_memories()` +- **自动关联**: `src/memory_graph/manager.py::auto_link_memories()` +- **配置验证**: `scripts/verify_config_update.py` + +## 💡 最佳实践 + +### 1. 定期维护流程 + +```bash +# 每周执行 +cd /path/to/bot + +# 1. 备份 +cp -r data/memory_graph data/memory_graph_backup_$(date +%Y%m%d) + +# 2. 预览 +python scripts/deduplicate_memories.py --dry-run --threshold 0.92 + +# 3. 执行 +python scripts/deduplicate_memories.py --threshold 0.92 + +# 4. 验证 +python scripts/verify_config_update.py +``` + +### 2. 保守去重策略 + +```bash +# 只删除极度相似的记忆 +python scripts/deduplicate_memories.py --dry-run --threshold 0.98 +python scripts/deduplicate_memories.py --threshold 0.98 +``` + +### 3. 批量清理策略 + +```bash +# 先清理高相似度的 +python scripts/deduplicate_memories.py --threshold 0.95 + +# 再清理中相似度的(可选) +python scripts/deduplicate_memories.py --dry-run --threshold 0.9 +python scripts/deduplicate_memories.py --threshold 0.9 +``` + +## 📝 总结 + +- ✅ **务必先备份数据** +- ✅ **务必先运行 `--dry-run`** +- ✅ **建议使用阈值 >= 0.92** +- ✅ **定期运行,保持记忆库清洁** +- ❌ **避免过低阈值(< 0.85)** +- ❌ **避免跳过预览直接执行** + +--- + +**创建日期**: 2024-11-06 +**版本**: v1.0 +**维护者**: MoFox-Bot Team diff --git a/scripts/deduplicate_memories.py b/scripts/deduplicate_memories.py new file mode 100644 index 000000000..936fd9014 --- /dev/null +++ b/scripts/deduplicate_memories.py @@ -0,0 +1,404 @@ +""" +记忆去重工具 + +功能: +1. 扫描所有标记为"相似"关系的记忆边 +2. 对相似记忆进行去重(保留重要性高的,删除另一个) +3. 支持干运行模式(预览不执行) +4. 提供详细的去重报告 + +使用方法: + # 预览模式(不实际删除) + python scripts/deduplicate_memories.py --dry-run + + # 执行去重 + python scripts/deduplicate_memories.py + + # 指定相似度阈值 + python scripts/deduplicate_memories.py --threshold 0.9 + + # 指定数据目录 + python scripts/deduplicate_memories.py --data-dir data/memory_graph +""" +import argparse +import asyncio +import sys +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional, Set, Tuple + +import numpy as np + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from src.common.logger import get_logger +from src.memory_graph.manager_singleton import get_memory_manager, initialize_memory_manager, shutdown_memory_manager + +logger = get_logger(__name__) + + +class MemoryDeduplicator: + """记忆去重器""" + + def __init__(self, data_dir: str = "data/memory_graph", dry_run: bool = False, threshold: float = 0.85): + self.data_dir = data_dir + self.dry_run = dry_run + self.threshold = threshold + self.manager = None + + # 统计信息 + self.stats = { + "total_memories": 0, + "similar_pairs": 0, + "duplicates_found": 0, + "duplicates_removed": 0, + "errors": 0, + } + + async def initialize(self): + """初始化记忆管理器""" + logger.info(f"正在初始化记忆管理器 (data_dir={self.data_dir})...") + self.manager = await initialize_memory_manager(data_dir=self.data_dir) + if not self.manager: + raise RuntimeError("记忆管理器初始化失败") + + self.stats["total_memories"] = len(self.manager.graph_store.get_all_memories()) + logger.info(f"✅ 记忆管理器初始化成功,共 {self.stats['total_memories']} 条记忆") + + async def find_similar_pairs(self) -> List[Tuple[str, str, float]]: + """ + 查找所有相似的记忆对(通过向量相似度计算) + + Returns: + [(memory_id_1, memory_id_2, similarity), ...] + """ + logger.info("正在扫描相似记忆对...") + similar_pairs = [] + seen_pairs = set() # 避免重复 + + # 获取所有记忆 + all_memories = self.manager.graph_store.get_all_memories() + total_memories = len(all_memories) + + logger.info(f"开始计算 {total_memories} 条记忆的相似度...") + + # 两两比较记忆的相似度 + for i, memory_i in enumerate(all_memories): + # 每处理10条记忆让出控制权 + if i % 10 == 0: + await asyncio.sleep(0) + if i > 0: + logger.info(f"进度: {i}/{total_memories} ({i*100//total_memories}%)") + + # 获取记忆i的向量(从主题节点) + vector_i = None + for node in memory_i.nodes: + if node.embedding is not None: + vector_i = node.embedding + break + + if vector_i is None: + continue + + # 与后续记忆比较 + for j in range(i + 1, total_memories): + memory_j = all_memories[j] + + # 获取记忆j的向量 + vector_j = None + for node in memory_j.nodes: + if node.embedding is not None: + vector_j = node.embedding + break + + if vector_j is None: + continue + + # 计算余弦相似度 + similarity = self._cosine_similarity(vector_i, vector_j) + + # 只保存满足阈值的相似对 + if similarity >= self.threshold: + pair_key = tuple(sorted([memory_i.id, memory_j.id])) + if pair_key not in seen_pairs: + seen_pairs.add(pair_key) + similar_pairs.append((memory_i.id, memory_j.id, similarity)) + + self.stats["similar_pairs"] = len(similar_pairs) + logger.info(f"找到 {len(similar_pairs)} 对相似记忆(阈值>={self.threshold})") + + return similar_pairs + + def _cosine_similarity(self, vec1: np.ndarray, vec2: np.ndarray) -> float: + """计算余弦相似度""" + try: + vec1_norm = np.linalg.norm(vec1) + vec2_norm = np.linalg.norm(vec2) + + if vec1_norm == 0 or vec2_norm == 0: + return 0.0 + + similarity = np.dot(vec1, vec2) / (vec1_norm * vec2_norm) + return float(similarity) + except Exception as e: + logger.error(f"计算余弦相似度失败: {e}") + return 0.0 + + def decide_which_to_keep(self, mem_id_1: str, mem_id_2: str) -> Tuple[Optional[str], Optional[str]]: + """ + 决定保留哪个记忆,删除哪个 + + 优先级: + 1. 重要性更高的 + 2. 激活度更高的 + 3. 创建时间更早的 + + Returns: + (keep_id, remove_id) + """ + mem1 = self.manager.graph_store.get_memory_by_id(mem_id_1) + mem2 = self.manager.graph_store.get_memory_by_id(mem_id_2) + + if not mem1 or not mem2: + logger.warning(f"记忆不存在: {mem_id_1} or {mem_id_2}") + return None, None + + # 比较重要性 + if mem1.importance > mem2.importance: + return mem_id_1, mem_id_2 + elif mem1.importance < mem2.importance: + return mem_id_2, mem_id_1 + + # 重要性相同,比较激活度 + if mem1.activation > mem2.activation: + return mem_id_1, mem_id_2 + elif mem1.activation < mem2.activation: + return mem_id_2, mem_id_1 + + # 激活度也相同,保留更早创建的 + if mem1.created_at < mem2.created_at: + return mem_id_1, mem_id_2 + else: + return mem_id_2, mem_id_1 + + async def deduplicate_pair(self, mem_id_1: str, mem_id_2: str, similarity: float) -> bool: + """ + 去重一对相似记忆 + + Returns: + 是否成功去重 + """ + keep_id, remove_id = self.decide_which_to_keep(mem_id_1, mem_id_2) + + if not keep_id or not remove_id: + self.stats["errors"] += 1 + return False + + keep_mem = self.manager.graph_store.get_memory_by_id(keep_id) + remove_mem = self.manager.graph_store.get_memory_by_id(remove_id) + + logger.info(f"") + logger.info(f"{'[预览]' if self.dry_run else '[执行]'} 去重相似记忆对 (相似度={similarity:.3f}):") + logger.info(f" 保留: {keep_id}") + logger.info(f" - 主题: {keep_mem.metadata.get('topic', 'N/A')}") + logger.info(f" - 重要性: {keep_mem.importance:.2f}") + logger.info(f" - 激活度: {keep_mem.activation:.2f}") + logger.info(f" - 创建时间: {keep_mem.created_at}") + logger.info(f" 删除: {remove_id}") + logger.info(f" - 主题: {remove_mem.metadata.get('topic', 'N/A')}") + logger.info(f" - 重要性: {remove_mem.importance:.2f}") + logger.info(f" - 激活度: {remove_mem.activation:.2f}") + logger.info(f" - 创建时间: {remove_mem.created_at}") + + if self.dry_run: + logger.info(" [预览模式] 不执行实际删除") + self.stats["duplicates_found"] += 1 + return True + + try: + # 增强保留记忆的属性 + keep_mem.importance = min(1.0, keep_mem.importance + 0.05) + keep_mem.activation = min(1.0, keep_mem.activation + 0.05) + + # 累加访问次数 + if hasattr(keep_mem, 'access_count') and hasattr(remove_mem, 'access_count'): + keep_mem.access_count += remove_mem.access_count + + # 删除相似记忆 + await self.manager.delete_memory(remove_id) + + self.stats["duplicates_removed"] += 1 + logger.info(f" ✅ 删除成功") + + # 让出控制权 + await asyncio.sleep(0) + + return True + + except Exception as e: + logger.error(f" ❌ 删除失败: {e}", exc_info=True) + self.stats["errors"] += 1 + return False + + async def run(self): + """执行去重""" + start_time = datetime.now() + + print("="*70) + print("记忆去重工具") + print("="*70) + print(f"数据目录: {self.data_dir}") + print(f"相似度阈值: {self.threshold}") + print(f"模式: {'预览模式(不实际删除)' if self.dry_run else '执行模式(会实际删除)'}") + print("="*70) + print() + + # 初始化 + await self.initialize() + + # 查找相似对 + similar_pairs = await self.find_similar_pairs() + + if not similar_pairs: + logger.info("未找到需要去重的相似记忆对") + print() + print("="*70) + print("未找到需要去重的记忆") + print("="*70) + return + + # 去重处理 + logger.info(f"开始{'预览' if self.dry_run else '执行'}去重...") + print() + + processed_pairs = set() # 避免重复处理 + + for mem_id_1, mem_id_2, similarity in similar_pairs: + # 检查是否已处理(可能一个记忆已被删除) + pair_key = tuple(sorted([mem_id_1, mem_id_2])) + if pair_key in processed_pairs: + continue + + # 检查记忆是否仍存在 + if not self.manager.graph_store.get_memory_by_id(mem_id_1): + logger.debug(f"记忆 {mem_id_1} 已不存在,跳过") + continue + if not self.manager.graph_store.get_memory_by_id(mem_id_2): + logger.debug(f"记忆 {mem_id_2} 已不存在,跳过") + continue + + # 执行去重 + success = await self.deduplicate_pair(mem_id_1, mem_id_2, similarity) + + if success: + processed_pairs.add(pair_key) + + # 保存数据(如果不是干运行) + if not self.dry_run: + logger.info("正在保存数据...") + await self.manager.persistence.save_graph_store(self.manager.graph_store) + logger.info("✅ 数据已保存") + + # 统计报告 + elapsed = (datetime.now() - start_time).total_seconds() + + print() + print("="*70) + print("去重报告") + print("="*70) + print(f"总记忆数: {self.stats['total_memories']}") + print(f"相似记忆对: {self.stats['similar_pairs']}") + print(f"发现重复: {self.stats['duplicates_found'] if self.dry_run else self.stats['duplicates_removed']}") + print(f"{'预览通过' if self.dry_run else '成功删除'}: {self.stats['duplicates_found'] if self.dry_run else self.stats['duplicates_removed']}") + print(f"错误数: {self.stats['errors']}") + print(f"耗时: {elapsed:.2f}秒") + + if self.dry_run: + print() + print("⚠️ 这是预览模式,未实际删除任何记忆") + print("💡 要执行实际删除,请运行: python scripts/deduplicate_memories.py") + else: + print() + print("✅ 去重完成!") + final_count = len(self.manager.graph_store.get_all_memories()) + print(f"📊 最终记忆数: {final_count} (减少 {self.stats['total_memories'] - final_count} 条)") + + print("="*70) + + async def cleanup(self): + """清理资源""" + if self.manager: + await shutdown_memory_manager() + + +async def main(): + """主函数""" + parser = argparse.ArgumentParser( + description="记忆去重工具 - 对标记为相似的记忆进行一键去重", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +示例: + # 预览模式(推荐先运行) + python scripts/deduplicate_memories.py --dry-run + + # 执行去重 + python scripts/deduplicate_memories.py + + # 指定相似度阈值(只处理相似度>=0.9的记忆对) + python scripts/deduplicate_memories.py --threshold 0.9 + + # 指定数据目录 + python scripts/deduplicate_memories.py --data-dir data/memory_graph + + # 组合使用 + python scripts/deduplicate_memories.py --dry-run --threshold 0.95 --data-dir data/test + """ + ) + + parser.add_argument( + "--dry-run", + action="store_true", + help="预览模式,不实际删除记忆(推荐先运行此模式)" + ) + + parser.add_argument( + "--threshold", + type=float, + default=0.85, + help="相似度阈值,只处理相似度>=此值的记忆对(默认: 0.85)" + ) + + parser.add_argument( + "--data-dir", + type=str, + default="data/memory_graph", + help="记忆数据目录(默认: data/memory_graph)" + ) + + args = parser.parse_args() + + # 创建去重器 + deduplicator = MemoryDeduplicator( + data_dir=args.data_dir, + dry_run=args.dry_run, + threshold=args.threshold + ) + + try: + # 执行去重 + await deduplicator.run() + except KeyboardInterrupt: + print("\n\n⚠️ 用户中断操作") + except Exception as e: + logger.error(f"执行失败: {e}", exc_info=True) + print(f"\n❌ 执行失败: {e}") + return 1 + finally: + # 清理资源 + await deduplicator.cleanup() + + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main()))