diff --git a/.gitignore b/.gitignore index 3ca1422e..debe4266 100644 --- a/.gitignore +++ b/.gitignore @@ -329,3 +329,5 @@ config.toml interested_rates.txt MaiBot.code-workspace +*.lock +actionlint diff --git a/README.md b/README.md index e7d45287..3ee493c2 100644 --- a/README.md +++ b/README.md @@ -50,12 +50,16 @@ 可前往 [Release](https://github.com/MaiM-with-u/MaiBot/releases/) 页面下载最新版本 + 可前往 [启动器发布页面](https://github.com/MaiM-with-u/mailauncher/releases/)下载最新启动器 + +注意,启动器处于早期开发版本,仅支持MacOS + **GitHub 分支说明:** - `main`: 稳定发布版本(推荐) - - `dev`: 开发测试版本(不稳定) + - `classical`: 经典版本(停止维护) ### 最新版本部署教程 @@ -69,7 +73,7 @@ ## 💬 讨论 -**技术交流群:** +**技术交流群/答疑群:** [麦麦脑电图](https://qm.qq.com/q/RzmCiRtHEW) | [麦麦大脑磁共振](https://qm.qq.com/q/VQ3XZrWgMs) | [麦麦要当VTB](https://qm.qq.com/q/wGePTl1UyY) | @@ -79,7 +83,7 @@ **聊天吹水群:** - [麦麦之闲聊群](https://qm.qq.com/q/JxvHZnxyec) - 麦麦相关闲聊群 + 麦麦相关闲聊群,此群仅用于聊天,提问部署/技术问题可能不会快速得到答案 **插件开发/测试版讨论群:** - [插件开发群](https://qm.qq.com/q/1036092828) diff --git a/docs-src/lpmm_parameters_guide.md b/docs-src/lpmm_parameters_guide.md new file mode 100644 index 00000000..aad9d874 --- /dev/null +++ b/docs-src/lpmm_parameters_guide.md @@ -0,0 +1,159 @@ +# LPMM 关键参数调节指南(进阶版) + +> 本文是对 `config/bot_config.toml` 中 `[lpmm_knowledge]` 段的补充说明。 +> 如果你只想使用默认配置,可以不改这些参数,脚本仍然可以正常工作。 +> +> 重要提醒:无论是修改 `[lpmm_knowledge]` 段的参数,还是通过脚本导入 / 删除 LPMM 知识库数据,主程序都需要重启(或在内部调用一次 `lpmm_start_up()`)后,新的参数和知识才会真正生效到聊天侧。 + +所有与 LPMM 相关的参数,都集中在: + +```toml +[lpmm_knowledge] # lpmm知识库配置 +enable = true +lpmm_mode = "agent" +... +``` + +下面按功能将常用参数分为三组介绍。 + +--- + +## 一、检索相关参数(影响答案质量与风格) + +```toml +qa_relation_search_top_k = 10 # 关系检索TopK +qa_relation_threshold = 0.5 # 关系阈值,相似度高于该值才认为“命中关系” +qa_paragraph_search_top_k = 1000 # 段落检索TopK,越小可能影响召回 +qa_paragraph_node_weight = 0.05 # 段落节点权重,在图检索&PPR中的权重 +qa_ent_filter_top_k = 10 # 实体过滤TopK +qa_ppr_damping = 0.8 # PPR阻尼系数 +qa_res_top_k = 3 # 最终提供给问答模型的段落数 +``` + +- `qa_relation_search_top_k` + 控制“最多考虑多少条关系向量候选”。 + - 数值大:召回更全面,但略慢; + - 数值小:更快,可能遗漏部分隐含关系。 + +- `qa_relation_threshold` + 关系相似度的阈值: + - 数值高:只信任非常相关的关系,系统更可能退化为纯段落向量检索; + - 数值低:图结构影响更大,适合实体关系较丰富的场景。 + +- `qa_paragraph_search_top_k` + 控制“最多考虑多少段落候选”。 + - 太小:可能召回不全,导致答案缺失; + - 太大:略微增加计算量,一般 1000 为安全默认。 + +- `qa_paragraph_node_weight` + 文段节点在图检索中的权重: + - 数值大:更依赖段落向量相似度(传统向量检索); + - 数值小:更依赖图结构和实体网络。 + +- `qa_ppr_damping` + Personalized PageRank 的阻尼系数: + - 通常保持在 0.8 左右即可; + - 越接近 1:偏向长路径探索,结果更发散; + - 略低:更集中在与问题直接相关的节点附近。 + +- `qa_res_top_k` + LPMM 最终会把相关度最高的前 `qa_res_top_k` 条段落组合成“知识上下文”给问答模型。 + - 太多:增加模型负担、阅读更多文字; + - 太少:信息不够充分,一般 3–5 比较平衡。 + +> 调参建议: +> - 优先在 `qa_relation_threshold`、`qa_paragraph_node_weight` 上做小幅调整; +> - 每次调整后,用 `scripts/test_lpmm_retrieval.py` 跑一遍固定问题,感受回答变化。 + +--- + +## 二、性能与硬件相关参数 + +```toml +embedding_dimension = 1024 # 嵌入向量维度,应与模型输出维度一致 +max_embedding_workers = 12 # 嵌入/抽取并发线程数 +embedding_chunk_size = 16 # 每批嵌入的条数 +info_extraction_workers = 3 # 实体抽取同时执行线程数 +enable_ppr = true # 是否启用PPR,低配机器可关闭 +ppr_node_cap = 8000 # 图节点数超过该值时自动跳过PPR +``` + +- `embedding_dimension` + 必须与所选嵌入模型的输出维度一致(比如 768、1024 等)。**不要随意修改,除非你知道你在做什么!!!** + +- `max_embedding_workers` + 决定导入/抽取阶段的并行线程数: + - 机器配置好:可以适当调大,加快导入速度; + - 机器配置弱:建议调低(如 2 或 4),避免 CPU 长时间 100%。 + +- `embedding_chunk_size` + 每批发送给嵌入 API 的段落数量: + - 数值大:请求次数少,但单次请求更“重”; + - 数值小:请求次数多,但对网络和 API 的单次压力小。 + +- `info_extraction_workers` + `scripts/info_extraction.py` 中实体抽取的并行线程数: + - 使用 Pro/贵价模型时建议不要太大,避免并行费用过高; + - 一般 2–4 就能取得较好平衡。 + +- `enable_ppr` + 是否启用个性化 PageRank(PPR)图检索: + - `true`:检索会结合向量+知识图,效果更好,但略慢; + - `false`:只用向量检索,牺牲一定效果,性能更稳定。 + +- `ppr_node_cap` + 安全阈值:当图节点数超过阈值时自动跳过 PPR,以避免“大图”导致卡顿。 + +> 调参建议: +> - 若导入/检索阶段机器明显“顶不住”(>=1MB的大文本,且分配配置<4C),优先调低: +> - `max_embedding_workers` +> - `embedding_chunk_size` +> - `info_extraction_workers` +> - 或暂时将 `enable_ppr = false` (除非真的出现问题,否则不建议禁用此项,大幅影响检索效果) +> - 调整后重新执行导入或检索,观察日志与系统资源占用。 + +> 小提示:每次大改参数或批量删除知识后,建议用 +> - `scripts/test_lpmm_retrieval.py` 看回答风格是否如预期; +> - 如需确认当前磁盘数据能否正常初始化,可执行 `scripts/refresh_lpmm_knowledge.py` 做一次快速自检。 + +--- + +## 三、开启/关闭 LPMM 与模式说明 + +```toml +enable = true # 是否开启lpmm知识库 +lpmm_mode = "agent" # 可选 classic / agent +``` + +- `enable` + - `true`:LPMM 知识库启用,检索和问答会使用知识库; + - `false`:LPMM 完全关闭,脚本仍可导入/删除数据,但对聊天问答不生效。 + +- `lpmm_mode` + - `classic`:传统模式,仅使用 LPMM 知识库本身; + - `agent`:与新的记忆系统联动,用于更复杂的记忆+知识混合场景。 + +> 修改 `enable` 或 `lpmm_mode` 后,需要重启主程序,让配置生效。 + +--- + +## 四、推荐的调参流程 + +1. **保持默认配置,先跑一轮完整流程** + - 导入 → `inspect_lpmm_global.py` → `test_lpmm_retrieval.py`; + - 记录当前“答案风格”和“响应速度”。 + +2. **每次只调整一到两个参数** + - 例如先调 `qa_relation_threshold`、`qa_paragraph_node_weight`; + - 或在性能不佳时调整 `max_embedding_workers`、`enable_ppr`。 + +3. **调整后重复同一组测试问题** + - 使用 `scripts/test_lpmm_retrieval.py`; + - 对比不同配置下的答案,选择更符合需求的组合。 + +4. **出现“怎么调都不对”时** + - 将 `[lpmm_knowledge]` 段恢复为仓库中的默认配置; + - 重启主程序,即可回到“出厂设置”。 + +通过本指南中的参数调节,你可以在“检索质量”“响应速度”“系统资源占用”之间找到适合自己麦麦和机器的平衡点! + diff --git a/docs-src/lpmm_pipelines_guide.md b/docs-src/lpmm_pipelines_guide.md new file mode 100644 index 00000000..6539a2fc --- /dev/null +++ b/docs-src/lpmm_pipelines_guide.md @@ -0,0 +1,326 @@ +## LPMM 知识库流水线使用指南(命令行版) + +本文档介绍如何使用 `scripts/lpmm_manager.py` 及相关子脚本,完成 **导入 / 删除 / 自检 / 刷新 / 回归测试** 等常见流水线操作,并说明各参数在交互式与非交互(脚本化)场景下的用法。 + +所有命令均假设在项目根目录 `MaiBot/` 下执行: + +```bash +cd MaiBot +``` + +--- + +## 1. 管理脚本总览:`scripts/lpmm_manager.py` + +### 1.1 基本用法 + +```bash +python scripts/lpmm_manager.py [--interactive] [-a ACTION] [--non-interactive] [-- ...子脚本参数...] +``` + +- `--interactive` / `-i`:进入交互式菜单模式(推荐人工运维时使用)。 +- `--action` / `-a`:直接执行指定操作(非交互入口),可选值: + - `prepare_raw`:预处理 `data/lpmm_raw_data/*.txt`。 + - `info_extract`:信息抽取,生成 OpenIE JSON 批次。 + - `import_openie`:导入 OpenIE 批次到向量库与知识图。 + - `delete`:删除/回滚知识(封装 `delete_lpmm_items.py`)。 + - `batch_inspect`:检查指定 OpenIE 批次的存在情况。 + - `global_inspect`:全库状态统计。 + - `refresh`:刷新 LPMM 磁盘数据到内存。 + - `test`:检索效果回归测试。 + - `full_import`:一键执行「预处理原始语料 → 信息抽取 → 导入 → 刷新」。 +- `--non-interactive`: + - 启用 **非交互模式**:`lpmm_manager` 自身不会再调用 `input()` 询问确认; + - 同时自动向子脚本透传 `--non-interactive`(若子脚本支持),用于在 CI / 定时任务中实现无人值守。 +- `--` 之后的内容会原样传递给对应子脚本的 `main()`,用于设置更细粒度参数。 + +> 注意:`--interactive` 与 `--non-interactive` 互斥,不能同时使用。 + +--- + +## 2. 典型流水线一:全量导入(从原始 txt 到可用 LPMM) + +### 2.1 前置条件 + +- 将待导入的原始文本放入: + +```text +data/lpmm_raw_data/*.txt +``` + +- 文本按「空行分段」,每个段落为一条候选知识。 + +### 2.2 一键全流程(交互式) + +```bash +python scripts/lpmm_manager.py --interactive +``` + +菜单中依次: + +1. 选择 `9. full_import`(预处理 → 信息抽取 → 导入 → 刷新)。 +2. 按提示确认可能的费用与时间消耗。 +3. 等待脚本执行完成。 + +### 2.3 一键全流程(非交互 / CI 友好) + +```bash +python scripts/lpmm_manager.py -a full_import --non-interactive +``` + +执行顺序: + +1. `prepare_raw`:调用 `raw_data_preprocessor.load_raw_data()`,统计段落与去重哈希数。 +2. `info_extract`:调用 `info_extraction.main(--non-interactive)`,从 `data/lpmm_raw_data` 读取段落,生成 OpenIE JSON 并写入 `data/openie/`。 +3. `import_openie`:调用 `import_openie.main(--non-interactive)`,导入 OpenIE 批次到嵌入库与 KG。 +4. `refresh`:调用 `refresh_lpmm_knowledge.main()`,刷新 LPMM 知识库到内存。 + +在 `--non-interactive` 模式下: + +- 若 `data/lpmm_raw_data` 中没有 `.txt` 文件,或 `data/openie` 中没有 `.json` 文件,将直接报错退出,并在日志中说明缺少的目录/文件。 +- 若 OpenIE 批次中存在非法文段,导入脚本会 **直接报错退出**,不会卡在交互确认上。 + +--- + +## 3. 典型流水线二:分步导入 + +若需要逐步调试或只执行部分步骤,可以分开调用: + +### 3.1 预处理原始语料:`prepare_raw` + +```bash +python scripts/lpmm_manager.py -a prepare_raw +``` + +行为: +- 使用 `raw_data_preprocessor.load_raw_data()` 读取 `data/lpmm_raw_data/*.txt`; +- 输出段落总数与去重后的哈希数,供人工检查原始数据质量。 + +### 3.2 信息抽取:`info_extract` + +#### 交互式(带费用提示) + +```bash +python scripts/lpmm_manager.py -a info_extract +``` + +脚本会: +- 打印预计费用/时间提示; +- 询问 `确认继续执行?(y/n)`; +- 然后开始从 `data/lpmm_raw_data` 中读取段落,调用 LLM 提取实体与三元组,并生成 OpenIE JSON。 + +#### 非交互式(无人工确认) + +```bash +python scripts/lpmm_manager.py -a info_extract --non-interactive +``` + +行为差异: +- 跳过`确认继续执行`的交互提示,直接开始抽取; +- 若 `data/lpmm_raw_data` 下没有 `.txt` 文件,会打印告警并以错误方式退出。 + +### 3.3 导入 OpenIE 批次:`import_openie` + +#### 交互式 + +```bash +python scripts/lpmm_manager.py -a import_openie +``` + +脚本会: +- 提示导入开销与资源占用情况; +- 询问是否继续; +- 调用 `OpenIE.load()` 加载批次,再将其导入嵌入库与 KG。 + +#### 非交互式 + +```bash +python scripts/lpmm_manager.py -a import_openie --non-interactive +``` + +- 跳过导入开销确认; +- 若数据存在非法文段: + - 在交互模式下会询问是否删除这些非法文段并继续; + - 在非交互模式下,会直接 `logger.error` 并 `sys.exit(1)`,防止导入不完整数据。 + +> 提示:当前 `OpenIE.load()` 仍可能在内部要求你选择具体批次文件,若需完全无交互的导入,可后续扩展为显式指定文件路径。 + +### 3.4 刷新 LPMM 知识库:`refresh` + +```bash +python scripts/lpmm_manager.py -a refresh +# 或 +python scripts/lpmm_manager.py -a refresh --non-interactive +``` + +两者行为相同: +- 调用 `refresh_lpmm_knowledge.main()`,内部执行 `lpmm_start_up()`; +- 日志中输出当前向量与 KG 规模,验证导入是否成功。 + +--- + +## 4. 典型流水线三:删除 / 回滚 + +删除操作通过 `lpmm_manager.py -a delete` 封装 `scripts/delete_lpmm_items.py`。 + +### 4.1 交互式删除(推荐人工操作) + +```bash +python scripts/lpmm_manager.py --interactive +``` + +菜单中选择: + +1. `4. delete - 删除/回滚知识` +2. 再选择删除方式: + - 按哈希文件(`--hash-file`) + - 按 OpenIE 批次(`--openie-file`) + - 按原始语料 + 段落索引(`--raw-file + --raw-index`) + - 按关键字搜索现有段落(`--search-text`) +3. 管理脚本会根据你的选择自动拼好常用参数(是否删除实体/关系、是否删除孤立实体、是否 dry-run、是否自动确认等),最后调用 `delete_lpmm_items.py` 执行。 + +### 4.2 非交互删除(CI / 脚本场景) + +#### 示例:按哈希文件删除(带完整保护参数) + +```bash +python scripts/lpmm_manager.py -a delete --non-interactive -- \ + --hash-file data/lpmm_delete_hashes.txt \ + --delete-entities \ + --delete-relations \ + --remove-orphan-entities \ + --max-delete-nodes 2000 \ + --yes +``` + +- `--non-interactive`(manager):禁止任何 `input()` 询问; +- 子脚本 `delete_lpmm_items.py` 中: + - `--hash-file`:指定待删段落哈希列表; + - `--delete-entities` / `--delete-relations` / `--remove-orphan-entities`:同步清理实体与关系; + - `--max-delete-nodes`:单次删除节点数上限,避免误删过大规模; + - `--yes`:跳过终极确认,适合已验证的自动流水线。 + +#### 按 OpenIE 批次删除(常用于批次回滚) + +```bash +python scripts/lpmm_manager.py -a delete --non-interactive -- \ + --openie-file data/openie/2025-01-01-12-00-openie.json \ + --delete-entities \ + --delete-relations \ + --remove-orphan-entities \ + --yes +``` + +### 4.3 非交互模式下的安全限制 + +在 `delete_lpmm_items.py` 中: + +- 若使用 `--search-text`,需要用户通过输入序号选择要删条目; + - 在 `--non-interactive` 模式下,这一步会直接报错退出,提示改用 `--hash-file / --openie-file / --raw-file` 等纯参数方式。 +- 若未指定 `--yes`: + - 非交互模式下会报错退出,提示「非交互模式且未指定 --yes,出于安全考虑删除操作已被拒绝」。 + +--- + +## 5. 典型流水线四:自检与状态检查 + +### 5.1 检查指定 OpenIE 批次状态:`batch_inspect` + +```bash +python scripts/lpmm_manager.py -a batch_inspect -- --openie-file data/openie/xx.json +``` + +输出该批次在当前库中的: +- 段落向量数量 / KG 段落节点数量; +- 实体向量数量 / KG 实体节点数量; +- 关系向量数量; +- 少量仍存在的样例内容。 + +常用于: +- 导入后确认是否完全成功; +- 删除后确认是否完全回滚。 + +### 5.2 查看整库状态:`global_inspect` + +```bash +python scripts/lpmm_manager.py -a global_inspect +``` + +输出: +- 段落 / 实体 / 关系向量条数; +- KG 节点/边总数,段落节点数、实体节点数; +- 实体计数表 `ent_appear_cnt` 的条目数; +- 少量剩余段落/实体样例,便于快速 sanity check。 + +--- + +## 6. 典型流水线五:检索效果回归测试 + +### 6.1 使用默认测试用例 + +```bash +python scripts/lpmm_manager.py -a test +``` + +- 调用 `test_lpmm_retrieval.py` 内置的 `DEFAULT_TEST_CASES`; +- 对每条用例输出: + - 原始结果; + - 状态(`PASS` / `WARN` / `NO_HIT` / `ERROR`); + - 期望关键字与命中关键字列表。 + +### 6.2 自定义测试问题与期望关键字 + +```bash +python scripts/lpmm_manager.py -a test -- --query "LPMM 是什么?" \ + --expect-keyword 哈希列表 \ + --expect-keyword 删除脚本 +``` + +也可以直接调用子脚本: + +```bash +python scripts/test_lpmm_retrieval.py \ + --query "LPMM 是什么?" \ + --expect-keyword 哈希列表 \ + --expect-keyword 删除脚本 +``` + +--- + +## 7. 推荐组合示例 + +### 7.1 导入 + 刷新 + 简单回归 + +```bash +# 1. 执行全量导入(支持非交互) +python scripts/lpmm_manager.py -a full_import --non-interactive + +# 2. 使用内置用例做一次检索回归 +python scripts/lpmm_manager.py -a test +``` + +### 7.2 批次回滚 + 自检 + +```bash +TARGET_BATCH=data/openie/2025-01-01-12-00-openie.json + +# 1. 按批次删除(非交互) +python scripts/lpmm_manager.py -a delete --non-interactive -- \ + --openie-file "$TARGET_BATCH" \ + --delete-entities \ + --delete-relations \ + --remove-orphan-entities \ + --yes + +# 2. 检查该批次是否彻底删除 +python scripts/lpmm_manager.py -a batch_inspect -- --openie-file "$TARGET_BATCH" + +# 3. 查看全库状态 +python scripts/lpmm_manager.py -a global_inspect +``` + +--- + +如需扩展更多流水线(例如「导入特定批次后自动跑自定义测试用例」),可以在 `scripts/lpmm_manager.py` 中新增对应的 `ACTION_INFO` 条目和 `run_action` 分支,或直接在 CI / shell 脚本中串联上述命令。该管理脚本已支持参数化与非交互调用,适合作为二次封装的基础入口。 + + diff --git a/docs-src/lpmm_user_guide.md b/docs-src/lpmm_user_guide.md new file mode 100644 index 00000000..147ebcab --- /dev/null +++ b/docs-src/lpmm_user_guide.md @@ -0,0 +1,411 @@ +# LPMM 知识库脚本使用指南(零基础用户版) + +本指南面向不熟悉命令行和代码的 C 端用户,帮助你完成: + +- LPMM 知识库的初始部署(从本地 txt 到可检索知识库) +- 安全删除知识(按批次、按原文、按哈希、按关键字) +- 导入 / 删除后的自检与检索效果验证 + +> 说明:本文默认你已经完成 MaiBot 的基础安装,并能在项目根目录打开命令行终端。 +> 重要提醒:每次使用导入 / 删除相关脚本(如 `import_openie.py`、`delete_lpmm_items.py`)修改 LPMM 知识库后,聊天机器人 / WebUI 端要想看到最新知识,需要重启主程序,或在主程序内部显式调用一次 `lpmm_start_up()` 重新初始化 LPMM + +--- +。 + + +## 一、需要用到的脚本一览 + +在项目根目录(`MaiBot-dev`)下,这些脚本是 LPMM 相关的“工具箱”: + +- 导入相关: + - `scripts/raw_data_preprocessor.py` + 从 `data/lpmm_raw_data` 目录读取 `.txt` 文件,按空行拆分为一个个段落,并做去重。 + - `scripts/info_extraction.py` + 调用大模型,从每个段落里抽取实体和三元组,生成中间的 OpenIE JSON 文件。 + - `scripts/import_openie.py` + 把 `data/openie` 目录中的 OpenIE JSON 文件导入到 LPMM 知识库(向量库 + 知识图)。 +- 删除相关: + - `scripts/delete_lpmm_items.py` + LPMM 知识库删除入口,支持按批次、按原始文本段落、按哈希列表、按关键字模糊搜索删除。 +- 自检相关: + - `scripts/inspect_lpmm_global.py` + 查看整个知识库的当前状态:段落/实体/关系条数、知识图节点/边数量、示例内容等。 + - `scripts/inspect_lpmm_batch.py` + 针对某个 OpenIE JSON 批次,检查它在向量库和知识图中的“残留情况”(导入与删除前后对比)。 + - `scripts/test_lpmm_retrieval.py` + 使用几条预设问题测试 LPMM 检索能力,帮助你判断知识库是否正常工作。 + - `scripts/refresh_lpmm_knowledge.py` + 手动重新加载 `data/embedding` 和 `data/rag` 到内存,用来确认当前磁盘上的 LPMM 知识库能正常初始化。 + +> 注意:所有命令示例都假设你已经在虚拟环境中,命令行前缀类似 `(.venv)`,并且当前目录是项目根目录。 + +--- + +## 二、LPMM 知识库的初始部署 + +### 2.1 准备原始 txt 文本 + +1. 把要导入的知识文档放到: + + ```text + data/lpmm_raw_data + ``` + +2. 文件要求: + + - 必须是 `.txt` 文件,建议使用 UTF-8 编码; + - 用**空行**分隔段落:一段话后空一行,即视为一条独立知识。 + +示例文件: + +- `data/lpmm_raw_data/lpmm_large_sample.txt`:仓库内已经提供了一份大样本测试文本,可以直接用来练习。 + +### 2.2 第一步:预处理原始文本(拆段 + 去重) + +在项目根目录执行: + +```bash +.\.venv\Scripts\python.exe scripts/raw_data_preprocessor.py +``` + +成功时通常会看到日志类似: + +- 正在处理文件: `lpmm_large_sample.txt` +- 共读取到 XX 条数据 + +这一步不会调用大模型,仅做拆段和去重。 + +### 2.3 第二步:进行信息抽取(生成 OpenIE JSON) + +执行: + +```bash +.\.venv\Scripts\python.exe scripts/info_extraction.py +``` + +你会看到一个“重要操作确认”提示,说明: + +- 信息抽取会调用大模型,消耗 API 费用和时间; +- 如果确认无误,输入 `y` 回车继续。 + +提取过程中可能出现: + +- 类似“模型 ... 网络错误(可重试)”这样的日志; + 这表示脚本在遇到网络问题时自动重试,一般无需手动干预。 + +运行结束后,会有类似提示: + +```text +信息提取结果已保存到: data/openie/11-27-10-06-openie.json +``` + +- 请记住这个文件名,比如:`11-27-10-06-openie.json` + 接下来我们会用 `` 来代指这类文件。 + +### 2.4 第三步:导入 OpenIE 数据到 LPMM 知识库 + +执行: + +```bash +.\.venv\Scripts\python.exe scripts/import_openie.py +``` + +这个脚本会: + +- 从 `data/openie` 目录读取所有 `*.json` 文件,并合并导入; +- 将新段落的嵌入向量写入 `data/embedding`; +- 将三元组构建为知识图写入 `data/rag`。 + +> 提示:如果你希望“只导入某几批数据”,可以暂时把不需要的 JSON 文件移出 `data/openie`,导入结束后再移回。 + +### 2.5 第四步:全局自检(确认导入成功) + +执行: + +```bash +.\.venv\Scripts\python.exe scripts/inspect_lpmm_global.py +``` + +你会看到类似输出: + +- 段落向量条数: `52` +- 实体向量条数: `260` +- 关系向量条数: `299` +- KG 节点总数 / 边总数 / 段落节点数 / 实体节点数 +- 若干条示例段落与实体内容预览 + +只要这些数字大于 0,就表示 LPMM 知识库已经有可用的数据了。 + +### 2.6 第五步:用脚本测试 LPMM 检索效果(可选但推荐) + +执行: + +```bash +.\.venv\Scripts\python.exe scripts/test_lpmm_retrieval.py +``` + +脚本会: + +- 自动初始化 LPMM(加载向量库与知识图); +- 用几条预设问题查询 LPMM; +- 打印原始检索结果和关键词命中情况。 + +你可以通过观察“RAW RESULT”里的内容,粗略判断: + +- 能否命中与问题高度相关的知识; +- 删除或导入新知识后,回答内容是否发生变化。 + +--- + +## 三、安全删除知识的几种方式 + +> 强烈建议:删除前先备份以下目录,以便“回档”: +> +> - `data/embedding`(向量库) +> - `data/rag`(知识图) + +所有删除操作使用同一个脚本: + +```bash +.\.venv\Scripts\python.exe scripts/delete_lpmm_items.py [参数...] +``` + +脚本特点: + +- 删除前会打印“待删除段落数量 / 实体数量 / 关系数量 / 预计删除节点数”等摘要; +- 需要你输入大写 `YES` 确认才会真正执行; +- 支持多种删除策略,可灵活组合。 + +### 3.1 按批次删除(推荐:整批回滚) + +适用场景:某次导入的整批知识有问题,希望整体回滚。 + +1. 删除前,先检查该批次状态: + + ```bash + .\.venv\Scripts\python.exe scripts/inspect_lpmm_batch.py ^ + --openie-file data/openie/.json + ``` + + 你会看到该批次: + + - 段落:总计多少条、向量库剩余多少、KG 中剩余多少; + - 实体、关系的类似统计; + - 少量示例段落/实体内容预览。 + +2. 确认无误后,按批次删除: + + ```bash + .\.venv\Scripts\python.exe scripts/delete_lpmm_items.py ^ + --openie-file data/openie/.json ^ + --delete-entities --delete-relations --remove-orphan-entities + ``` + + 参数含义: + + - `--delete-entities`:删除该批次涉及的实体向量; + - `--delete-relations`:删除该批次涉及的关系向量; + - `--remove-orphan-entities`:顺带清理删除后不再参与任何边的“孤立实体”节点。 + +3. 删除后再检查: + + ```bash + .\.venv\Scripts\python.exe scripts/inspect_lpmm_batch.py ^ + --openie-file data/openie/.json + + .\.venv\Scripts\python.exe scripts/inspect_lpmm_global.py + ``` + + 若批次检查显示“向量库剩余 0 / KG 中剩余 0”,则说明该批次已被彻底删除。 + +### 3.2 按原始文本段落删除(精确定位某一段) + +适用场景:某个原始 txt 的特定段落写错了,只想删这段对应的知识。 + +命令示例: + +```bash +.\.venv\Scripts\python.exe scripts/delete_lpmm_items.py ^ + --raw-file data/lpmm_raw_data/lpmm_large_sample.txt ^ + --raw-index 2 +``` + +说明: + +- `--raw-index` 从 1 开始计数,可用逗号多选,例如:`1,3,5`; +- 脚本会展示该段落的内容预览和哈希值,再请求你确认。 + +### 3.3 按哈希列表删除(进阶用法) + +适用场景:你有一份“需要删除的段落哈希列表”(比如从其他系统导出)。 + +示例哈希列表文件: + +- `data/openie/lpmm_delete_test_hashes.txt` + +命令: + +```bash +.\.venv\Scripts\python.exe scripts/delete_lpmm_items.py ^ + --hash-file data/openie/lpmm_delete_test_hashes.txt +``` + +说明: + +- 文件中每行一条,可以是 `paragraph-xxxx` 或纯哈希,脚本会自动识别; +- 适合“精确控制删除哪些段落”,但准备哈希列表需要一定技术基础。 + +### 3.4 按关键字模糊搜索删除(对非技术用户最友好) + +适用场景:只知道某段话里包含某个关键词,不知道它在哪个 txt 或批次里。 + +示例 1:删除与“近义词扩展”相关的段落 + +```bash +.\.venv\Scripts\python.exe scripts/delete_lpmm_items.py --search-text "近义词扩展" --search-limit 5 +``` + +示例 2:删除与“LPMM”强相关的一些段落 + +```bash +.\.venv\Scripts\python.exe scripts/delete_lpmm_items.py --search-text "LPMM" --search-limit 20 + +``` + +执行过程: + +1. 脚本在当前段落库中查找包含该关键字的段落; +2. 列出前 N 条候选(`--search-limit` 决定数量); +3. 提示你输入要删除的序号列表,例如:`1,2,5`; +4. 再次提示你输入 `YES` 确认,才会真正执行删除。 + +> 建议: +> +> - 第一次使用时可以先加 `--dry-run` 看看效果: +> ```bash +> .\.venv\Scripts\python.exe scripts/delete_lpmm_items.py ^ +> --search-text "LPMM" ^ +> --search-limit 20 ^ +> --dry-run +> ``` +> - 确认候选列表确实是你要删的内容后,再去掉 `--dry-run` 正式执行。 + +--- + +## 四、自检:如何确认导入 / 删除是否“生效” + +### 4.1 全局状态检查 + +每次导入或删除之后,建议跑一次: + +```bash +.\.venv\Scripts\python.exe scripts/inspect_lpmm_global.py +``` + +你可以在这里看到: + +- 段落向量条数、实体向量条数、关系向量条数; +- 知识图的节点总数、边总数、段落节点和实体节点数量; +- 若干条“剩余段落示例”和“剩余实体示例”。 + +观察方式: + +- 导入后:数字应该明显上升(说明新增数据生效); +- 删除后:数字应该明显下降(说明删除操作生效)。 + +### 4.2 某个批次的局部状态 + +如果你想确认“某一个 OpenIE 文件对应的那一批知识”是否存在,可以使用: + +```bash +.\.venv\Scripts\python.exe scripts/inspect_lpmm_batch.py --openie-file data/openie/.json +``` + +输出中会包含: + +- 该批次的段落 / 实体 / 关系的总数; +- 在向量库中还剩多少条,在 KG 中还剩多少条; +- 若干条仍存在的段落/实体示例。 + +典型用法: + +- 导入后立刻检查一次:确认这一批已经“写入”; +- 删除后再检查一次:确认这一批是否已经“清空”。 + +### 4.3 检索效果回归测试 + +每次做完导入或删除,你都可以用这条命令快速验证检索效果: + +```bash +.\.venv\Scripts\python.exe scripts/test_lpmm_retrieval.py +``` + +它会: + +- 初始化 LPMM(加载当前向量库和知识图); +- 用几条预设问题(包括与 LPMM 和配置相关的问题)进行检索; +- 打印检索结果以及命中关键词情况。 + +通过对比不同时间点的输出,你可以判断: + +- 某些知识是否已经被成功删除(不再出现在回答中); + +- 新增的知识是否已经能被检索到。 + +### 4.4 进阶:一键刷新(可选) + +- 想简单确认“现在这份 data/embedding + data/rag 是否健康”?执行: + + `.\.venv\Scripts\python.exe scripts/refresh_lpmm_knowledge.py ` + + 它会尝试初始化 LPMM,并打印当前段落/实体/关系条数和图大小。 + + + + + +--- + +## 五、常见提示与注意事项 + +1. **看到“网络错误(可重试)”需要担心吗?** + + - 不需要。 + - 这些日志说明脚本在自动处理网络抖动,多数情况下会在重试后成功返回结果。 + - 只要脚本最后没有报“重试耗尽并退出”,一般导入/提取结果是有效的。 + +2. **删除操作会不会“一删全没”?** + + - 不会直接“一删全没”: + - 每次删除会打印摘要信息; + - 必须输入 `YES` 才会真正执行; + - 大批次时还有 `--max-delete-nodes` 保护,超过阈值会警告。 + - 但仍然建议: + - 在大规模删除前备份 `data/embedding` 和 `data/rag`; + - 先通过 `--dry-run` 看看待删列表。 + +3. **可以多次导入吗?需要先清空吗?** + + - 可以多次导入,系统会根据段落内容的哈希做去重; + - 不需要每次都清空,只要你希望老数据仍然保留即可; + - 如果你确实想“重来一遍”,可以: + - 先备份,然后删除 `data/embedding` 和 `data/rag`; + - 再重新跑导入流程。 + +4. **LPMM 开关在哪里?** + + - 配置文件:`config/bot_config.toml`; + - 小节:`[lpmm_knowledge]`; + - 其中有 `enable = true/false` 开关: + - 为 `true`:LPMM 知识库启用,问答时会使用; + - 为 `false`:LPMM 关闭,即使知识库有数据,也不会参与回答。 + - 修改后需要重启主程序,让设置生效。 + +--- + +如果你是普通用户,只需要记住一句话: + +> “导入三步走:预处理 → 信息抽取 → 导入 OpenIE; +> 删除三步走:先检查 → 再删除 → 然后再检查。” + +照着本指南中的命令一步一步执行,就可以安全地管理你的 LPMM 知识库。*** diff --git a/scripts/delete_lpmm_items.py b/scripts/delete_lpmm_items.py new file mode 100644 index 00000000..2eb37ded --- /dev/null +++ b/scripts/delete_lpmm_items.py @@ -0,0 +1,386 @@ +import argparse +import sys +from pathlib import Path +from typing import List, Tuple, Dict, Any +import json +import os + +# 强制使用 utf-8,避免控制台编码报错 +try: + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(encoding="utf-8") +except Exception: + pass + +# 确保能找到 src 包 +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from src.chat.knowledge.embedding_store import EmbeddingManager +from src.chat.knowledge.kg_manager import KGManager +from src.common.logger import get_logger +from src.chat.knowledge.utils.hash import get_sha256 + +logger = get_logger("delete_lpmm_items") + + +def read_hashes(file_path: Path) -> List[str]: + """读取哈希列表,跳过空行""" + hashes: List[str] = [] + for line in file_path.read_text(encoding="utf-8").splitlines(): + val = line.strip() + if not val: + continue + hashes.append(val) + return hashes + + +def read_openie_hashes(file_path: Path) -> List[str]: + """从 OpenIE JSON 中提取 idx 作为段落哈希""" + data: Dict[str, Any] = json.loads(file_path.read_text(encoding="utf-8")) + docs = data.get("docs", []) if isinstance(data, dict) else [] + hashes: List[str] = [] + for doc in docs: + idx = doc.get("idx") if isinstance(doc, dict) else None + if isinstance(idx, str) and idx.strip(): + hashes.append(idx.strip()) + return hashes + + +def normalize_paragraph_keys(raw_hashes: List[str]) -> Tuple[List[str], List[str]]: + """将输入规范为完整键和纯哈希两份列表""" + keys: List[str] = [] + hashes: List[str] = [] + for h in raw_hashes: + if h.startswith("paragraph-"): + keys.append(h) + hashes.append(h.replace("paragraph-", "", 1)) + else: + keys.append(f"paragraph-{h}") + hashes.append(h) + return keys, hashes + + +def main(): + parser = argparse.ArgumentParser(description="Delete paragraphs from LPMM knowledge base (vectors + graph).") + parser.add_argument("--hash-file", help="文本文件路径,每行一个 paragraph 哈希或带前缀键") + parser.add_argument("--openie-file", help="OpenIE 输出文件(JSON),将其 docs.idx 作为待删段落哈希") + parser.add_argument("--raw-file", help="原始 txt 语料文件(按空行分段),可结合 --raw-index 使用") + parser.add_argument( + "--raw-index", + help="在 --raw-file 中要删除的段落索引,1 基,支持逗号分隔,例如 1,3", + ) + parser.add_argument("--search-text", help="在当前段落库中按子串搜索匹配段落并交互选择删除") + parser.add_argument( + "--search-limit", + type=int, + default=10, + help="--search-text 模式下最多展示的候选段落数量", + ) + parser.add_argument("--delete-entities", action="store_true", help="同时删除 OpenIE 文件中的实体节点/嵌入") + parser.add_argument("--delete-relations", action="store_true", help="同时删除 OpenIE 文件中的关系嵌入") + parser.add_argument("--remove-orphan-entities", action="store_true", help="删除删除后孤立的实体节点") + parser.add_argument("--dry-run", action="store_true", help="仅预览将删除的项,不实际修改") + parser.add_argument("--yes", action="store_true", help="跳过交互确认,直接执行删除(谨慎使用)") + parser.add_argument( + "--max-delete-nodes", + type=int, + default=2000, + help="单次最大允许删除的节点数量(段落+实体),超过则需要显式确认或调整该参数", + ) + parser.add_argument( + "--non-interactive", + action="store_true", + help=( + "非交互模式:不再通过 input() 询问任何信息;" + "在该模式下,如果需要交互(例如 --search-text 未指定具体条目、未提供 --yes)," + "会直接报错退出。" + ), + ) + args = parser.parse_args() + + # 至少需要一种来源 + if not (args.hash_file or args.openie_file or args.raw_file or args.search_text): + logger.error("必须指定 --hash-file / --openie-file / --raw-file / --search-text 之一") + sys.exit(1) + + raw_hashes: List[str] = [] + raw_entities: List[str] = [] + raw_relations: List[str] = [] + + if args.hash_file: + hash_file = Path(args.hash_file) + if not hash_file.exists(): + logger.error(f"哈希文件不存在: {hash_file}") + sys.exit(1) + raw_hashes.extend(read_hashes(hash_file)) + + if args.openie_file: + openie_path = Path(args.openie_file) + if not openie_path.exists(): + logger.error(f"OpenIE 文件不存在: {openie_path}") + sys.exit(1) + # 段落 + raw_hashes.extend(read_openie_hashes(openie_path)) + # 实体/关系(实体同时包含 extracted_entities 与三元组主语/宾语,以匹配 KG 构图逻辑) + try: + data = json.loads(openie_path.read_text(encoding="utf-8")) + docs = data.get("docs", []) if isinstance(data, dict) else [] + for doc in docs: + if not isinstance(doc, dict): + continue + ents = doc.get("extracted_entities", []) + if isinstance(ents, list): + raw_entities.extend([e for e in ents if isinstance(e, str)]) + triples = doc.get("extracted_triples", []) + if isinstance(triples, list): + for t in triples: + if isinstance(t, list) and len(t) == 3: + subj, _, obj = t + if isinstance(subj, str): + raw_entities.append(subj) + if isinstance(obj, str): + raw_entities.append(obj) + raw_relations.append(str(tuple(t))) + except Exception as e: + logger.error(f"读取 OpenIE 文件失败: {e}") + sys.exit(1) + + # 从原始 txt 语料按段落索引选择删除 + if args.raw_file: + raw_path = Path(args.raw_file) + if not raw_path.exists(): + logger.error(f"原始语料文件不存在: {raw_path}") + sys.exit(1) + text = raw_path.read_text(encoding="utf-8") + paragraphs: List[str] = [] + buf = [] + for line in text.splitlines(): + if line.strip() == "": + if buf: + paragraphs.append("\n".join(buf).strip()) + buf = [] + else: + buf.append(line) + if buf: + paragraphs.append("\n".join(buf).strip()) + + if not paragraphs: + logger.error(f"原始语料文件 {raw_path} 中没有解析到任何段落") + sys.exit(1) + + if not args.raw_index: + logger.info(f"{raw_path} 共解析出 {len(paragraphs)} 个段落,请通过 --raw-index 指定要删除的段落,例如 --raw-index 1,3") + sys.exit(1) + + # 解析索引列表(1-based) + try: + idx_list = [int(x.strip()) for x in str(args.raw_index).split(",") if x.strip()] + except ValueError: + logger.error(f"--raw-index 解析失败: {args.raw_index}") + sys.exit(1) + + for idx in idx_list: + if idx < 1 or idx > len(paragraphs): + logger.error(f"--raw-index 包含无效索引 {idx}(有效范围 1~{len(paragraphs)})") + sys.exit(1) + + logger.info("根据原始语料选择段落:") + for idx in idx_list: + para = paragraphs[idx - 1] + h = get_sha256(para) + logger.info(f"- 第 {idx} 段,hash={h},内容预览:{para[:80]}") + raw_hashes.append(h) + + # 在现有库中按子串搜索候选段落并交互选择 + if args.search_text: + search_text = args.search_text.strip() + if not search_text: + logger.error("--search-text 不能为空") + sys.exit(1) + logger.info(f"正在根据关键字在现有段落库中搜索:{search_text!r}") + em_search = EmbeddingManager() + try: + em_search.load_from_file() + except Exception as e: + logger.error(f"加载嵌入库失败,无法使用 --search-text 功能: {e}") + sys.exit(1) + + candidates = [] + for key, item in em_search.paragraphs_embedding_store.store.items(): + if search_text in item.str: + candidates.append((key, item.str)) + if len(candidates) >= args.search_limit: + break + + if not candidates: + logger.info("未在现有段落库中找到包含该关键字的段落") + else: + logger.info("找到以下候选段落(输入序号选择要删除的条目,可用逗号分隔,多选):") + for i, (key, text) in enumerate(candidates, start=1): + logger.info(f"{i}. {key} | {text[:80]}") + if args.non_interactive: + logger.error( + "当前处于非交互模式,无法通过输入序号选择要删除的候选段落;" + "如需脚本化删除,请改用 --hash-file / --openie-file / --raw-file 等方式。" + ) + sys.exit(1) + choice = input("请输入要删除的序号列表(如 1,3),或直接回车取消:").strip() + if choice: + try: + idxs = [int(x.strip()) for x in choice.split(",") if x.strip()] + except ValueError: + logger.error("输入的序号列表无法解析,已取消 --search-text 删除") + else: + for i in idxs: + if 1 <= i <= len(candidates): + key, _ = candidates[i - 1] + # key 已是完整的 paragraph-xxx + if key.startswith("paragraph-"): + raw_hashes.append(key.split("paragraph-", 1)[1]) + else: + logger.warning(f"忽略无效序号: {i}") + + # 去重但保持顺序 + seen = set() + raw_hashes = [h for h in raw_hashes if not (h in seen or seen.add(h))] + + if not raw_hashes: + logger.error("未读取到任何待删哈希,无操作") + sys.exit(1) + + keys, pg_hashes = normalize_paragraph_keys(raw_hashes) + + ent_hashes: List[str] = [] + rel_hashes: List[str] = [] + if args.delete_entities and raw_entities: + ent_hashes = [get_sha256(e) for e in raw_entities] + if args.delete_relations and raw_relations: + rel_hashes = [get_sha256(r) for r in raw_relations] + + logger.info("=== 删除操作预备 ===") + logger.info("请确保已备份 data/embedding 与 data/rag,必要时可使用 --dry-run 预览") + logger.info(f"待删除段落数量: {len(keys)}") + logger.info(f"示例: {keys[:5]}") + if ent_hashes: + logger.info(f"待删除实体数量: {len(ent_hashes)}") + if rel_hashes: + logger.info(f"待删除关系数量: {len(rel_hashes)}") + + total_nodes_to_delete = len(pg_hashes) + (len(ent_hashes) if args.delete_entities else 0) + logger.info(f"本次预计删除节点总数(段落+实体): {total_nodes_to_delete}") + + if args.dry_run: + logger.info("dry-run 模式,未执行删除") + return + + # 大批次删除保护 + if total_nodes_to_delete > args.max_delete_nodes and not args.yes: + logger.error( + f"本次预计删除节点 {total_nodes_to_delete} 个,超过阈值 {args.max_delete_nodes}。" + " 为避免误删,请降低批次规模或使用 --max-delete-nodes 调整阈值,并加上 --yes 明确确认。" + ) + sys.exit(1) + + # 交互确认 + if not args.yes: + if args.non_interactive: + logger.error( + "当前处于非交互模式且未指定 --yes,出于安全考虑,删除操作已被拒绝。\n" + "如确认需要在非交互模式下执行删除,请显式添加 --yes 参数。" + ) + sys.exit(1) + confirm = input("确认删除上述数据?输入大写 YES 以继续,其他任意键取消: ").strip() + if confirm != "YES": + logger.info("用户取消删除操作") + return + + # 加载嵌入与图 + embed_manager = EmbeddingManager() + kg_manager = KGManager() + + try: + embed_manager.load_from_file() + kg_manager.load_from_file() + except Exception as e: + logger.error(f"加载现有知识库失败: {e}") + sys.exit(1) + + # 记录删除前全局统计,便于对比 + before_para_vec = len(embed_manager.paragraphs_embedding_store.store) + before_ent_vec = len(embed_manager.entities_embedding_store.store) + before_rel_vec = len(embed_manager.relation_embedding_store.store) + before_nodes = len(kg_manager.graph.get_node_list()) + before_edges = len(kg_manager.graph.get_edge_list()) + logger.info( + f"删除前统计: 段落向量={before_para_vec}, 实体向量={before_ent_vec}, 关系向量={before_rel_vec}, " + f"KG节点={before_nodes}, KG边={before_edges}" + ) + + # 删除向量 + deleted, skipped = embed_manager.paragraphs_embedding_store.delete_items(keys) + embed_manager.stored_pg_hashes = set(embed_manager.paragraphs_embedding_store.store.keys()) + logger.info(f"段落向量删除完成,删除: {deleted}, 跳过: {skipped}") + ent_deleted = ent_skipped = rel_deleted = rel_skipped = 0 + if ent_hashes: + ent_keys = [f"entity-{h}" for h in ent_hashes] + ent_deleted, ent_skipped = embed_manager.entities_embedding_store.delete_items(ent_keys) + logger.info(f"实体向量删除完成,删除: {ent_deleted}, 跳过: {ent_skipped}") + if rel_hashes: + rel_keys = [f"relation-{h}" for h in rel_hashes] + rel_deleted, rel_skipped = embed_manager.relation_embedding_store.delete_items(rel_keys) + logger.info(f"关系向量删除完成,删除: {rel_deleted}, 跳过: {rel_skipped}") + + # 删除图节点/边 + kg_result = kg_manager.delete_paragraphs( + pg_hashes, + ent_hashes=ent_hashes if args.delete_entities else None, + remove_orphan_entities=args.remove_orphan_entities, + ) + logger.info( + f"KG 删除完成,删除: {kg_result.get('deleted', 0)}, 跳过: {kg_result.get('skipped', 0)}, " + f"孤立实体清理: {kg_result.get('orphan_removed', 0)}" + ) + + # 重建索引并保存 + logger.info("重建 Faiss 索引并保存嵌入文件...") + embed_manager.rebuild_faiss_index() + embed_manager.save_to_file() + + logger.info("保存 KG 数据...") + kg_manager.save_to_file() + + # 删除后统计 + after_para_vec = len(embed_manager.paragraphs_embedding_store.store) + after_ent_vec = len(embed_manager.entities_embedding_store.store) + after_rel_vec = len(embed_manager.relation_embedding_store.store) + after_nodes = len(kg_manager.graph.get_node_list()) + after_edges = len(kg_manager.graph.get_edge_list()) + + logger.info( + "删除后统计: 段落向量=%d(%+d), 实体向量=%d(%+d), 关系向量=%d(%+d), KG节点=%d(%+d), KG边=%d(%+d)" + % ( + after_para_vec, + after_para_vec - before_para_vec, + after_ent_vec, + after_ent_vec - before_ent_vec, + after_rel_vec, + after_rel_vec - before_rel_vec, + after_nodes, + after_nodes - before_nodes, + after_edges, + after_edges - before_edges, + ) + ) + + logger.info("删除流程完成") + print( + "\n[NOTICE] 删除脚本执行完毕。如主程序(聊天 / WebUI)已在运行," + "请重启主程序,或在主程序内部调用一次 lpmm_start_up() 以应用最新 LPMM 知识库。" + ) + print("[NOTICE] 如果不清楚 lpmm_start_up 是什么,直接重启主程序即可。") + + +if __name__ == "__main__": + main() diff --git a/scripts/import_openie.py b/scripts/import_openie.py index f9405f59..4057a52c 100644 --- a/scripts/import_openie.py +++ b/scripts/import_openie.py @@ -4,10 +4,12 @@ # print("未找到quick_algo库,无法使用quick_algo算法") # print("请安装quick_algo库 - 在lib.quick_algo中,执行命令:python setup.py build_ext --inplace") +import argparse import sys import os import asyncio from time import sleep +from typing import Optional sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) from src.chat.knowledge.embedding_store import EmbeddingManager @@ -71,7 +73,12 @@ def hash_deduplicate( return new_raw_paragraphs, new_triple_list_data -def handle_import_openie(openie_data: OpenIE, embed_manager: EmbeddingManager, kg_manager: KGManager) -> bool: +def handle_import_openie( + openie_data: OpenIE, + embed_manager: EmbeddingManager, + kg_manager: KGManager, + non_interactive: bool = False, +) -> bool: # sourcery skip: extract-method # 从OpenIE数据中提取段落原文与三元组列表 # 索引的段落原文 @@ -124,8 +131,13 @@ def handle_import_openie(openie_data: OpenIE, embed_manager: EmbeddingManager, k logger.info("所有数据均完整,没有发现缺失字段。") return False # 新增:提示用户是否删除非法文段继续导入 - # 将print移到所有logger.error之后,确保不会被冲掉 + # 在非交互模式下,不再询问用户,而是直接报错终止 logger.info(f"\n检测到非法文段,共{len(missing_idxs)}条。") + if non_interactive: + logger.error( + "检测到非法文段且当前处于非交互模式,无法询问是否删除非法文段,导入终止。" + ) + sys.exit(1) logger.info("\n是否删除所有非法文段后继续导入?(y/n): ", end="") user_choice = input().strip().lower() if user_choice != "y": @@ -174,20 +186,25 @@ def handle_import_openie(openie_data: OpenIE, embed_manager: EmbeddingManager, k return True -async def main_async(): # sourcery skip: dict-comprehension +async def main_async(non_interactive: bool = False) -> bool: # sourcery skip: dict-comprehension # 新增确认提示 - print("=== 重要操作确认 ===") - print("OpenIE导入时会大量发送请求,可能会撞到请求速度上限,请注意选用的模型") - print("同之前样例:在本地模型下,在70分钟内我们发送了约8万条请求,在网络允许下,速度会更快") - print("推荐使用硅基流动的Pro/BAAI/bge-m3") - print("每百万Token费用为0.7元") - print("知识导入时,会消耗大量系统资源,建议在较好配置电脑上运行") - print("同上样例,导入时10700K几乎跑满,14900HX占用80%,峰值内存占用约3G") - confirm = input("确认继续执行?(y/n): ").strip().lower() - if confirm != "y": - logger.info("用户取消操作") - print("操作已取消") - sys.exit(1) + if non_interactive: + logger.warning( + "当前处于非交互模式,将跳过导入开销确认提示,直接开始执行 OpenIE 导入。" + ) + else: + print("=== 重要操作确认 ===") + print("OpenIE导入时会大量发送请求,可能会撞到请求速度上限,请注意选用的模型") + print("同之前样例:在本地模型下,在70分钟内我们发送了约8万条请求,在网络允许下,速度会更快") + print("推荐使用硅基流动的Pro/BAAI/bge-m3") + print("每百万Token费用为0.7元") + print("知识导入时,会消耗大量系统资源,建议在较好配置电脑上运行") + print("同上样例,导入时10700K几乎跑满,14900HX占用80%,峰值内存占用约3G") + confirm = input("确认继续执行?(y/n): ").strip().lower() + if confirm != "y": + logger.info("用户取消操作") + print("操作已取消") + sys.exit(1) print("\n" + "=" * 40 + "\n") ensure_openie_dir() # 确保OpenIE目录存在 logger.info("----开始导入openie数据----\n") @@ -235,14 +252,27 @@ async def main_async(): # sourcery skip: dict-comprehension except Exception as e: logger.error(f"导入OpenIE数据文件时发生错误:{e}") return False - if handle_import_openie(openie_data, embed_manager, kg_manager) is False: + if handle_import_openie(openie_data, embed_manager, kg_manager, non_interactive=non_interactive) is False: logger.error("处理OpenIE数据时发生错误") return False - return None + return True -def main(): - """主函数 - 设置新的事件循环并运行异步主函数""" +def main(argv: Optional[list[str]] = None) -> None: + """主函数 - 解析参数并运行异步主流程。""" + parser = argparse.ArgumentParser( + description=( + "OpenIE 导入脚本:读取 data/openie 中的 OpenIE JSON 批次," + "将其导入到 LPMM 的向量库与知识图中。" + ) + ) + parser.add_argument( + "--non-interactive", + action="store_true", + help="非交互模式:跳过导入确认提示以及非法文段删除询问,遇到非法文段时直接报错退出。", + ) + args = parser.parse_args(argv) + # 检查是否有现有的事件循环 try: loop = asyncio.get_running_loop() @@ -255,13 +285,22 @@ def main(): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) + ok: bool = False try: # 在新的事件循环中运行异步主函数 - loop.run_until_complete(main_async()) + ok = loop.run_until_complete(main_async(non_interactive=args.non_interactive)) + print( + "\n[NOTICE] OpenIE 导入脚本执行完毕。如主程序(聊天 / WebUI)已在运行," + "请重启主程序,或在主程序内部调用一次 lpmm_start_up() 以应用最新 LPMM 知识库。" + ) + print("[NOTICE] 如果不清楚 lpmm_start_up 是什么,直接重启主程序即可。") finally: # 确保事件循环被正确关闭 if not loop.is_closed(): loop.close() + if not ok: + # 统一错误码,方便在非交互场景下检测失败 + sys.exit(1) if __name__ == "__main__": diff --git a/scripts/info_extraction.py b/scripts/info_extraction.py index 9ef8c098..b5068b0f 100644 --- a/scripts/info_extraction.py +++ b/scripts/info_extraction.py @@ -1,3 +1,4 @@ +import argparse import json import os import signal @@ -5,6 +6,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed from threading import Lock, Event import sys import datetime +from typing import Optional sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) # 添加项目根目录到 sys.path @@ -115,22 +117,34 @@ def signal_handler(_signum, _frame): sys.exit(0) -def main(): # sourcery skip: comprehension-to-generator, extract-method +def _run(non_interactive: bool = False) -> None: # sourcery skip: comprehension-to-generator, extract-method # 设置信号处理器 signal.signal(signal.SIGINT, signal_handler) ensure_dirs() # 确保目录存在 # 新增用户确认提示 - print("=== 重要操作确认,请认真阅读以下内容哦 ===") - print("实体提取操作将会花费较多api余额和时间,建议在空闲时段执行。") - print("举例:600万字全剧情,提取选用deepseek v3 0324,消耗约40元,约3小时。") - print("建议使用硅基流动的非Pro模型") - print("或者使用可以用赠金抵扣的Pro模型") - print("请确保账户余额充足,并且在执行前确认无误。") - confirm = input("确认继续执行?(y/n): ").strip().lower() - if confirm != "y": - logger.info("用户取消操作") - print("操作已取消") - sys.exit(1) + if non_interactive: + logger.warning( + "当前处于非交互模式,将跳过费用与时长确认提示,直接开始进行实体提取操作。" + ) + else: + print("=== 重要操作确认,请认真阅读以下内容哦 ===") + print("实体提取操作将会花费较多api余额和时间,建议在空闲时段执行。") + print("举例:600万字全剧情,提取选用deepseek v3 0324,消耗约40元,约3小时。") + print("建议使用硅基流动的非Pro模型") + print("或者使用可以用赠金抵扣的Pro模型") + print("请确保账户余额充足,并且在执行前确认无误。") + confirm = input("确认继续执行?(y/n): ").strip().lower() + if confirm != "y": + logger.info("用户取消操作") + print("操作已取消") + sys.exit(1) + + # 友好提示:说明“网络错误(可重试)”日志属于正常自动重试行为,避免用户误以为任务失败 + print( + "\n提示:在提取过程中,如果看到模型出现“网络错误(可重试)”等日志," + "表示系统正在自动重试请求,一般不会影响整体导入结果,请耐心等待即可。\n" + ) + print("\n" + "=" * 40 + "\n") ensure_dirs() # 确保目录存在 logger.info("--------进行信息提取--------\n") @@ -215,5 +229,22 @@ def main(): # sourcery skip: comprehension-to-generator, extract-method logger.info(f"提取失败的文段SHA256:{failed_sha256}") +def main(argv: Optional[list[str]] = None) -> None: + parser = argparse.ArgumentParser( + description=( + "LPMM 信息提取脚本:从 data/lpmm_raw_data/*.txt 中读取原始段落," + "调用 LLM 提取实体和三元组,并生成 OpenIE JSON 批次文件。" + ) + ) + parser.add_argument( + "--non-interactive", + action="store_true", + help="非交互模式:跳过费用确认提示,直接开始执行;适用于 CI / 定时任务等场景。", + ) + args = parser.parse_args(argv) + + _run(non_interactive=args.non_interactive) + + if __name__ == "__main__": main() diff --git a/scripts/inspect_lpmm_batch.py b/scripts/inspect_lpmm_batch.py new file mode 100644 index 00000000..2ed719cf --- /dev/null +++ b/scripts/inspect_lpmm_batch.py @@ -0,0 +1,132 @@ +import argparse +import json +import os +import sys +from pathlib import Path +from typing import List, Tuple + +# 确保能导入 src.* +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from src.chat.knowledge.utils.hash import get_sha256 +from src.chat.knowledge.embedding_store import EmbeddingManager +from src.chat.knowledge.kg_manager import KGManager +from src.common.logger import get_logger + +logger = get_logger("inspect_lpmm_batch") + + +def load_openie_hashes(path: Path) -> Tuple[List[str], List[str], List[str]]: + """从 OpenIE JSON 中提取段落 / 实体 / 关系的哈希 + + 注意:实体既包括 extracted_entities 中的条目,也包括三元组中的主语/宾语, + 以与 KG 构图逻辑保持一致。 + """ + with path.open("r", encoding="utf-8") as f: + data = json.load(f) + + pg_hashes: List[str] = [] + ent_hashes: List[str] = [] + rel_hashes: List[str] = [] + + for doc in data.get("docs", []): + if not isinstance(doc, dict): + continue + idx = doc.get("idx") + if isinstance(idx, str) and idx.strip(): + pg_hashes.append(idx.strip()) + + ents = doc.get("extracted_entities", []) + if isinstance(ents, list): + for e in ents: + if isinstance(e, str): + ent_hashes.append(get_sha256(e)) + + triples = doc.get("extracted_triples", []) + if isinstance(triples, list): + for t in triples: + if isinstance(t, list) and len(t) == 3: + # 主语/宾语作为实体参与构图 + subj, _, obj = t + if isinstance(subj, str): + ent_hashes.append(get_sha256(subj)) + if isinstance(obj, str): + ent_hashes.append(get_sha256(obj)) + rel_hashes.append(get_sha256(str(tuple(t)))) + + # 去重但保留顺序 + def unique(seq: List[str]) -> List[str]: + seen = set() + return [x for x in seq if not (x in seen or seen.add(x))] + + return unique(pg_hashes), unique(ent_hashes), unique(rel_hashes) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="检查指定 OpenIE 文件对应批次在当前向量库与 KG 中的存在情况(用于验证删除效果)。" + ) + parser.add_argument("--openie-file", required=True, help="OpenIE 输出 JSON 文件路径") + args = parser.parse_args() + + openie_path = Path(args.openie_file) + if not openie_path.exists(): + logger.error(f"OpenIE 文件不存在: {openie_path}") + sys.exit(1) + + pg_hashes, ent_hashes, rel_hashes = load_openie_hashes(openie_path) + logger.info( + f"从 {openie_path.name} 解析到 段落 {len(pg_hashes)} 条,实体 {len(ent_hashes)} 个,关系 {len(rel_hashes)} 条" + ) + + # 加载当前嵌入与 KG + em = EmbeddingManager() + kg = KGManager() + try: + em.load_from_file() + kg.load_from_file() + except Exception as e: + logger.error(f"加载当前知识库失败: {e}") + sys.exit(1) + + graph_nodes = set(kg.graph.get_node_list()) + + # 检查段落 + pg_keys = [f"paragraph-{h}" for h in pg_hashes] + pg_in_vec = sum(1 for k in pg_keys if k in em.paragraphs_embedding_store.store) + pg_in_kg = sum(1 for k in pg_keys if k in graph_nodes) + + # 检查实体 + ent_keys = [f"entity-{h}" for h in ent_hashes] + ent_in_vec = sum(1 for k in ent_keys if k in em.entities_embedding_store.store) + ent_in_kg = sum(1 for k in ent_keys if k in graph_nodes) + + # 检查关系(只针对向量库) + rel_keys = [f"relation-{h}" for h in rel_hashes] + rel_in_vec = sum(1 for k in rel_keys if k in em.relation_embedding_store.store) + + print("==== 批次存在情况(删除前/后对比用) ====") + print(f"段落: 总计 {len(pg_keys)}, 向量库剩余 {pg_in_vec}, KG 中剩余 {pg_in_kg}") + print(f"实体: 总计 {len(ent_keys)}, 向量库剩余 {ent_in_vec}, KG 中剩余 {ent_in_kg}") + print(f"关系: 总计 {len(rel_keys)}, 向量库剩余 {rel_in_vec}") + + # 打印少量仍存在的样例,便于检查内容是否正常 + sample_pg = [k for k in pg_keys if k in graph_nodes][:3] + if sample_pg: + print("\n仍在 KG 中的段落节点示例:") + for k in sample_pg: + nd = kg.graph[k] + content = nd["content"] if "content" in nd else k + print(f"- {k}: {content[:80]}") + + sample_ent = [k for k in ent_keys if k in graph_nodes][:3] + if sample_ent: + print("\n仍在 KG 中的实体节点示例:") + for k in sample_ent: + nd = kg.graph[k] + content = nd["content"] if "content" in nd else k + print(f"- {k}: {content[:80]}") + + +if __name__ == "__main__": + main() diff --git a/scripts/inspect_lpmm_global.py b/scripts/inspect_lpmm_global.py new file mode 100644 index 00000000..13b80e14 --- /dev/null +++ b/scripts/inspect_lpmm_global.py @@ -0,0 +1,71 @@ +import os +import sys +from typing import Set + +# 保证可以导入 src.* +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from src.chat.knowledge.embedding_store import EmbeddingManager +from src.chat.knowledge.kg_manager import KGManager +from src.common.logger import get_logger + +logger = get_logger("inspect_lpmm_global") + + +def main() -> None: + """检查当前整库(所有批次)的向量与 KG 状态,用于观察删除对剩余数据的影响。""" + em = EmbeddingManager() + kg = KGManager() + + try: + em.load_from_file() + kg.load_from_file() + except Exception as e: + logger.error(f"加载当前知识库失败: {e}") + sys.exit(1) + + # 向量库统计 + para_cnt = len(em.paragraphs_embedding_store.store) + ent_cnt_vec = len(em.entities_embedding_store.store) + rel_cnt_vec = len(em.relation_embedding_store.store) + + # KG 统计 + nodes = kg.graph.get_node_list() + edges = kg.graph.get_edge_list() + node_set: Set[str] = set(nodes) + + para_nodes = [n for n in nodes if n.startswith("paragraph-")] + ent_nodes = [n for n in nodes if n.startswith("entity-")] + + print("==== 向量库统计 ====") + print(f"段落向量条数: {para_cnt}") + print(f"实体向量条数: {ent_cnt_vec}") + print(f"关系向量条数: {rel_cnt_vec}") + + print("\n==== KG 图统计 ====") + print(f"节点总数: {len(nodes)}") + print(f"边总数: {len(edges)}") + print(f"段落节点数: {len(para_nodes)}") + print(f"实体节点数: {len(ent_nodes)}") + + # ent_appear_cnt 状态 + ent_cnt_meta = len(kg.ent_appear_cnt) + print(f"\n实体计数表条目数: {ent_cnt_meta}") + + # 抽样查看剩余段落/实体内容 + print("\n==== 剩余段落示例(最多 3 条) ====") + for nid in para_nodes[:3]: + nd = kg.graph[nid] + content = nd["content"] if "content" in nd else nid + print(f"- {nid}: {content[:80]}") + + print("\n==== 剩余实体示例(最多 5 条) ====") + for nid in ent_nodes[:5]: + nd = kg.graph[nid] + content = nd["content"] if "content" in nd else nid + print(f"- {nid}: {content[:80]}") + + +if __name__ == "__main__": + main() + diff --git a/scripts/lpmm_manager.py b/scripts/lpmm_manager.py new file mode 100644 index 00000000..9ca42254 --- /dev/null +++ b/scripts/lpmm_manager.py @@ -0,0 +1,541 @@ +import argparse +import os +import re +import sys +from datetime import datetime +from pathlib import Path +from typing import Optional, List + +# 尽量统一控制台编码为 utf-8,避免中文输出报错 +try: + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(encoding="utf-8") +except Exception: + pass + +# 确保能导入 src.* 以及同目录脚本 +CURRENT_DIR = os.path.dirname(__file__) +PROJECT_ROOT = os.path.abspath(os.path.join(CURRENT_DIR, "..")) +if PROJECT_ROOT not in sys.path: + sys.path.append(PROJECT_ROOT) + +from src.common.logger import get_logger # type: ignore +from src.config.config import global_config, model_config # type: ignore + +# 引入各功能脚本的入口函数 +from import_openie import main as import_openie_main # type: ignore +from info_extraction import main as info_extraction_main # type: ignore +from delete_lpmm_items import main as delete_lpmm_items_main # type: ignore +from inspect_lpmm_batch import main as inspect_lpmm_batch_main # type: ignore +from inspect_lpmm_global import main as inspect_lpmm_global_main # type: ignore +from refresh_lpmm_knowledge import main as refresh_lpmm_knowledge_main # type: ignore +from test_lpmm_retrieval import main as test_lpmm_retrieval_main # type: ignore +from raw_data_preprocessor import load_raw_data # type: ignore + + +logger = get_logger("lpmm_manager") + + +ACTION_INFO = { + "prepare_raw": "预处理 data/lpmm_raw_data/*.txt,按空行切分为段落并做去重统计", + "info_extract": "原始 txt -> OpenIE 信息抽取(调用 info_extraction.py)", + "import_openie": "导入 OpenIE 批次到向量库与知识图(调用 import_openie.py)", + "delete": "删除/回滚知识(调用 delete_lpmm_items.py)", + "batch_inspect": "检查指定 OpenIE 批次在当前库中的存在情况(调用 inspect_lpmm_batch.py)", + "global_inspect": "查看当前整库向量与 KG 状态(调用 inspect_lpmm_global.py)", + "refresh": "刷新 LPMM 磁盘数据到内存(调用 refresh_lpmm_knowledge.py)", + "test": "运行 LPMM 检索效果回归测试(调用 test_lpmm_retrieval.py)", + "embedding_helper": "嵌入模型迁移辅助:查看当前嵌入模型/维度并归档 embedding_model_test.json", + "full_import": "一键执行:信息抽取 -> 导入 OpenIE -> 刷新", +} + + +def _with_overridden_argv(extra_args: List[str], target_main) -> None: + """在不修改子脚本的前提下,临时覆盖 sys.argv 以透传参数。""" + old_argv = list(sys.argv) + try: + # 第 0 个元素为“程序名”,后续元素为实际参数 + # 这里不再插入类似 delete_lpmm_items.py 的占位,避免被 argparse 误识别为位置参数 + sys.argv = [old_argv[0]] + extra_args + target_main() + finally: + sys.argv = old_argv + + +def _check_before_info_extract(non_interactive: bool = False) -> bool: + """信息抽取前的轻量级检查。""" + raw_dir = Path(PROJECT_ROOT) / "data" / "lpmm_raw_data" + txt_files = list(raw_dir.glob("*.txt")) + if not txt_files: + msg = ( + f"[WARN] 未在 {raw_dir} 下找到任何 .txt 原始语料文件," + "info_extraction 可能立即退出或无数据可处理。" + ) + print(msg) + if non_interactive: + logger.error( + "非交互模式下要求原始语料目录中已存在可用的 .txt 文件,请先准备好数据再重试。" + ) + return False + cont = input("仍然继续执行信息提取吗?(y/n): ").strip().lower() + return cont == "y" + return True + + +def _check_before_import_openie(non_interactive: bool = False) -> bool: + """导入 OpenIE 前的轻量级检查。""" + openie_dir = Path(PROJECT_ROOT) / "data" / "openie" + json_files = list(openie_dir.glob("*.json")) + if not json_files: + msg = ( + f"[WARN] 未在 {openie_dir} 下找到任何 OpenIE JSON 文件," + "import_openie 可能会因为找不到批次而失败。" + ) + print(msg) + if non_interactive: + logger.error( + "非交互模式下要求 data/openie 目录中已存在可用的 OpenIE JSON 文件,请先执行信息提取脚本。" + ) + return False + cont = input("仍然继续执行导入吗?(y/n): ").strip().lower() + return cont == "y" + return True + + +def _warn_if_lpmm_disabled() -> None: + """在部分操作前提醒 lpmm_knowledge.enable 状态。""" + try: + if not getattr(global_config.lpmm_knowledge, "enable", False): + print( + "[WARN] 当前配置 lpmm_knowledge.enable = false," + "刷新或检索测试可能无法在聊天侧真正启用 LPMM。" + ) + except Exception: + # 配置异常时不阻断主流程,仅忽略提示 + pass + + +def run_action(action: str, extra_args: Optional[List[str]] = None) -> None: + """根据动作名称调度到对应脚本。 + + 这里不重复解析子参数,而是直接调用各脚本的 main(), + 让子脚本保留原有的交互/参数行为。 + """ + logger.info("开始执行操作: %s", action) + + extra_args = extra_args or [] + + try: + if action == "prepare_raw": + logger.info("开始预处理原始语料 (data/lpmm_raw_data/*.txt)...") + sha_list, raw_data = load_raw_data() + print( + f"\n[PREPARE_RAW] 完成原始语料预处理:共 {len(raw_data)} 条段落," + f"去重后哈希数 {len(sha_list)}。" + ) + elif action == "info_extract": + if not _check_before_info_extract("--non-interactive" in extra_args): + print("已根据用户选择,取消执行信息提取。") + return + _with_overridden_argv(extra_args, info_extraction_main) + elif action == "import_openie": + if not _check_before_import_openie("--non-interactive" in extra_args): + print("已根据用户选择,取消执行导入。") + return + _with_overridden_argv(extra_args, import_openie_main) + elif action == "delete": + _with_overridden_argv(extra_args, delete_lpmm_items_main) + elif action == "batch_inspect": + _with_overridden_argv(extra_args, inspect_lpmm_batch_main) + elif action == "global_inspect": + _with_overridden_argv(extra_args, inspect_lpmm_global_main) + elif action == "refresh": + _warn_if_lpmm_disabled() + _with_overridden_argv(extra_args, refresh_lpmm_knowledge_main) + elif action == "test": + _warn_if_lpmm_disabled() + _with_overridden_argv(extra_args, test_lpmm_retrieval_main) + elif action == "embedding_helper": + # 嵌入模型迁移辅助:查看当前嵌入模型/维度并归档 embedding_model_test.json + _run_embedding_helper() + elif action == "full_import": + # 一键流水线:预处理原始语料 -> 信息抽取 -> 导入 -> 刷新 + logger.info("开始 full_import:预处理原始语料 -> 信息抽取 -> 导入 -> 刷新") + sha_list, raw_data = load_raw_data() + print( + f"\n[FULL_IMPORT] 原始语料预处理完成:共 {len(raw_data)} 条段落," + f"去重后哈希数 {len(sha_list)}。" + ) + non_interactive = "--non-interactive" in extra_args + if not _check_before_info_extract(non_interactive): + print("已根据用户选择,取消 full_import(信息提取阶段被取消)。") + return + # 使用与单步 info_extract 相同的参数透传机制,确保 --non-interactive 等生效 + _with_overridden_argv(extra_args, info_extraction_main) + if not _check_before_import_openie(non_interactive): + print("已根据用户选择,取消 full_import(导入阶段被取消)。") + return + _with_overridden_argv(extra_args, import_openie_main) + _warn_if_lpmm_disabled() + _with_overridden_argv(extra_args, refresh_lpmm_knowledge_main) + else: + logger.error("未知操作: %s", action) + except KeyboardInterrupt: + logger.info("用户中断当前操作(Ctrl+C)") + except SystemExit: + # 子脚本里大量使用 sys.exit,直接透传即可 + raise + except Exception as exc: # pragma: no cover - 防御性兜底 + logger.error("执行操作 %s 时发生未捕获异常: %s", action, exc) + raise + + +def print_menu() -> None: + print("\n===== LPMM 管理菜单 =====") + for idx, key in enumerate( + [ + "prepare_raw", + "info_extract", + "import_openie", + "delete", + "batch_inspect", + "global_inspect", + "refresh", + "test", + "embedding_helper", + "full_import", + ], + start=1, + ): + desc = ACTION_INFO.get(key, "") + print(f"{idx}. {key:14s} - {desc}") + print("0. 退出") + print("=========================") + + +def interactive_loop() -> None: + """交互式选择模式。""" + key_order = [ + "prepare_raw", + "info_extract", + "import_openie", + "delete", + "batch_inspect", + "global_inspect", + "refresh", + "test", + "embedding_helper", + "full_import", + ] + + while True: + print_menu() + choice = input("请输入选项编号(0-10):").strip() + + if choice in ("0", "q", "Q", "quit", "exit"): + print("已退出 LPMM 管理器。") + return + + try: + idx = int(choice) + except ValueError: + print("输入无效,请输入 0-10 之间的数字。") + continue + + if not (1 <= idx <= len(key_order)): + print("输入编号超出范围,请重新输入。") + continue + + action = key_order[idx - 1] + print(f"\n你选择了: {action} - {ACTION_INFO.get(action, '')}") + confirm = input("确认执行该操作?(y/n): ").strip().lower() + if confirm != "y": + print("已取消当前操作。\n") + continue + + # 通过交互式问题,尽量帮用户补全对应脚本的常用参数 + extra_args: List[str] = [] + if action == "delete": + extra_args = _interactive_build_delete_args() + elif action == "batch_inspect": + extra_args = _interactive_build_batch_inspect_args() + elif action == "test": + extra_args = _interactive_build_test_args() + else: + extra_args = [] + + run_action(action, extra_args=extra_args) + print("\n当前操作已结束,回到主菜单。\n") + + +def _interactive_choose_openie_file(prompt: str) -> Optional[str]: + """在 data/openie 下列出可选 JSON 文件,并返回用户选择的路径。""" + openie_dir = Path(PROJECT_ROOT) / "data" / "openie" + files = sorted(openie_dir.glob("*.json")) + if not files: + print(f"[WARN] 在 {openie_dir} 下没有找到任何 OpenIE JSON 文件。") + return input(prompt).strip() or None + + print("\n可选的 OpenIE 批次文件:") + for i, f in enumerate(files, start=1): + print(f"{i}. {f.name}") + print("0. 手动输入完整路径") + + while True: + choice = input("请选择文件编号:").strip() + if choice == "0": + manual = input(prompt).strip() + return manual or None + try: + idx = int(choice) + except ValueError: + print("请输入合法的编号。") + continue + if 1 <= idx <= len(files): + return str(files[idx - 1]) + print("编号超出范围,请重试。") + + +def _interactive_build_delete_args() -> List[str]: + """为 delete_lpmm_items 构造常见参数,减少二次交互。""" + print( + "\n[DELETE] 请选择删除方式:\n" + "1. 按哈希文件删除 (--hash-file)\n" + "2. 按 OpenIE 批次删除 (--openie-file)\n" + "3. 按原始语料文件 + 段落索引删除 (--raw-file + --raw-index)\n" + "4. 按关键字搜索现有段落 (--search-text)\n" + "回车跳过,由子脚本自行交互。" + ) + mode = input("输入选项编号(1-4,或回车跳过):").strip() + args: List[str] = [] + + if mode == "1": + path = input("请输入哈希文件路径(每行一个 hash):").strip() + if path: + args += ["--hash-file", path] + elif mode == "2": + path = _interactive_choose_openie_file("请输入 OpenIE JSON 文件路径:") + if path: + args += ["--openie-file", path] + elif mode == "3": + raw_file = input("请输入原始语料 txt 文件路径:").strip() + raw_index = input("请输入要删除的段落索引(如 1,3):").strip() + if raw_file and raw_index: + args += ["--raw-file", raw_file, "--raw-index", raw_index] + elif mode == "4": + text = input("请输入用于搜索的关键字(出现在段落原文中):").strip() + if text: + args += ["--search-text", text] + else: + # 留空则完全交给子脚本交互 + return [] + + # 进一步询问与安全相关的布尔选项 + print( + "\n[DELETE] 接下来是一些安全相关选项的说明:\n" + "- 删除实体向量/节点:会一并清理与这些段落关联的实体节点及其向量;\n" + "- 删除关系向量:在上面的基础上,额外清理关系向量(一般与删除实体一同使用);\n" + "- 删除孤立实体节点:删除后若实体不再连接任何段落,将其从图中移除,避免残留孤点;\n" + "- dry-run:只预览将要删除的内容,不真正修改任何数据;\n" + "- 跳过交互确认(--yes):直接执行删除操作,适合脚本化或已充分确认的场景;\n" + "- 单次最大删除节点数上限:防止一次性删除规模过大,起到误操作保护作用;\n" + "- 一般情况下建议同时删除实体向量/节点/关系向量/节点,以确保知识图谱的完整性。" + ) + + # 快速选项:按推荐方式清理所有相关实体/关系 + quick_all = input( + "是否使用推荐策略:同时删除关联的实体向量/节点、关系向量,并清理孤立实体?(Y/n): " + ).strip().lower() + if quick_all in ("", "y", "yes"): + args.extend(["--delete-entities", "--delete-relations", "--remove-orphan-entities"]) + else: + # 仅当未使用快速方案时,再逐项询问 + if input("是否同时删除实体向量/节点?(y/N): ").strip().lower() == "y": + args.append("--delete-entities") + if input("是否同时删除关系向量?(y/N): ").strip().lower() == "y": + args.append("--delete-relations") + + if input("是否删除孤立实体节点?(y/N): ").strip().lower() == "y": + args.append("--remove-orphan-entities") + + if input("是否以 dry-run 预览而不真正删除?(y/N): ").strip().lower() == "y": + args.append("--dry-run") + else: + if input("是否跳过交互确认直接删除?(默认否,请谨慎) (y/N): ").strip().lower() == "y": + args.append("--yes") + + max_nodes = input("单次最大删除节点数上限(回车使用默认 2000):").strip() + if max_nodes: + args += ["--max-delete-nodes", max_nodes] + + return args + + +def _interactive_build_batch_inspect_args() -> List[str]: + """为 inspect_lpmm_batch 构造 --openie-file 参数。""" + path = _interactive_choose_openie_file( + "请输入要检查的 OpenIE JSON 文件路径(回车跳过,由子脚本自行交互):" + ) + if not path: + return [] + return ["--openie-file", path] + + +def _interactive_build_test_args() -> List[str]: + """为 test_lpmm_retrieval 构造自定义测试用例参数。""" + print( + "\n[TEST] 你可以:\n" + "- 直接回车使用内置的默认测试用例;\n" + "- 或者输入一条自定义问题,并指定期望命中的关键字。" + ) + query = input("请输入自定义测试问题(回车则使用默认用例):").strip() + if not query: + return [] + + expect = input("请输入期望命中的关键字(可选,多项用逗号分隔):").strip() + args: List[str] = ["--query", query] + if expect: + for kw in expect.split(","): + kw = kw.strip() + if kw: + args.extend(["--expect-keyword", kw]) + return args + + +def _run_embedding_helper() -> None: + """嵌入模型迁移辅助:展示当前配置,并安全归档 embedding_model_test.json。""" + from src.chat.knowledge.embedding_store import EMBEDDING_TEST_FILE # type: ignore + + # 1. 读取当前配置中的嵌入维度与模型信息 + current_dim = getattr(getattr(global_config, "lpmm_knowledge", None), "embedding_dimension", None) + embed_task = getattr(model_config.model_task_config, "embedding", None) + model_ids: List[str] = [] + if embed_task is not None: + model_ids = getattr(embed_task, "model_list", []) or [] + primary_model = model_ids[0] if model_ids else "unknown" + safe_model_name = re.sub(r"[^0-9A-Za-z_.-]+", "_", primary_model) or "unknown" + + print("\n===== 嵌入模型迁移辅助 (embedding_helper) =====") + print(f"- 当前嵌入模型标识(model_task_config.embedding.model_list[0]): {primary_model}") + print(f"- 当前配置中的嵌入维度 (lpmm_knowledge.embedding_dimension): {current_dim}") + print(f"- 测试文件路径: {EMBEDDING_TEST_FILE}") + + new_dim = input( + "\n如果你计划更换嵌入模型,请在此输入“新的嵌入维度”(仅用于记录与提示,回车则跳过):" + ).strip() + if new_dim and not new_dim.isdigit(): + print("输入的维度不是纯数字,已取消操作。") + return + + print( + "\n[重要提示]\n" + "- 修改嵌入模型或维度会导致当前磁盘中的旧知识库(data/embedding 下的向量)与新模型不兼容;\n" + "- 这通常意味着你需要清空旧的向量/图数据,并重新执行 LPMM 导入流水线;\n" + "- 请仅在你**确定要切换嵌入模型/维度**时再继续。\n" + ) + confirm = input("是否已充分评估风险,并准备切换嵌入模型/维度?(y/N): ").strip().lower() + if confirm != "y": + print("已根据你的选择取消嵌入模型迁移辅助操作。") + return + + print( + "\n接下来请手动完成以下操作(脚本不会自动修改配置或删除知识库):\n" + f"1. 在配置文件中,将 lpmm_knowledge.embedding_dimension 从 {current_dim} 修改为你计划使用的新维度" + + (f"(例如 {new_dim})" if new_dim else "") # 仅作为示例 + + ";\n" + "2. 根据需要,清空 data/embedding 与相关 KG 数据(data/rag 等),然后重新执行导入流水线;\n" + "3. 本脚本将帮助你归档当前的 embedding_model_test.json,避免旧测试文件干扰新模型的校验。\n" + ) + + # 2. 归档 embedding_model_test.json + test_path = Path(EMBEDDING_TEST_FILE) + if not test_path.exists(): + print(f"\n[INFO] 未在 {test_path} 发现 embedding_model_test.json,无需归档。") + return + + ts = datetime.now().strftime("%Y%m%d-%H%M%S") + archive_name = f"embedding_model_test-{safe_model_name}-{ts}.json" + archive_path = test_path.with_name(archive_name) + + # 若不巧重名,简单追加后缀避免覆盖 + suffix_id = 1 + while archive_path.exists(): + archive_name = f"embedding_model_test-{safe_model_name}-{ts}-{suffix_id}.json" + archive_path = test_path.with_name(archive_name) + suffix_id += 1 + + try: + test_path.rename(archive_path) + except Exception as exc: # pragma: no cover - 防御性兜底 + logger.error("归档 embedding_model_test.json 失败: %s", exc) + print(f"[ERROR] 归档 embedding_model_test.json 失败,请检查文件权限与路径。错误详情已写入日志。") + return + + print( + f"\n[OK] 已将 {test_path.name} 重命名为 {archive_path.name}。\n" + f"- 归档位置: {archive_path}\n" + "- 之后再次运行涉及嵌入模型的一致性校验时,将会基于当前配置与新模型生成新的测试文件。\n" + "- 在完成配置修改与知识库重导入前,请不要手动再创建名为 embedding_model_test.json 的文件。" + ) + + +def parse_args(argv: Optional[list[str]] = None) -> tuple[argparse.Namespace, List[str]]: + parser = argparse.ArgumentParser( + description=( + "LPMM 管理脚本:集中入口管理 LPMM 的导入 / 删除 / 自检 / 刷新 / 测试等功能。\n" + "可以通过 --interactive 进入菜单模式,也可以使用 --action 直接执行单个操作。" + ) + ) + parser.add_argument( + "-i", + "--interactive", + action="store_true", + help="进入交互式菜单模式(推荐给手动运维使用)", + ) + parser.add_argument( + "-a", + "--action", + choices=list(ACTION_INFO.keys()), + help="直接执行指定操作(非交互模式)", + ) + parser.add_argument( + "--non-interactive", + action="store_true", + help=( + "启用非交互模式:lpmm_manager 自身不会再通过 input() 询问是否继续前置检查;" + "并会将 --non-interactive 透传给子脚本,以避免子脚本中的交互式确认。" + ), + ) + # 允许在管理脚本之后继续跟随子脚本参数,例如: + # python lpmm_manager.py -a delete -- --hash-file xxx --yes + args, unknown = parser.parse_known_args(argv) + return args, unknown + + +def main(argv: Optional[list[str]] = None) -> None: + args, extra_args = parse_args(argv) + + # 如果指定了 non-interactive,则不能进入交互式菜单 + if args.non_interactive and args.interactive: + logger.error("不能同时指定 --interactive 与 --non-interactive,请二选一。") + sys.exit(1) + + # 没有指定 action 或显式要求交互 -> 进入菜单 + if args.interactive or not args.action: + interactive_loop() + return + + # 在非交互模式下,将 --non-interactive 透传给子脚本,避免其内部出现 input() 交互 + if args.non_interactive: + extra_args = ["--non-interactive"] + extra_args + + # 非交互模式:直接执行指定操作 + run_action(args.action, extra_args=extra_args) + + +if __name__ == "__main__": + main() + + diff --git a/scripts/raw_data_preprocessor.py b/scripts/raw_data_preprocessor.py index b5762198..6cca59d3 100644 --- a/scripts/raw_data_preprocessor.py +++ b/scripts/raw_data_preprocessor.py @@ -1,9 +1,9 @@ import os from pathlib import Path import sys # 新增系统模块导入 -from src.chat.knowledge.utils.hash import get_sha256 sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) +from src.chat.knowledge.utils.hash import get_sha256 from src.common.logger import get_logger logger = get_logger("lpmm") @@ -59,10 +59,11 @@ def load_raw_data() -> tuple[list[str], list[str]]: - raw_data: 原始数据列表 - sha256_list: 原始数据的SHA256集合 """ - raw_data = _process_multi_files() + raw_paragraphs = _process_multi_files() sha256_list = [] sha256_set = set() - for item in raw_data: + raw_data: list[str] = [] + for item in raw_paragraphs: if not isinstance(item, str): logger.warning(f"数据类型错误:{item}") continue diff --git a/scripts/refresh_lpmm_knowledge.py b/scripts/refresh_lpmm_knowledge.py new file mode 100644 index 00000000..e70093a8 --- /dev/null +++ b/scripts/refresh_lpmm_knowledge.py @@ -0,0 +1,66 @@ +import os +import sys + +try: + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(encoding="utf-8") +except Exception: + pass + +# 确保能导入 src.* +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from src.common.logger import get_logger +from src.config.config import global_config +from src.chat.knowledge import lpmm_start_up, get_qa_manager + +logger = get_logger("refresh_lpmm_knowledge") + + +def main() -> None: + logger.info("开始刷新 LPMM 知识库(重新加载向量库与 KG)...") + + if not global_config.lpmm_knowledge.enable: + logger.warning( + "当前配置中 lpmm_knowledge.enable = false,本次仅刷新磁盘数据与内存结构," + "但聊天侧如未启用 LPMM 仍不会在问答中使用知识库。" + ) + + # 调用标准启动逻辑,内部会加载 data/embedding 与 data/rag + lpmm_start_up() + + qa_manager = get_qa_manager() + if qa_manager is None: + logger.error("刷新后 qa_manager 仍为 None,请检查是否已经成功导入过 LPMM 知识库。") + return + + # 简要输出当前知识库规模,方便人工确认 + embed_manager = qa_manager.embed_manager + kg_manager = qa_manager.kg_manager + + para_vec = len(embed_manager.paragraphs_embedding_store.store) + ent_vec = len(embed_manager.entities_embedding_store.store) + rel_vec = len(embed_manager.relation_embedding_store.store) + nodes = len(kg_manager.graph.get_node_list()) + edges = len(kg_manager.graph.get_edge_list()) + + logger.info("LPMM 知识库刷新完成,当前规模:") + logger.info( + "段落向量=%d, 实体向量=%d, 关系向量=%d, KG节点=%d, KG边=%d", + para_vec, + ent_vec, + rel_vec, + nodes, + edges, + ) + + print("\n[REFRESH] 刷新完成,请注意:") + print("- 本脚本是在独立进程内执行的,用于验证磁盘数据可以正常加载。") + print("- 若主程序已在运行且未在内部调用 lpmm_start_up() 重新初始化,仍需重启或新增管理入口来热刷新。") + print("- 如果不清楚 lpmm_start_up 是什么,只需要重启主程序即可。") + + +if __name__ == "__main__": + main() diff --git a/scripts/test_lpmm_retrieval.py b/scripts/test_lpmm_retrieval.py new file mode 100644 index 00000000..c6aeccda --- /dev/null +++ b/scripts/test_lpmm_retrieval.py @@ -0,0 +1,122 @@ +import argparse +import asyncio +import os +import sys +from typing import List, Dict, Any, Optional + +# 强制使用 utf-8,避免控制台编码报错影响 Embedding 加载 +try: + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(encoding="utf-8") +except Exception: + pass + +# 确保能导入 src.* +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from src.common.logger import get_logger +from src.config.config import global_config +from src.chat.knowledge import lpmm_start_up +from src.memory_system.retrieval_tools.query_lpmm_knowledge import query_lpmm_knowledge + +logger = get_logger("test_lpmm_retrieval") + + +DEFAULT_TEST_CASES: List[Dict[str, Any]] = [ + { + "name": "回滚一批知识", + "query": "LPMM是什么?", + "expect_keywords": ["哈希列表", "删除脚本", "OpenIE"], + }, + { + "name": "调整 LPMM 检索参数", + "query": "不同用词习惯带来的检索偏差该如何解决", + "expect_keywords": ["bot_config.toml", "lpmm_knowledge", "qa_paragraph_search_top_k"], + }, +] + + +async def run_tests(test_cases: Optional[List[Dict[str, Any]]] = None) -> None: + """简单测试 LPMM 知识库检索能力""" + if not global_config.lpmm_knowledge.enable: + logger.warning("当前配置中 lpmm_knowledge.enable 为 False,检索测试可能直接返回“未启用”。") + + logger.info("开始初始化 LPMM 知识库...") + lpmm_start_up() + logger.info("LPMM 知识库初始化完成,开始执行测试用例。") + + cases = test_cases if test_cases is not None else DEFAULT_TEST_CASES + + for case in cases: + name = case["name"] + query = case["query"] + expect_keywords: List[str] = case.get("expect_keywords", []) + + print("\n" + "=" * 60) + print(f"[TEST] {name}") + print(f"[Q] {query}") + + result = await query_lpmm_knowledge(query, limit=3) + + print("\n[RAW RESULT]") + print(result) + + status = "UNKNOWN" + hit_keywords: List[str] = [] + + if isinstance(result, str): + if "未启用" in result or "未初始化" in result or "查询失败" in result: + status = "ERROR" + elif "未找到与" in result: + status = "NO_HIT" + else: + if expect_keywords: + hit_keywords = [kw for kw in expect_keywords if kw in result] + status = "PASS" if hit_keywords else "WARN" + else: + status = "PASS" + + print("\n[CHECK]") + print(f"Status: {status}") + if expect_keywords: + print(f"Expected keywords: {expect_keywords}") + print(f"Hit keywords: {hit_keywords}") + + print("\n" + "=" * 60) + print("LPMM 检索测试完成。请根据每条用例的 Status 和命中关键词判断检索效果是否符合预期。") + + +def main() -> None: + parser = argparse.ArgumentParser( + description=( + "测试 LPMM 知识库检索能力。\n" + "如不提供参数,则执行内置的默认用例;\n" + "也可以通过 --query 与 --expect-keyword 自定义一条测试用例。" + ) + ) + parser.add_argument( + "--query", + help="自定义测试问题(单条)。提供该参数时,将仅运行这一条用例。", + ) + parser.add_argument( + "--expect-keyword", + action="append", + help="期望在检索结果中出现的关键字,可重复多次指定;仅在提供 --query 时生效。", + ) + args = parser.parse_args() + + if args.query: + custom_case = { + "name": "custom", + "query": args.query, + "expect_keywords": args.expect_keyword or [], + } + asyncio.run(run_tests([custom_case])) + else: + asyncio.run(run_tests()) + + +if __name__ == "__main__": + main() diff --git a/src/chat/knowledge/kg_manager.py b/src/chat/knowledge/kg_manager.py index ac86fa20..245d6e9e 100644 --- a/src/chat/knowledge/kg_manager.py +++ b/src/chat/knowledge/kg_manager.py @@ -1,7 +1,8 @@ import json import os import time -from typing import Dict, List, Tuple +from typing import Dict, List, Tuple, Set +import xml.etree.ElementTree as ET import numpy as np import pandas as pd @@ -98,6 +99,28 @@ class KGManager: # 加载KG self.graph = di_graph.load_from_file(self.graph_data_path) + def _rebuild_metadata_from_graph(self) -> None: + """根据当前图重建 stored_paragraph_hashes 与 ent_appear_cnt""" + nodes = self.graph.get_node_list() + edges = self.graph.get_edge_list() + + # 段落 hash:paragraph-{hash} + self.stored_paragraph_hashes = set() + for node_id in nodes: + if node_id.startswith("paragraph-"): + self.stored_paragraph_hashes.add(node_id.split("paragraph-", 1)[1]) + + # 实体出现次数:基于 entity -> paragraph 的边权 + ent_appear_cnt: Dict[str, float] = {} + for edge_tuple in edges: + src, tgt = edge_tuple[0], edge_tuple[1] + if src.startswith("entity") and tgt.startswith("paragraph"): + edge_data = self.graph[src, tgt] + weight = edge_data["weight"] if "weight" in edge_data else 1.0 + ent_appear_cnt[src] = ent_appear_cnt.get(src, 0.0) + float(weight) + + self.ent_appear_cnt = ent_appear_cnt + def _build_edges_between_ent( self, node_to_node: Dict[Tuple[str, str], float], @@ -149,6 +172,13 @@ class KGManager: ent_hash_list.add("entity" + "-" + get_sha256(triple[0])) ent_hash_list.add("entity" + "-" + get_sha256(triple[2])) ent_hash_list = list(ent_hash_list) + # 性能保护:限制同义连接的实体数量 + max_synonym_entities = global_config.lpmm_knowledge.max_synonym_entities + if max_synonym_entities and len(ent_hash_list) > max_synonym_entities: + logger.warning( + f"同义连接实体数 {len(ent_hash_list)} 超过阈值 {max_synonym_entities},跳过同义边构建以保护性能" + ) + return 0 synonym_hash_set = set() synonym_result = {} @@ -328,6 +358,13 @@ class KGManager: paragraph_search_result: ParagraphEmbedding的搜索结果(paragraph_hash, similarity) embed_manager: EmbeddingManager对象 """ + # 性能保护:关闭或超限时直接返回向量检索结果(仅基于节点规模与开关) + if ( + not global_config.lpmm_knowledge.enable_ppr + or len(self.graph.get_node_list()) > global_config.lpmm_knowledge.ppr_node_cap + ): + logger.info("PPR 已禁用或超出阈值,使用纯向量检索结果") + return paragraph_search_result, None # 图中存在的节点总集 existed_nodes = self.graph.get_node_list() @@ -357,7 +394,15 @@ class KGManager: ent_mean_scores = {} # 记录实体的平均相似度 for ent_hash, scores in ent_sim_scores.items(): # 先对相似度进行累加,然后与实体计数相除获取最终权重 - ent_weights[ent_hash] = float(np.sum(scores)) / self.ent_appear_cnt[ent_hash] + # 保护:有些实体在当前图中可能只有实体-实体关系,不会出现在 ent_appear_cnt 中 + appear_cnt = self.ent_appear_cnt.get(ent_hash) + if not appear_cnt or appear_cnt <= 0: + logger.debug( + f"实体 {ent_hash} 在 ent_appear_cnt 中不存在或计数为 0," + f"将使用 1.0 作为默认出现次数参与权重计算" + ) + appear_cnt = 1.0 + ent_weights[ent_hash] = float(np.sum(scores)) / float(appear_cnt) # 记录实体的平均相似度,用于后续的top_k筛选 ent_mean_scores[ent_hash] = float(np.mean(scores)) del ent_sim_scores @@ -434,3 +479,115 @@ class KGManager: passage_node_res = sorted(passage_node_res, key=lambda item: item[1], reverse=True) return passage_node_res, ppr_node_weights + + def delete_paragraphs( + self, + pg_hashes: List[str], + ent_hashes: List[str] | None = None, + remove_orphan_entities: bool = False, + ) -> Dict[str, int]: + """删除段落/实体节点及相关边(基于 GraphML),可选清理孤立实体,并重建元数据""" + # 要删除的节点 ID + nodes_to_delete: Set[str] = {f"paragraph-{h}" for h in pg_hashes} + if ent_hashes: + nodes_to_delete.update({f"entity-{h}" for h in ent_hashes}) + + if not os.path.exists(self.graph_data_path): + raise FileNotFoundError(f"KG图文件{self.graph_data_path}不存在") + + tree = ET.parse(self.graph_data_path) + root = tree.getroot() + + # GraphML 可能带命名空间,用尾缀判断 + def is_node(elem: ET.Element) -> bool: + return elem.tag.endswith("node") + + def is_edge(elem: ET.Element) -> bool: + return elem.tag.endswith("edge") + + graph_elem = None + for child in root: + if child.tag.endswith("graph"): + graph_elem = child + break + if graph_elem is None: + raise RuntimeError("GraphML 中未找到 节点") + + # 统计现有节点 + existing_nodes: Set[str] = set() + for elem in graph_elem: + if is_node(elem): + node_id = elem.get("id") + if node_id: + existing_nodes.add(node_id) + + deleted_nodes = len(nodes_to_delete & existing_nodes) + skipped_nodes = len(nodes_to_delete - existing_nodes) + + # 先删除指定节点及相关边 + # 删除节点 + for elem in list(graph_elem): + if is_node(elem): + node_id = elem.get("id") + if node_id and node_id in nodes_to_delete: + graph_elem.remove(elem) + + # 删除 incident edges + for elem in list(graph_elem): + if is_edge(elem): + src = elem.get("source") + tgt = elem.get("target") + if src in nodes_to_delete or tgt in nodes_to_delete: + graph_elem.remove(elem) + + orphan_removed = 0 + if remove_orphan_entities: + # 计算仍然参与边的节点 + used_nodes: Set[str] = set() + for elem in graph_elem: + if is_edge(elem): + src = elem.get("source") + tgt = elem.get("target") + if src: + used_nodes.add(src) + if tgt: + used_nodes.add(tgt) + + # 找出没有任何边的实体节点 + orphan_entities: Set[str] = set() + for elem in graph_elem: + if is_node(elem): + node_id = elem.get("id") + if node_id and node_id.startswith("entity") and node_id not in used_nodes: + orphan_entities.add(node_id) + + orphan_removed = len(orphan_entities) + + if orphan_entities: + # 删除孤立实体节点 + for elem in list(graph_elem): + if is_node(elem): + node_id = elem.get("id") + if node_id in orphan_entities: + graph_elem.remove(elem) + + # 删除与孤立实体相关的边(理论上已无,但做一次防御性清理) + for elem in list(graph_elem): + if is_edge(elem): + src = elem.get("source") + tgt = elem.get("target") + if src in orphan_entities or tgt in orphan_entities: + graph_elem.remove(elem) + + # 写回 GraphML + tree.write(self.graph_data_path, encoding="utf-8", xml_declaration=True) + + # 重新加载图并重建元数据 + self.graph = di_graph.load_from_file(self.graph_data_path) + self._rebuild_metadata_from_graph() + + return { + "deleted": deleted_nodes, + "skipped": skipped_nodes, + "orphan_removed": orphan_removed, + } diff --git a/template/bot_config_template.toml b/template/bot_config_template.toml index 0ea0d7ca..27da018f 100644 --- a/template/bot_config_template.toml +++ b/template/bot_config_template.toml @@ -173,24 +173,27 @@ ban_msgs_regex = [ [lpmm_knowledge] # lpmm知识库配置 enable = false # 是否启用lpmm知识库 lpmm_mode = "agent" -# 可选:classic经典模式,agent 模式,结合最新的记忆一同使用 -rag_synonym_search_top_k = 10 # 同义词搜索TopK -rag_synonym_threshold = 0.8 # 同义词阈值(相似度高于此阈值的词语会被认为是同义词) -info_extraction_workers = 3 # 实体提取同时执行线程数,非Pro模型不要设置超过5 -qa_relation_search_top_k = 10 # 关系搜索TopK -qa_relation_threshold = 0.5 # 关系阈值(相似度高于此阈值的关系会被认为是相关的关系) -qa_paragraph_search_top_k = 1000 # 段落搜索TopK(不能过小,可能影响搜索结果) +# 可选择classic传统模式/agent 模式,结合新的记忆一同使用 +rag_synonym_search_top_k = 10 # 同义检索TopK +rag_synonym_threshold = 0.8 # 同义阈值,相似度高于该值的关系会被当作同义词 +info_extraction_workers = 3 # 实体抽取同时执行线程数,非Pro模型不要设置超过5 +qa_relation_search_top_k = 10 # 关系检索TopK +qa_relation_threshold = 0.5 # 关系阈值,相似度高于该值的关系会被认为是相关关系 +qa_paragraph_search_top_k = 1000 # 段落检索TopK(不能过小,可能影响搜索结果) qa_paragraph_node_weight = 0.05 # 段落节点权重(在图搜索&PPR计算中的权重,当搜索仅使用DPR时,此参数不起作用) qa_ent_filter_top_k = 10 # 实体过滤TopK qa_ppr_damping = 0.8 # PPR阻尼系数 -qa_res_top_k = 3 # 最终提供的文段TopK -embedding_dimension = 1024 # 嵌入向量维度,应该与模型的输出维度一致 +qa_res_top_k = 3 # 最终提供段落TopK +embedding_dimension = 1024 # 嵌入向量维度,输出维度一致 +# 性能与降级参数(低配机器可下调) +# 低配机器参考:单/双核或内存≤4GB(如轻量云主机/云函数/开发板),建议先关闭PPR并降低并发 +max_embedding_workers = 3 # 嵌入/抽取并发线程数 +embedding_chunk_size = 4 # 每批嵌入的条数 +max_synonym_entities = 2000 # 同义边参与的实体数上限,超限则跳过 +enable_ppr = true # 是否启用PPR,低配机器可关闭 +ppr_node_cap = 8000 # 图节点数超过该值时跳过PPR +webui_graph_default_limit = 200 # WebUI /graph 默认返回的最大节点数,避免大图负载 -# keyword_rules 用于设置关键词触发的额外回复知识 -# 添加新规则方法:在 keyword_rules 数组中增加一项,格式如下: -# { keywords = ["关键词1", "关键词2"], reaction = "触发这些关键词时的回复内容" } -# 例如,添加一个新规则:当检测到“你好”或“hello”时回复“你好,有什么可以帮你?” -# { keywords = ["你好", "hello"], reaction = "你好,有什么可以帮你?" } [keyword_reaction] keyword_rules = [ { keywords = ["人机", "bot", "机器", "入机", "robot", "机器人", "ai", "AI"], reaction = "有人说你是人机或者机器人,否定这一事实,攻击或反问他。注意,只有认为你是机器人才回复,否则不要否认" }, @@ -272,4 +275,3 @@ chat_prompts = [] # 此系统暂时移除,无效配置 [relationship] enable_relationship = true # 是否启用关系系统 -