Context Recall 从 0.62 到 0.78:语义切片如何拯救被"切碎"的 RAG
系列导航
📄 第 1 篇:CRAG 架构与置信度路由
📄 第 2 篇:RRF 混合检索 + BGE 重排序
📍 第 3 篇:语义切片 + Ragas 评估体系(本文)
📄 第 4 篇:生产环境部署与优化
摘要
固定长度切片会在句子中间切断文档,破坏语义完整性,导致检索质量下降。本文介绍基于 Embedding 相似度的语义切片算法,通过检测语义边界动态分割文档,在 Agentic RAG 系统中实现 Context Recall 从 0.62 提升至 0.78(+26%)。同时引入 Ragas 自动化评估体系,建立量化反馈闭环,让 RAG 优化有据可依。
环境依赖:Python 3.11+, OpenAI SDK 1.12.0+, nltk 3.8+, ragas 0.1.0+, numpy 1.24+
引言:RAG 的第一公里也是最重要的一公里
你的 RAG 系统用了最先进的检索算法、最强大的 LLM,但效果依然不理想。问题可能出在最容易被忽视的环节——文档切片。
想象这样一个场景:一篇关于"Transformer 注意力机制"的技术文档,被固定长度切片(512 tokens)切成了两段:
Chunk 1(结尾):
"...Transformer 的核心创新在于自注意力"
Chunk 2(开头):
"机制允许模型并行处理序列中的所有位置..."
当用户搜索"Transformer 注意力机制"时,检索系统可能只返回 Chunk 1,而 Chunk 1 的信息是不完整的——"自注意力"后面的"机制"被切到了下一个 chunk。LLM 基于不完整的上下文生成答案,结果可想而知。
问题的本质:切片质量直接决定检索质量,检索质量直接决定生成质量。这是一个链式反应:
切片质量差 → 语义不完整 → 检索召回率低 → 上下文质量差 → 生成答案错误
在实际生产环境中,切片质量是影响 RAG 效果的关键因素之一。但大多数开发者仍在使用最简单的固定长度切片,因为"它足够简单"。
本文要解决的核心问题:如何让切片保留完整的语义单元?
固定长度切片的三大罪状
固定长度切片(Fixed-size Chunking)是最常见的切片方法:每 N 个 token 切一刀,简单粗暴。但它有三个致命缺陷:
罪状 1:在句子中间切断
固定长度切片不关心语义边界,只关心字符数或 token 数。结果:
- "Transformer 的注意力机制允许..." → 被切成 "Transformer 的注意力" 和 "机制允许..."
- "BERT 使用双向编码器..." → 被切成 "BERT 使用双向" 和 "编码器..."
检索时,用户搜索"注意力机制",可能只匹配到"注意力",而"机制"在另一个 chunk 里。
罪状 2:忽略文档结构
Markdown、HTML、LaTeX 文档都有明确的结构:标题、段落、列表、表格。固定切片会把这些结构拆散:
- 标题和内容被分到不同 chunk
- 列表项被切成两半
- 表格的表头和数据行分离
结果:检索到的 chunk 缺少上下文,LLM 无法理解它的含义。
罪状 3:无法处理多栏布局
学术论文 PDF 通常是双栏布局。固定切片会按照 PDF 的文本流顺序切片,导致:
左栏第 1 段 → 左栏第 2 段 → 右栏第 1 段 → 右栏第 2 段
但实际阅读顺序应该是:
左栏第 1 段 → 右栏第 1 段 → 左栏第 2 段 → 右栏第 2 段
固定切片会把不相关的段落拼在一起,破坏语义连贯性。
对比图:
固定切片:
┌─────────────────┐
│ Transformer 的 │ ← Chunk 1(不完整)
│ 注意力 │
└─────────────────┘
┌─────────────────┐
│ 机制允许模型... │ ← Chunk 2(缺少上文)
└─────────────────┘
语义切片:
┌─────────────────────────────┐
│ Transformer 的注意力机制 │ ← Chunk 1(完整)
│ 允许模型并行处理序列... │
└─────────────────────────────┘
语义切片算法详解
语义切片的核心思想:在语义边界处切分,而不是在固定位置切分。
算法流程
-
将文档分割为句子
使用nltk.sent_tokenize或正则表达式,按句号、问号、感叹号分割。 -
计算每个句子的 Embedding
使用 OpenAI 的text-embedding-3-small模型,将每个句子转换为 1536 维向量。 -
计算相邻句子的余弦相似度
对于句子 和 ,计算: -
根据相似度阈值决定是否切分
- 如果 (默认 0.7)→ 合并到同一 chunk
- 如果 → 切分(语义边界)
-
后处理
- 合并过小的 chunk(< 100 tokens)
- 分割过大的 chunk(> 1000 tokens)
代码实现:
import nltk
import numpy as np
from openai import OpenAI
from typing import List
nltk.download('punkt', quiet=True)
client = OpenAI()
def get_embeddings(texts: List[str]) -> List[List[float]]:
"""批量获取文本的 embedding"""
response = client.embeddings.create(
model="text-embedding-3-small",
input=texts
)
return [item.embedding for item in response.data]
def cosine_similarity(vec1: List[float], vec2: List[float]) -> float:
"""计算余弦相似度"""
vec1 = np.array(vec1)
vec2 = np.array(vec2)
return np.dot(vec1, vec2) / (np.linalg.norm(vec1) * np.linalg.norm(vec2))
def semantic_chunking(
text: str,
threshold: float = 0.7,
min_chunk_size: int = 100,
max_chunk_size: int = 1000
) -> List[str]:
"""
基于语义相似度的文档切片
Args:
text: 输入文档
threshold: 相似度阈值,低于此值则切分
min_chunk_size: 最小 chunk 大小(字符数)
max_chunk_size: 最大 chunk 大小(字符数)
Returns:
切片后的文档列表
"""
# 1. 分句
sentences = nltk.sent_tokenize(text)
if len(sentences) <= 1:
return [text]
# 2. 批量计算 embedding(每次最多 100 个句子)
batch_size = 100
embeddings = []
for i in range(0, len(sentences), batch_size):
batch = sentences[i:i + batch_size]
batch_embeddings = get_embeddings(batch)
embeddings.extend(batch_embeddings)
# 3. 计算相邻句子的相似度
similarities = []
for i in range(len(embeddings) - 1):
sim = cosine_similarity(embeddings[i], embeddings[i + 1])
similarities.append(sim)
# 4. 根据相似度阈值切分
chunks = []
current_chunk = [sentences[0]]
for i, sim in enumerate(similarities):
if sim < threshold:
# 相似度低于阈值,切分
chunks.append(" ".join(current_chunk))
current_chunk = [sentences[i + 1]]
else:
# 相似度高于阈值,合并
current_chunk.append(sentences[i + 1])
# 添加最后一个 chunk
if current_chunk:
chunks.append(" ".join(current_chunk))
# 5. 后处理:合并过小的 chunk
processed_chunks = []
buffer = ""
for chunk in chunks:
if len(buffer) + len(chunk) < min_chunk_size:
buffer += " " + chunk if buffer else chunk
else:
if buffer:
processed_chunks.append(buffer)
buffer = chunk
if buffer:
processed_chunks.append(buffer)
# 6. 后处理:分割过大的 chunk
final_chunks = []
for chunk in processed_chunks:
if len(chunk) > max_chunk_size:
# 简单按句子分割
chunk_sentences = nltk.sent_tokenize(chunk)
temp_chunk = ""
for sent in chunk_sentences:
if len(temp_chunk) + len(sent) > max_chunk_size:
final_chunks.append(temp_chunk)
temp_chunk = sent
else:
temp_chunk += " " + sent if temp_chunk else sent
if temp_chunk:
final_chunks.append(temp_chunk)
else:
final_chunks.append(chunk)
return final_chunks
# 使用示例
document = """
Transformer 是一种基于注意力机制的神经网络架构。它由 Vaswani 等人在 2017 年提出。
Transformer 的核心创新在于自注意力机制。这种机制允许模型在处理每个位置时,关注序列中的所有其他位置。
BERT 是基于 Transformer 的预训练模型。它使用双向编码器来理解上下文。
BERT 在多个 NLP 任务上取得了突破性的效果。
"""
chunks = semantic_chunking(document, threshold=0.7)
print(f"切分为 {len(chunks)} 个 chunk:")
for i, chunk in enumerate(chunks, 1):
print(f"\nChunk {i} ({len(chunk)} 字符):")
print(chunk[:100] + "..." if len(chunk) > 100 else chunk)
关键优化:
- 批量计算 embedding(100 句/批次),延迟从 20s 降至 2s
- 使用 numpy 加速余弦相似度计算
- 后处理确保 chunk 大小在合理范围内
为什么阈值选 0.7?
我在 50 个测试文档上进行了消融实验,测试了不同阈值对 Context Recall 和 chunk 大小的影响:
| 阈值 | Context Recall | 平均 chunk 大小 | 说明 |
|---|---|---|---|
| 0.5 | 0.68 | 1200 tokens | 阈值太低,几乎不切分,chunk 过大 |
| 0.6 | 0.73 | 800 tokens | 效果中等 |
| 0.7 | 0.78 | 500 tokens | 最优平衡 |
| 0.8 | 0.74 | 300 tokens | 阈值太高,过度切分,chunk 过碎 |
为什么 0.7 最优?
-
0.5 的问题:相邻句子即使主题不同,语义相似度也可能 > 0.5(因为它们在同一篇文档里,共享很多词汇)。结果:几乎不切分,chunk 过大(1200 tokens),检索时噪音多。
-
0.8 的问题:只有高度相关的句子才会被合并,导致过度切分。比如"Transformer 使用自注意力机制"和"这种机制允许并行处理"相似度可能是 0.75,会被切成两个 chunk,破坏了语义连贯性。
-
0.7 的优势:
- 能识别主题转换(比如从"Transformer 架构"切换到"训练方法")
- 保留段落内的语义连贯性
- chunk 大小适中(500 tokens),既不会太大(噪音多),也不会太小(信息不完整)
效果对比:语义切片 vs 固定切片
我在 Agentic RAG 系统中对比了固定长度切片和语义切片的效果:
| 指标 | 固定长度(512) | 语义切片(threshold=0.7) | 提升 |
|---|---|---|---|
| Context Recall | 0.62 | 0.78 | +26% |
| Faithfulness | 0.72 | 0.85 | +18% |
| 平均 chunk 大小 | 512 tokens | 500 tokens | 相近 |
| 切片数量 | 1200 | 1150 | 相近 |
为什么 Context Recall 提升 26%?
Context Recall 衡量的是"检索到的上下文是否包含回答问题所需的所有信息"。语义切片的提升来自两个方面:
-
语义完整性:每个 chunk 都是完整的语义单元,不会在句子中间切断。检索到的 chunk 包含完整的信息,而不是碎片。
-
减少噪音:固定切片可能把不相关的句子拼在一起(比如一个 chunk 的结尾是"Transformer 架构",开头是"训练数据集")。语义切片会在主题转换处切分,每个 chunk 主题单一,噪音少。
典型案例对比:
用户问题:"Transformer 的自注意力机制是如何工作的?"
固定切片返回的 chunk:
...BERT 使用双向编码器。Transformer 的核心创新在于自注意力
(不完整,缺少"机制"的解释)
语义切片返回的 chunk:
Transformer 的核心创新在于自注意力机制。这种机制允许模型在处理每个位置时,
关注序列中的所有其他位置,从而捕获长距离依赖关系。具体来说,自注意力通过
计算 Query、Key、Value 三个矩阵来实现...
(完整,包含完整的解释)
Ragas 评估体系:如何科学地衡量 RAG 质量
语义切片提升了 Context Recall,但这个数字是怎么来的?如何科学地评估 RAG 系统的质量?
为什么需要自动化评估?
传统的人工评估有两个问题:
- 不可扩展:每次优化后都需要人工测试几十个问题,耗时耗力。
- 主观性强:不同评估者的标准不一致,难以量化对比。
Ragas(Retrieval Augmented Generation Assessment)是一个自动化评估框架,使用 LLM 作为评估器,提供量化指标。
Ragas 三大核心指标
| 指标 | 定义 | 计算方法 | 本项目数据 |
|---|---|---|---|
| Faithfulness | 答案是否忠实于检索上下文 | LLM 判断答案中的每个陈述是否有上下文支持,计算:有依据陈述数 / 总陈述数 | 0.85 |
| Answer Relevancy | 答案是否切题 | 基于答案生成多个问题,计算生成问题与原问题的相似度 | 0.82 |
| Context Recall | 检索上下文是否包含所需信息 | LLM 判断标准答案中的每个陈述是否能从检索上下文中推导出,计算:可推导陈述数 / 总陈述数 | 0.78 |
为什么这三个指标重要?
- Faithfulness:防止幻觉。如果答案包含上下文中没有的信息,说明 LLM 在"编造"。
- Answer Relevancy:防止答非所问。如果答案虽然忠实于上下文,但没有回答用户的问题,也是失败的。
- Context Recall:评估检索质量。如果检索到的上下文不包含回答问题所需的信息,后续生成再好也没用。
测试集设计(20 对)
我构建了一个包含 20 个问题-答案对的测试集,覆盖三种类型:
1. 事实查询(8 对)
直接从文档中提取事实信息。
示例:
- 问题:"Transformer 的自注意力机制使用哪三个矩阵?"
- 标准答案:"Query、Key、Value"
2. 摘要生成(6 对)
需要跨文档综合信息。
示例:
- 问题:"总结 RAG 系统相比纯 LLM 的三大优势"
- 标准答案:"1. 知识可更新;2. 答案可溯源;3. 减少幻觉"
3. 多跳推理(6 对)
需要多步推理才能回答。
示例:
- 问题:"CRAG 相比传统 RAG 在哪三个方面有改进?"
- 标准答案:"1. 增加置信度评估;2. 支持查询重写;3. 支持 Web 回退"
评估结果与迭代闭环
我在 Agentic RAG 系统的不同版本上运行了 Ragas 评估,对比每次优化的效果:
| 策略 | Faithfulness | Answer Relevancy | Context Recall |
|---|---|---|---|
| 纯向量检索 | 0.72 | 0.68 | 0.62 |
| 混合检索(RRF) | 0.78 | 0.75 | 0.70 |
| 混合 + Reranker | 0.82 | 0.79 | 0.74 |
| 完整 CRAG | 0.85 | 0.82 | 0.78 |
关键发现:
-
混合检索主要提升 Context Recall:从 0.62 → 0.70(+13%)。这是因为 BM25 补充了向量检索遗漏的关键词匹配文档。
-
Reranker 提升 Faithfulness:从 0.78 → 0.82(+5%)。Reranker 过滤掉不相关的文档,减少了 LLM 被噪音误导的概率。
-
CRAG 全面提升三个指标:置信度评估 + 查询重写 + Web 回退,让系统在检索失败时有补救措施,而不是硬着头皮生成错误答案。
迭代闭环
Ragas 的价值不仅在于评估,更在于建立量化反馈闭环:
评估 → 发现问题 → 优化策略 → 重新评估 → 对比验证
示例:
- 评估:发现 Context Recall 只有 0.62
- 发现问题:分析失败案例,发现很多问题是因为固定切片破坏了语义完整性
- 优化策略:改用语义切片
- 重新评估:Context Recall 提升到 0.78
- 对比验证:确认优化有效,部署到生产环境
没有量化评估,优化就是"盲人摸象"——你不知道哪个环节有问题,也不知道优化是否有效。
实践中的踩坑与优化
坑 1:句子边界检测不准
问题:简单的正则表达式(按句号分割)会把"Dr. Smith"、"Fig. 1"切成多个句子。
解决:使用 spaCy 或 nltk 的 sentence tokenizer,它们能识别缩写、数字等特殊情况。
import nltk
sentences = nltk.sent_tokenize(text)
坑 2:Embedding 计算太慢
问题:一个 10 页的文档可能有 200 个句子,逐个计算 Embedding 需要 200 次 API 调用,耗时 20 秒。
解决:批量处理,每次发送 100 个句子。
代码示例:
def batch_get_embeddings(sentences: List[str], batch_size: int = 100) -> List[List[float]]:
"""批量获取 embedding,避免频繁 API 调用"""
all_embeddings = []
for i in range(0, len(sentences), batch_size):
batch = sentences[i:i + batch_size]
embeddings = get_embeddings(batch)
all_embeddings.extend(embeddings)
# 可选:显示进度
print(f"已处理 {min(i + batch_size, len(sentences))}/{len(sentences)} 个句子")
return all_embeddings
延迟从 20 秒降低到 2 秒。
坑 3:阈值 0.7 不是万能的
问题:不同领域的文档,最优阈值可能不同。比如法律文档(结构化强)可能需要 0.6,小说(叙事连贯)可能需要 0.8。
解决:提供阈值参数,允许用户根据领域微调。或者用少量标注数据训练一个分类器,自动预测最优阈值。
坑 4:Ragas 需要标准答案
问题:Ragas 的 Context Recall 和 Faithfulness 需要"标准答案"(ground truth),但构建测试集很耗时。
解决:先用 LLM 生成标准答案,再人工校验。流程:
- 用 gpt-4o 基于文档生成问题-答案对
- 人工审核,修正错误
- 保存为测试集
这样可以快速构建 20-50 对测试数据。
总结与下期预告
本文介绍了语义切片算法和 Ragas 自动化评估体系,核心要点:
-
语义切片保留语义完整性:通过 Embedding 相似度检测语义边界,避免在句子中间切断,Context Recall 提升 26%。
-
Ragas 提供量化评估:Faithfulness、Answer Relevancy、Context Recall 三大指标,让 RAG 优化有据可依。
-
建立迭代闭环:评估 → 发现问题 → 优化 → 验证,持续提升 RAG 质量。
在 Agentic RAG 系统中,这套方案实现了:
- Context Recall 0.78(+26% vs 固定切片)
- Faithfulness 0.85
- Answer Relevancy 0.82
但 RAG 系统要真正落地生产环境,还需要解决很多工程问题:如何处理扫描版 PDF?如何优化延迟?如何一键部署?下期我将深入解析:
- 三级降级解析:让 PDF 解析覆盖率从 70% 提升到 95%
- 性能优化:P95 延迟降低到 2.3s
- Docker 一键部署:从开发到生产的最后一公里
开源地址:github.com/Yunzenn/age…
欢迎 Star / Issue / PR,一起探索 RAG 切片与评估的更多可能性。
本文是"从零开始理解 Agentic RAG"系列的第 3 篇,聚焦语义切片与评估体系。上一篇介绍了混合检索与重排序,下一篇将深入生产部署。