Merge pull request #1386 from A-Dawn/feat-lpmm知识库加强

LPMM 知识库删除能力与自检脚本增强(附关键健壮性修复)
This commit is contained in:
UnCLAS-Prommer
2025-12-18 22:56:12 +08:00
committed by GitHub
16 changed files with 2505 additions and 55 deletions

2
.gitignore vendored
View File

@@ -329,3 +329,5 @@ config.toml
interested_rates.txt
MaiBot.code-workspace
*.lock
actionlint

View File

@@ -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)

View File

@@ -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` 条段落组合成“知识上下文”给问答模型。
- 太多:增加模型负担、阅读更多文字;
- 太少:信息不够充分,一般 35 比较平衡。
> 调参建议:
> - 优先在 `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/贵价模型时建议不要太大,避免并行费用过高;
- 一般 24 就能取得较好平衡。
- `enable_ppr`
是否启用个性化 PageRankPPR图检索
- `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]` 段恢复为仓库中的默认配置;
- 重启主程序,即可回到“出厂设置”。
通过本指南中的参数调节,你可以在“检索质量”“响应速度”“系统资源占用”之间找到适合自己麦麦和机器的平衡点!

View File

@@ -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 脚本中串联上述命令。该管理脚本已支持参数化与非交互调用,适合作为二次封装的基础入口。

411
docs-src/lpmm_user_guide.md Normal file
View File

@@ -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`
接下来我们会用 `<OPENIE>` 来代指这类文件。
### 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/<OPENIE>.json
```
你会看到该批次:
- 段落总计多少条、向量库剩余多少、KG 中剩余多少;
- 实体、关系的类似统计;
- 少量示例段落/实体内容预览。
2. 确认无误后,按批次删除:
```bash
.\.venv\Scripts\python.exe scripts/delete_lpmm_items.py ^
--openie-file data/openie/<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/<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/<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 知识库。***

View File

@@ -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()

View File

@@ -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__":

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

541
scripts/lpmm_manager.py Normal file
View File

@@ -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()

View File

@@ -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

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()
# 段落 hashparagraph-{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 中未找到 <graph> 节点")
# 统计现有节点
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,
}

View File

@@ -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 # 是否启用关系系统