检索质量
RAG 系统的效果天花板由检索质量决定——检索不准,再强的 LLM 也无法给出正确答案。检索质量涉及两个核心技术:分块(Chunking)决定知识库的粒度,重排序(Rerank)决定返回给 LLM 的文档排序。面试中,这两个话题几乎是必考项,因为它们直接反映了候选人对 RAG 工程细节的理解深度。
分块策略
为什么需要分块
原始文档通常太长,直接作为上下文输入 LLM 有三个问题。上下文窗口有限,一篇技术文档可能上万字,超出模型的处理能力。长文档会稀释关键信息的注意力权重——模型对输入中间位置的内容关注度最低(Lost in the Middle),关键信息淹没在大量无关内容中检索不到。向量检索的精度随文本长度下降,一个 3000 字的 chunk 的向量表示远不如一个 500 字的 chunk 精准,因为前者混合了太多语义。
分块的目标是在保持语义完整性的前提下,将文档切成适合检索的小片段。理想的 chunk 应该是一个自包含的语义单元——读者只看这一个片段就能理解其含义,不依赖前后文。
固定长度切分
最简单的分块方式是按字符数切分,每 N 个字符为一个 chunk。这种方法实现简单,但问题明显:可能在句子中间截断,破坏语义完整性。例如"本协议自双方签字之日起生效,有效期为三年"被切成"本协议自双方签字之日起生效,有效"和"期为三年",两个 chunk 都失去了完整含义。
改进方案是固定长度 + 重叠(Overlap):每个 chunk 包含 N 个字符,相邻 chunk 之间重叠 M 个字符(通常 M = N/5 到 N/10)。重叠确保被截断的关键词至少在其中一个 chunk 中完整出现。例如 chunk_size=500、overlap=50,第 1 个 chunk 是 0-499 字符,第 2 个是 450-949 字符。重叠的代价是存储和索引膨胀,以及检索结果可能出现重复内容。
语义感知切分
更好的方案是沿着文本的自然结构切分,保持语义完整性。常见的策略按优先级排列:
按标题层级切分是最粗粒度的方式。Markdown 的 #、##、### 等标题天然地将文档组织成独立的语义单元。一个 ## 下的内容通常围绕一个主题展开,切分后每个 chunk 是一个自包含的章节。这种方式适合结构良好的技术文档、API 文档。
按段落切分是中等粒度。以双换行符 \n\n 作为分隔符,每个段落是一个 chunk。段落是文本的自然语义边界,一个段落通常表达一个完整的意思。缺点是段落长度差异大,有些段落只有一句话(几十字),有些段落可能上千字。
递归字符切分(Recursive Character Text Splitter)是目前最通用的方案,LangChain 和 LlamaIndex 都内置了这个方法。它按分隔符优先级逐级尝试:先用 \n\n(段落)切分,如果产生的 chunk 仍然超过目标长度,再用 \n(换行)切分,再用 . (句号)切分,最后才按字符数硬切。每一级都保留 overlap,确保边界处的语义不被截断。这种方法在保持语义完整性和控制 chunk 长度之间取得了良好的平衡。
按句子切分是最细粒度的方式,以句号、问号、感叹号为分隔符。每个句子是一个 chunk,语义最完整,但 chunk 太短导致检索时返回太多不相关的片段,且 LLM 需要更多的上下文片段才能理解完整语境。适合 QA 场景——每个问题对应一个答案句子,检索精度最高。
分块参数调优
chunk_size 和 overlap 没有放之四海而皆准的最优值,需要根据场景调优。chunk_size 太小(<100 字),每个 chunk 信息量少,检索召回的 chunk 虽然相关但可能缺少上下文,LLM 无法生成完整答案。chunk_size 太大(>1000 字),语义稀释,向量表示不够精准,检索精度下降。经验上,500-800 字符(约 100-200 Token)是大多数场景的合理起点。
overlap 的作用是弥补边界截断带来的语义损失。overlap 太小(<10%),截断处的关键词可能无法被完整检索到。overlap 太大(>50%),存储和索引膨胀,检索结果重复率高。通常 overlap 设为 chunk_size 的 10%-20%。
调优方法是用测试集评估不同参数组合的 Recall@K。准备 50-100 个 (query, 相关文档) 对,用不同 chunk_size 和 overlap 构建索引,统计检索命中率。这是一个需要实际跑数据才能确定的值,凭感觉设置大概率不是最优的。
父子分块召回
固定粒度的分块面临一个根本矛盾:chunk_size 大时每个 chunk 信息丰富但向量不够精准,chunk_size 小时向量精准但单个 chunk 缺少上下文。父子分块(Parent-Child Chunking)是解决这个矛盾的方案:用小 chunk 做检索(保证精度),命中后返回其所属的大 chunk 作为上下文(保证信息量)。
实现方式是建立两级索引。大 chunk(父块)按标题或固定长度(1000-1500 字)切分,小 chunk(子块)覆盖同样的文本范围但粒度更细(200-300 字)。每个子块记录其所属父块的 ID,只有子块被向量化并建立索引。检索时在小 chunk 索引中找 top-K,然后返回对应的大 chunk 注入 Prompt。
例如一篇关于"网络架构"的技术文档,父块包含整个章节(约 1200 字),子块按段落切分(每段约 250 字)。用户问"负载均衡如何配置",子块索引精准命中"负载均衡"相关的段落,但返回给 LLM 的是包含该段落的完整父块——上下文信息更充分,LLM 能理解该配置在整个架构中的位置和前置条件。
父子分块的代价是存储开销增加(每个子块需要记录父块引用)和代码复杂度上升。Milvus 的特性支持这种模式:子块和父块存储在同一 Collection 中,通过元数据字段标记父子关系,查询时先检索子块再通过过滤条件拉取父块。LlamaIndex 的 AutoMergingRetriever 也内置了父子分块支持。
不同文档类型的分块
Markdown / 纯文本(txt)
Markdown 是 RAG 友好度最高的格式。标题层级(#、##、###)、代码块(```包裹)、表格(| 分隔)、列表(- / 1.)都有明确的语法标记,分块可以精确地沿着这些结构边界切割。
处理流程:用正则或 Markdown 解析器(如 markdown-it)识别文档结构。标题层级作为自然分段点,代码块和表格保持完整不拆分。如果某个章节过长(如一篇很长的 ## 章节),对其内部内容做递归字符切分。frontmatter(YAML 头部)和脚注通常是全局性信息,不随正文分块,而是在需要时附加到所有 chunk 或作为独立 chunk 存储。
纯文本没有结构标记,只能依赖段落分隔(双换行)和递归字符切分。对于质量高的纯文本(如新闻文章、论文),段落结构通常清晰,分块效果尚可。对于格式混乱的纯文本(如日志、聊天记录),分块效果较差,可能需要先做预处理(按时间戳分段、过滤无意义内容)。
PDF 是 RAG 中最常见的文档格式,但也是分块最困难的格式。PDF 不是为机器阅读设计的——它记录的是视觉排版信息(字体、坐标、布局),而非逻辑结构(标题、段落、表格)。
文本型 PDF(可选中文字的)可以用 PyMuPDF(fitz)或 pdfplumber 提取文本。提取流程通常是:按页提取原始文本 → 正则清理页眉页脚(匹配固定模式的页码、公司名等重复内容)→ 检测标题层级(通过字体大小、加粗、编号样式推断,pdfminer 的布局分析可以辅助)→ 按检测到的标题结构分段 → 递归字符切分。PyMuPDF 的 get_text("dict") 模式能保留字体大小信息,便于标题层级恢复。
扫描件 PDF(图片型)需要先 OCR。用 Tesseract 或 PaddleOCR 识别文字后,得到的是纯文本流,丢失了所有排版信息。处理方式是对 OCR 结果做段落检测(基于行间距和缩进),然后递归字符切分。OCR 质量直接影响分块效果——识别错误的文字会导致向量表示偏离真实语义。
PDF 中的表格是重灾区。pdfplumber 可以尝试提取表格结构,但如果表格合并单元格、跨页等情况,提取结果往往混乱。工程实践中,如果 PDF 表格占比高,建议将表格提取为 CSV/Excel 后单独处理(见表格分块部分),而非硬从 PDF 中还原。
Word(docx)
docx 基于 Open XML 格式,天然保留了文档结构信息——标题层级(Heading 1/2/3)、段落、表格、列表。python-docx 可以直接读取这些结构,分块变得简单。
处理流程:用 python-docx 遍历文档元素(paragraph 和 table),按 Heading 样式分段,每个 Heading 下的内容作为一个语义单元。如果一个 Heading 下的内容超过 chunk_size 阈值,对内容部分做递归字符切分。表格和图片保持完整,不跨表格切分。文档属性(作者、创建时间)可以作为元数据附加到每个 chunk,支持后续的元数据过滤。
docx 的优势是结构可靠、不会像 PDF 那样丢失信息。劣势是只能处理 .docx 格式(.doc 旧格式需要先转换为 .docx),且 docx 中的嵌入对象(OLE 对象、嵌入的 Excel 表格)提取困难。
PPT(pptx)
PPT 的分块逻辑与其他文档截然不同——PPT 的信息组织单位是幻灯片(Slide),而非段落。每张幻灯片通常围绕一个要点展开,信息密度低但语义独立性强。
python-pptx 可以提取每张幻灯片的文本内容。分块策略:每张幻灯片(或每 2-3 张相关的幻灯片)作为一个 chunk,chunk 的开头包含幻灯片标题作为语义锚点。如果一张幻灯片的文本过少(如只有几个关键词),可以将相邻幻灯片合并。
PPT 中的 speaker notes(演讲者备注)通常包含更详细的说明文字,应该与幻灯片文本合并后再切分。表格和图表的 alt text(替代文本)也需要提取,因为图表本身无法向量化。
PPT 的特殊挑战是动画和分步显示——一页幻灯片可能分多次出现不同内容。如果需要保留这些信息,需要将每次动画步骤的内容分别提取并关联到对应的 chunk 中。
表格(CSV / Excel)
表格数据的分块目标是保留行列关系,让 LLM 能理解数据之间的对应关系。直接将整张表格作为一个 chunk 是最简单的方案,但如果表格很大(几十列、上千行),会超出 chunk_size 限制且有大量无关列。
行列级切分策略:按行分块(每 N 行一个 chunk),但必须将表头行附加到每个 chunk 的开头,否则 LLM 不知道每列的含义。按列分组分块(将相关列分组,如基础信息列一组、财务数据列一组),减少每个 chunk 的宽度。按语义分块(根据查询场景,将用户可能一起查询的列放在同一 chunk)。
元数据标注对表格分块尤其重要。每个 chunk 应附加元数据:来源表名、列名列表、数据时间范围。检索时可以先用元数据过滤缩小范围(如"只查 2024 年的销售表"),再向量检索。
重排序
为什么需要重排序
向量检索(Bi-Encoder)的工作方式是:将 query 和文档分别编码为向量,计算余弦相似度。这种方式的高效性来源于"双塔"架构——query 和文档独立编码,文档向量可以预计算并建立索引,检索时只需计算 query 向量与候选向量的相似度。但双塔架构的精度有天花板:query 和文档没有"见面",模型无法捕捉它们之间的细粒度交互(如 query 中的某个关键词在文档中的具体语境)。
实际效果是:向量检索返回的 top-50 结果中,真正相关的可能只有 top-5。但 top-5 的排序未必准确——相关但表述不同的文档可能排在不太相关但表述相近的文档之后。重排序(Rerank)的职责就是在这 50 个候选文档中重新精确排序,把最相关的推到最前面。
Cross-Encoder 原理
Cross-Encoder(交叉编码器)是重排序的标准方案。与 Bi-Encoder 分别编码不同,Cross-Encoder 将 query 和文档拼接后一起输入模型,输出一个相关性分数。
Bi-Encoder 的计算是 [SEP] 分隔)后输入 Transformer,注意力机制让 query 的每个 token 与文档的每个 token 交互,模型能捕捉细粒度的语义关联——例如 query 中的"部署"和文档中的"Docker 部署"虽然不完全匹配,但 Cross-Encoder 能理解它们的相关性。
Cross-Encoder 的精度显著高于 Bi-Encoder,但计算开销也大得多:Bi-Encoder 中文档向量是预计算的,检索时只需一次向量相似度计算(
两阶段检索流水线
生产环境的检索流水线是三步走:召回(Recall)→ 精排(Rerank)→ 上下文构造。
第一步召回,用向量检索(或混合检索)从全量文档库中快速筛选 top-50 候选。这一步追求速度和召回率,允许有一定的噪声。
第二步精排,用 Cross-Encoder 对 50 个候选逐一打分,按分数降序排列,保留 top-5。这一步追求精度。
第三步上下文构造,将 top-5 文档按分数从高到低排列,拼接后注入 Prompt。如果总 Token 数超过预算,截断低分文档。
这种两阶段设计的关键是召回和精排之间的候选数量。候选太少(如 top-10),精排的改善空间有限;候选太多(如 top-500),精排的计算开销过大。top-50 是常见的平衡点。
主流 Rerank 模型
bge-reranker-v2-m3(BAAI 发布)是目前综合表现最强的开源重排模型,支持中英日韩等多语言,同时支持稠密向量(Cross-Encoder)和稀疏向量(BM25 风格)两种打分方式。推理速度约 30ms/对(A100),适合在线服务。
Cohere Rerank 是闭源方案的代表,提供 API 调用,精度高且支持多语言,但数据需出境。Jina Reranker 是另一个开源选择,轻量级(约 400MB),适合资源受限的场景。
自训练 Rerank 模型的场景:通用模型在特定领域(医疗、法律)的表现可能不够精准。训练数据是 (query, 正例文档, 负例文档) 三元组,正例是真正相关的文档,负例是不相关的文档。Hard Negatives(与 query 语义相关但不是正确答案的文档)作为负例效果最好——这些"容易混淆"的样本是模型最需要学会区分的。训练框架可以用 sentence-transformers 的 CrossEncoder 类,几万条高质量的训练数据就能训练出领域专用的 Rerank 模型。
面试高频问答
为什么不用 Cross-Encoder 直接替代向量检索?因为 Cross-Encoder 无法预计算文档表示,每次查询都需要对所有文档做前向传播,百万级文档库的延迟不可接受。两阶段流水线用向量检索做粗筛、Cross-Encoder 做精排,兼顾速度和精度。
Rerank 模型选型看什么?三个维度:精度(在领域测试集上的 NDCG/Recall@K)、延迟(每次打分的毫秒数,影响在线服务的响应时间)、部署成本(模型大小,是否需要 GPU)。开源模型适合私有化部署,闭源 API 适合快速验证。
chunk_size 怎么确定?没有万能的最优值,核心原则是"一个 chunk 应该是一个自包含的语义单元"。结构化文档(有标题、段落)用语义切分,非结构化文档(纯文本)用递归字符切分。最终用测试集评估不同参数的 Recall@K,数据驱动决策。
PDF 分块最大的坑是什么?排版信息丢失。PDF 提取后标题层级、表格结构、页眉页脚都混在一起,需要额外的预处理步骤恢复结构。扫描件 PDF 的 OCR 错误会直接影响向量质量。工程上建议优先将 PDF 转换为 docx 或 Markdown 后再分块,或者用 Unstructured、Marker 等专用工具做结构化提取。
父子分块什么时候用?当一个 chunk 的上下文信息对 LLM 理解答案很重要时——例如法律条文需要看到完整条款、技术文档需要看到前置条件。父子分块的额外复杂度(两级索引管理)在数据量小(<10 万 chunk)时可以用 LlamaIndex 的 AutoMergingRetriever 快速实现,数据量大时需要在 Milvus 中设计元数据关联。