语义切片 + Ragas 评估体系

5 阅读14分钟

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(完整)
│ 允许模型并行处理序列...     │
└─────────────────────────────┘

语义切片算法详解

语义切片的核心思想:在语义边界处切分,而不是在固定位置切分

算法流程

  1. 将文档分割为句子
    使用 nltk.sent_tokenize 或正则表达式,按句号、问号、感叹号分割。

  2. 计算每个句子的 Embedding
    使用 OpenAI 的 text-embedding-3-small 模型,将每个句子转换为 1536 维向量。

  3. 计算相邻句子的余弦相似度
    对于句子 sis_isi+1s_{i+1},计算:

    similarity(si,si+1)=emb(si)emb(si+1)emb(si)emb(si+1)\text{similarity}(s_i, s_{i+1}) = \frac{\text{emb}(s_i) \cdot \text{emb}(s_{i+1})}{\|\text{emb}(s_i)\| \|\text{emb}(s_{i+1})\|}
  4. 根据相似度阈值决定是否切分

    • 如果 similaritythreshold\text{similarity} \geq \text{threshold}(默认 0.7)→ 合并到同一 chunk
    • 如果 similarity<threshold\text{similarity} < \text{threshold} → 切分(语义边界)
  5. 后处理

    • 合并过小的 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.50.681200 tokens阈值太低,几乎不切分,chunk 过大
0.60.73800 tokens效果中等
0.70.78500 tokens最优平衡
0.80.74300 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 Recall0.620.78+26%
Faithfulness0.720.85+18%
平均 chunk 大小512 tokens500 tokens相近
切片数量12001150相近

为什么 Context Recall 提升 26%?

Context Recall 衡量的是"检索到的上下文是否包含回答问题所需的所有信息"。语义切片的提升来自两个方面:

  1. 语义完整性:每个 chunk 都是完整的语义单元,不会在句子中间切断。检索到的 chunk 包含完整的信息,而不是碎片。

  2. 减少噪音:固定切片可能把不相关的句子拼在一起(比如一个 chunk 的结尾是"Transformer 架构",开头是"训练数据集")。语义切片会在主题转换处切分,每个 chunk 主题单一,噪音少。

典型案例对比

用户问题:"Transformer 的自注意力机制是如何工作的?"

固定切片返回的 chunk

...BERT 使用双向编码器。Transformer 的核心创新在于自注意力

(不完整,缺少"机制"的解释)

语义切片返回的 chunk

Transformer 的核心创新在于自注意力机制。这种机制允许模型在处理每个位置时,
关注序列中的所有其他位置,从而捕获长距离依赖关系。具体来说,自注意力通过
计算 Query、Key、Value 三个矩阵来实现...

(完整,包含完整的解释)


Ragas 评估体系:如何科学地衡量 RAG 质量

语义切片提升了 Context Recall,但这个数字是怎么来的?如何科学地评估 RAG 系统的质量?

为什么需要自动化评估?

传统的人工评估有两个问题:

  1. 不可扩展:每次优化后都需要人工测试几十个问题,耗时耗力。
  2. 主观性强:不同评估者的标准不一致,难以量化对比。

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 评估,对比每次优化的效果:

策略FaithfulnessAnswer RelevancyContext Recall
纯向量检索0.720.680.62
混合检索(RRF)0.780.750.70
混合 + Reranker0.820.790.74
完整 CRAG0.850.820.78

关键发现

  1. 混合检索主要提升 Context Recall:从 0.62 → 0.70(+13%)。这是因为 BM25 补充了向量检索遗漏的关键词匹配文档。

  2. Reranker 提升 Faithfulness:从 0.78 → 0.82(+5%)。Reranker 过滤掉不相关的文档,减少了 LLM 被噪音误导的概率。

  3. CRAG 全面提升三个指标:置信度评估 + 查询重写 + Web 回退,让系统在检索失败时有补救措施,而不是硬着头皮生成错误答案。

迭代闭环

Ragas 的价值不仅在于评估,更在于建立量化反馈闭环

评估 → 发现问题 → 优化策略 → 重新评估 → 对比验证

示例:

  1. 评估:发现 Context Recall 只有 0.62
  2. 发现问题:分析失败案例,发现很多问题是因为固定切片破坏了语义完整性
  3. 优化策略:改用语义切片
  4. 重新评估:Context Recall 提升到 0.78
  5. 对比验证:确认优化有效,部署到生产环境

没有量化评估,优化就是"盲人摸象"——你不知道哪个环节有问题,也不知道优化是否有效。


实践中的踩坑与优化

坑 1:句子边界检测不准

问题:简单的正则表达式(按句号分割)会把"Dr. Smith"、"Fig. 1"切成多个句子。

解决:使用 spaCynltk 的 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 生成标准答案,再人工校验。流程:

  1. 用 gpt-4o 基于文档生成问题-答案对
  2. 人工审核,修正错误
  3. 保存为测试集

这样可以快速构建 20-50 对测试数据。


总结与下期预告

本文介绍了语义切片算法和 Ragas 自动化评估体系,核心要点:

  1. 语义切片保留语义完整性:通过 Embedding 相似度检测语义边界,避免在句子中间切断,Context Recall 提升 26%。

  2. Ragas 提供量化评估:Faithfulness、Answer Relevancy、Context Recall 三大指标,让 RAG 优化有据可依。

  3. 建立迭代闭环:评估 → 发现问题 → 优化 → 验证,持续提升 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 篇,聚焦语义切片与评估体系。上一篇介绍了混合检索与重排序,下一篇将深入生产部署。