RAG设计之语义感知策略
文章目录
又到了 RAG 优化环节了🥹,这次遇到了大文档有效信息截断的问题,分析很多 case 发现扩展 token+段落metadata 融合,也不咋好使,最终摸索实现“结构递归切分 + should_merge 语义合并 + embedding 相似度 + 大模型灰区判断”的 chunk 策略。
1. 背景
为什么需要语义感知切分?
当前项目的长期知识库链路是:
|
|
没啥特殊的,这是经典做法:各类 parser 先把 PDF、DOCX、Markdown、HTML、TXT、JSON、Excel 转成 sections,然后由merge 模块按 token 阈值合并,再进入 tokenize_chunks()。
不同格式在当前链路中的差异如下:
| 格式 | 当前处理方式 | 语义切分改造重点 |
|---|---|---|
通过 layout_recognize 选择 DeepDOC、Plain Text 或 Auto 策略;文本走 tokenize_chunks(),表格走 tokenize_table()。 |
保留标题层级、页码、坐标和跨页段落;复杂 PDF 不应丢失引用定位。 | |
| DOCX | Docx() 输出段落、表格和图片,文本走 naive_merge_docx()。 |
保留 Heading 层级、列表项和图片上下文,语义合并时不能丢失图片拼接能力。 |
| Excel | ExcelParser() 或 html4excel 输出行/表格文本。 |
表格类 chunk 应优先保持行列语义,不强行和正文段落混合。 |
| TXT / 代码文本 | TxtParser() 按分隔符和 token 阈值产生 sections。 |
缺少显式标题时,需要通过段落、列表和相似度补足边界判断。 |
| Markdown | Markdown() 会抽取表格和剩余正文。 |
标题层级天然清晰,适合作为结构递归切分的优先输入。 |
| HTML | HtmlParser() 抽取正文段落。 |
需要恢复 h1 到 h6、列表、表格等 DOM 结构。 |
| JSON | JsonParser(chunk_token_num) 做结构化切分。 |
应保留 key path,避免把字段名和字段值拆开。 |
固定 token 合并能保证 chunk 大小可控,但它不理解语义边界,容易出现四类截断:
- 标题和正文被拆开:检索命中正文时,缺少章节主题。
- 前导段和列表被拆开:检索命中列表项时,不知道列表在说明什么。
- 转折和条件说明被拆开:答案缺少“但是”“除非”“需要注意”的限制条件。
- 同一知识点的连续段落被拆开:定义、步骤、例外、结果分布在多个 chunk。
RAG 的检索单元是 chunk。如果一个标准答案需要的证据被切散,后续无论 rerank 和大模型多强,都可能拿不到完整上下文。
因此切分策略要优先保证“一个 chunk 内尽量包含一个完整可回答的语义单元”。
就像刚才这段话,是有递进关系的,你人类读的时候,不可能拆散吧,所以语义节点提上日程。
2. 设计原则
语义感知切分千万不能把整篇文档交给大模型自由切分,有三个大问题:
- 成本高:每次上传文档都要对长文本调用模型,费用可顶不住。
- 不稳定:同一文档多次切分结果可能不一致,很难做回归评估。
- 难定位:切分错误时,不容易知道是结构解析、模型判断还是 prompt 问题。
所以必须通过工程化的措施去处理:
- parser 负责恢复结构:章节、小节、段落、列表、表格、页码和坐标。
- 规则负责处理确定性强的边界:列表前导、转折词、指代词。
- embedding 负责判断相邻段落是否仍属于同一语义主题。
- 大模型只处理灰区边界:相似度不上不下、规则无法判断时,返回
MERGE或SPLIT。 - soft limit、hard limit 和 overlap 负责控制 chunk 大小,避免语义合并无限增长。
这样切分的话既有语义判断,又保持可复现、可解释、可评估。
3. 主流程
flowchart TD A["原始文档"] --> B["parser 解析<br/>PDF / DOCX / MD / HTML / TXT"] B --> C["结构化 Block<br/>章节 / 小节 / 段落 / 列表 / 表格"] C --> D["递归切分<br/>章节 -> 小节 -> 段落 -> 句子"] D --> E["相邻 Block 边界判断"] E --> F["should_merge 规则<br/>列表前导 / 转折词 / 指代词"] F --> G["embedding 相似度<br/>cosine > 0.75"] G --> H["灰区判断<br/>0.55 <= sim <= 0.75 调 LLM"] H --> I["chunk 组装<br/>soft limit / hard limit / overlap"] I --> J["补充 metadata<br/>section_path / page / position / merge_reasons"] J --> K["tokenize_chunks()"] K --> L["generate_embedding()"] L --> M["Elasticsearch 入库"]
这个实现和之前的实现有点小区别,从直接切分加了中间层:
|
|
升级为:
|
|
4. 数据结构设计
4.1 Block
Block 是 parser 输出和 chunk 之间的中间结构。它不直接入库,而是用于保留文档结构和边界判断依据。
|
|
字段含义:
| 字段 | 说明 |
|---|---|
text |
当前结构块文本。 |
block_type |
heading、paragraph、list_item、table、code 等。 |
level |
章节层级,一级标题为 1,二级标题为 2,普通段落可设为 99。 |
section_path |
当前块所属标题路径,例如 ["第 3 章 用户认证", "3.1 登录流程"]。 |
page_num_int |
PDF 页码,用于引用定位。 |
position_int |
PDF 坐标,用于引用高亮。 |
children |
结构树子节点。 |
image |
DOCX 或 PDF 截图上下文,兼容现有 naive_merge_docx() 能力。 |
4.2 ChunkDraft
ChunkDraft 是语义合并后的待入库 chunk。
|
|
最终进入 ES 前,需要把这些 metadata 合并到现有 doc 字段中。当前 tokenize_chunks() 能从 PDF parser 回填 page_num_int、position_int 和 top_int。
新增的 section_path、block_types、semantic_merge_reasons 可作为 keyword/text 字段随 chunk 一起保存,或者先保存在 content_with_weight 的 header 中,等 mapping 稳定后再结构化入库。
这块倒是影响不大。
5. 结构递归切分
结构递归切分的目标是先找自然边界,再用 token 限制兜底。
优先级固定为:
|
|
伪代码:
|
|
- 如果整章不超过
SOFT_LIMIT,整章保留。 - 如果章节太长,按小节递归。
- 如果小节太长,按段落递归。
- 如果段落太长,才按句号、分号、换行等句子边界切。
- 如果单句仍超过
HARD_LIMIT,按 token 硬切,并在下一 chunk 加 overlap。
这样可以避免一开始就按固定长度切断结构。
6. should_merge 规则
should_merge(prev, curr) 决定相邻两个 block 是否应放入同一个 chunk。
|
|
6.1 列表前导合并
需要合并的典型文本:
|
|
如果切分后只命中列表项,回答会缺少“这是停车场规划验收流程”的上下文。
判断规则:
|
|
6.2 转折和承接词合并
需要合并的典型文本:
|
|
第二、三句依赖上一段,不适合独立成为 chunk。
|
|
6.3 指代词合并
需要合并的典型文本:
|
|
该xxx 依赖上一段定义。如果单独检索到第二段,会丢失指代对象。
|
|
6.4 embedding 相似度合并
对于没有明显规则信号的相邻段落,用 embedding 判断是否仍在讲同一主题。
当前工程已有 app/service/core/rag/nlp/model.py::generate_embedding(),可以复用它批量生成向量,再用 cosine similarity 判断:
|
|
阈值固定为:
|
|
6.5 大模型灰区判断
大模型只处理灰区,不做全量切分。触发条件:
|
|
Prompt 固定为:
|
|
结果需要缓存,缓存 key 可以由文档名、相邻 block 文本 hash、模型名组成:
|
|
缓存可以优先放 Redis,避免同一文档重复上传或评估回放时重复调用模型。
7. chunk 组装策略
chunk 大小策略固定为:
|
|
组装逻辑:
|
|
关键点:
- 小于
SOFT_LIMIT时正常合并,减少碎片。 - 超过
SOFT_LIMIT但should_merge=True时,允许合并到HARD_LIMIT。 - 超过
HARD_LIMIT时必须切分,但新 chunk 带上上一 chunk 的OVERLAP。 - 每个 chunk 前面补
section_path,保证标题上下文不丢。 semantic_merge_reasons记录合并原因,方便评估和排查。
最终 chunk 格式:
|
|
新增语义解析层:
|
|
为了更好的测试,也要兼容策略:
|
|
这样可以保留原策略作为 baseline,方便在评估系统中做 baseline/current 对比。
9. 评估指标
语义切分不能只看 chunk 数量,要看证据是否完整、检索是否改善、答案是否更准。
9.1 基础 chunk 质量
| 指标 | 作用 |
|---|---|
| chunk 空值率 | 检查 parser 或合并策略是否产生空 chunk。 |
| 平均 token | 判断 chunk 是否过碎或过长。 |
| 最大 token | 检查是否突破 HARD_LIMIT = 800。 |
| 标题继承率 | 检查 chunk 是否带上 section_path。 |
| 表格保留率 | 检查表格是否独立保留,而不是被正文切散。 |
| PDF 坐标保留率 | 检查 page_num_int、position_int 是否从 parser 透传到最终 chunk。 |
9.2 截断率
定义:
|
|
所谓的阶段率不能是主观体验,必须是基于金标 evidence case 的覆盖结果。
9.3 证据完整率
定义:
|
|
如果问题答案需要“定义 + 条件 + 例外”,只有 chunk 同时覆盖这些要点,才算完整。
9.4 邻接覆盖率
定义:
|
|
这个指标用于评估 OVERLAP = xxx 是否足够。如果邻接覆盖率高但单 chunk 完整率低,说明 chunk 可能切得偏碎。
9.5 检索排序指标
复用现有 RAG 评估系统:
| 指标 | 作用 |
|---|---|
Recall@K |
正确 chunk 是否进入 TopK。 |
MRR |
正确 chunk 排名是否靠前。 |
nDCG@K |
多证据场景下排序质量。 |
| 空召回率 | 新切分是否导致召回失败。 |
9.6 答案和引用指标
| 指标 | 作用 |
|---|---|
| 答案要点覆盖率 | 模型回答是否覆盖标准答案要点。 |
| 幻觉率 | 回答是否包含 evidence 不支持的内容。 |
| 引用准确率 | 引用 chunk 是否真实支撑答案。 |
| 引用召回率 | 标准 evidence 对应引用是否被展示。 |
10. 评估与反馈闭环
flowchart TD
A["金标集<br/>question / answer / evidence"] --> B["baseline<br/>naive_merge"]
A --> C["current<br/>semantic_chunk"]
B --> D["检索与答案评估"]
C --> D
D --> E["指标报告<br/>截断率 / 证据完整率 / Recall / MRR / 引用"]
E --> F{"失败类型归因"}
F --> G["parser 问题<br/>标题/列表/表格未识别"]
F --> H["chunk 问题<br/>should_merge 规则缺失"]
F --> I["embedding 问题<br/>阈值或向量质量不稳"]
F --> J["LLM 判断问题<br/>prompt 或缓存错误"]
F --> K["overlap 问题<br/>硬切后上下文不足"]
G --> L["修 parser 或 Block 构造"]
H --> M["补规则和测试 case"]
I --> N["调 0.75 / 0.55 阈值"]
J --> O["改 prompt / 加置信度"]
K --> P["调 OVERLAP 或 HARD_LIMIT"]
L --> Q["失败 case 回流金标集"]
M --> Q
N --> Q
O --> Q
P --> Q
Q --> A
反馈闭环的重点是把失败 case 分层归因:
- parser 问题:章节、小节、表格、列表没有被识别成结构。
- chunk 问题:规则没有把相关段落合并。
- embedding 问题:相似段落分数偏低,或无关段落分数偏高。
- LLM 判断问题:灰区判断错,或者输出不符合 JSON。
- overlap 问题:硬切后下一 chunk 缺少前文。
每次修复后,把失败 case 加入金标集,重新跑 baseline/current。只有截断率、证据完整率、Recall@K、引用准确率同时不退化,才认为策略有效。
文章作者 沐桢