RAG 进阶:Chunking 策略与检索优化的实战取舍
大多数 RAG 管线在检索环节就失败了——根因是 chunking 策略。本文从五种分块方法、混合搜索、Reranking 到完整生产管线,给出可落地的决策框架。
RAG 进阶:Chunking 策略与检索优化的实战取舍
你搭好了 RAG 管线:文档切一切,向量存一存,用户问一句,top-k 检索出来丢给 LLM。结果回答质量不稳定——有时候精准,有时候答非所问。你调了 embedding 模型、换了更大的 LLM,效果提升有限。
问题大概率不在生成端,而在检索端。而检索质量的上限,在你切分文档的那一刻就已经决定了。
为什么检索是瓶颈
RAG 的端到端质量取决于一条最短路径:chunking -> embedding -> retrieval -> reranking -> generation。这条链上任何一环拉垮,后面的环节再怎么优化都是补救。但实际经验中,90% 的检索质量问题可以追溯到 chunking 策略和检索方式的选择。
一个直观的例子:假设你的知识库有一份 API 文档,其中一段描述了某个函数的参数和返回值。如果你用固定 512 token 切分,参数说明和返回值说明可能被切成两个 chunk。用户问"这个函数返回什么",embedding 检索可能只命中参数说明的 chunk,返回值信息丢失了。
这不是 embedding 模型的问题,不是向量数据库的问题——是 chunking 把语义完整性破坏了。
五种 Chunking 策略详解
1. 固定大小切分(Fixed-Size with Overlap)
最简单的方案:按字符数或 token 数切分,相邻 chunk 之间有重叠。
from langchain.text_splitter import CharacterTextSplitter
splitter = CharacterTextSplitter(
separator="\n\n",
chunk_size=1000,
chunk_overlap=200,
length_function=len,
)
chunks = splitter.split_text(your_document)
print(f"共 {len(chunks)} 个 chunk,平均长度 {sum(len(c) for c in chunks) / len(chunks):.0f}")
适用场景:纯文本语料、日志文件、无明显结构的文档。作为 baseline 快速验证管线是否跑通。
不适用:结构化文档(API 文档、法律合同、技术规范)、包含表格或代码块的文档。固定切分会无情地切断表格行、代码块、段落间的逻辑关系。
2. 递归字符切分(Recursive Character Splitting)
LlamaIndex 和 LangChain 的默认方案。按分隔符优先级递归切分:先尝试按双换行切,切不动再按单换行切,再切不动按句号切,以此类推。
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
separators=["\n\n", "\n", "。", "!", "?", ".", "!", "?", " ", ""],
chunk_size=1000,
chunk_overlap=100,
)
chunks = splitter.split_text(your_document)
为什么比固定切分好:它尊重自然语言的自然边界(段落 > 句子 > 词),大多数情况下不会把一个句子从中间切断。
仍然不足:它不理解文档结构。一个 Markdown 文档里,## API Reference 下面的内容和 ## Getting Started 下面的内容可能在同一个 chunk 里——对检索来说这是噪音。
3. 语义切分(Semantic Chunking)
不按固定大小切,而是按语义相似度切:计算相邻句子的 embedding 相似度,当相似度低于阈值时切分。
from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
splitter = SemanticChunker(
embeddings=embeddings,
breakpoint_threshold_type="percentile",
breakpoint_threshold_amount=75, # 相似度差距排在第 75 百分位时切分
)
chunks = splitter.split_text(your_document)
for i, chunk in enumerate(chunks):
print(f"Chunk {i}: {len(chunk)} 字符")
优势:每个 chunk 内部语义连贯,检索时命中的 chunk 更可能完整回答用户问题。
代价:需要对每个句子做 embedding,处理速度慢 5-10 倍。适合离线预处理,不适合实时切分。阈值选择对效果影响大——太低则 chunk 太碎,太高则 chunk 太长。
实战中,语义切分更适合长文本文献(论文、报告)和问答对数据集。对于结构化文档,有更好的选择。
4. 文档结构感知切分(Structure-Aware Splitting)
利用文档本身的结构标记(Markdown 标题、HTML 标签、代码块边界、表格行)作为切分点。这是当前生产环境中最推荐的做法。
from langchain.text_splitter import MarkdownHeaderTextSplitter
from langchain.text_splitter import RecursiveCharacterTextSplitter
# 第一步:按 Markdown 标题层级切分
headers_to_split_on = [
("#", "h1"),
("##", "h2"),
("###", "h3"),
]
md_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)
md_chunks = md_splitter.split_text(markdown_document)
# 第二步:对超过 chunk_size 的部分再递归切分
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=800,
chunk_overlap=100,
)
final_chunks = text_splitter.split_documents(md_chunks)
# 每个 chunk 自动携带标题元数据
for chunk in final_chunks[:3]:
print(f"Metadata: {chunk.metadata}")
print(f"Content: {chunk.page_content[:100]}...")
print("---")
对于代码文档,可以用语言感知的切分器:
from langchain.text_splitter import Language, RecursiveCharacterTextSplitter
python_splitter = RecursiveCharacterTextSplitter.from_language(
language=Language.PYTHON,
chunk_size=1500,
chunk_overlap=200,
)
code_chunks = python_splitter.split_text(python_source_code)
核心优势:chunk 自带结构元数据(属于哪个章节、哪个函数),检索时可以把元数据用于过滤和加权。这是其他切分方式做不到的。
Haystack 框架在这方面做得很好——它的 PreProcessor 组件原生支持按段落、标题、句子级别切分,并且自动保留元数据。
5. Late Chunking(延迟切分)
2024 年新兴的模式:先对整个文档做 embedding,然后再切分。核心思想是:长上下文 embedding 模型(如 Jina Embeddings v3)可以处理 8192 token 的输入,先得到整文档级别的语义表示,再在 embedding 空间里做切分。
from transformers import AutoTokenizer, AutoModel
import torch
import numpy as np
tokenizer = AutoTokenizer.from_pretrained("jinaai/jina-embeddings-v3")
model = AutoModel.from_pretrained("jinaai/jina-embeddings-v3", trust_remote_code=True)
def late_chunking(document: str, chunk_size: int = 500) -> list[dict]:
"""先 embedding 整篇文档,再在 token embedding 空间中切分"""
inputs = tokenizer(document, return_tensors="pt", truncation=True, max_length=8192)
with torch.no_grad():
outputs = model(**inputs)
# 获取每个 token 的 embedding
token_embeddings = outputs.last_hidden_state.squeeze(0) # [seq_len, hidden_dim]
# 在 token embedding 空间中按 chunk_size 分组并平均池化
tokens = inputs["input_ids"].squeeze(0)
chunk_embeddings = []
for i in range(0, len(tokens), chunk_size):
chunk_emb = token_embeddings[i : i + chunk_size].mean(dim=0)
chunk_text = tokenizer.decode(tokens[i : i + chunk_size], skip_special_tokens=True)
chunk_embeddings.append({
"text": chunk_text,
"embedding": chunk_emb.numpy(),
})
return chunk_embeddings
chunks = late_chunking(your_document, chunk_size=500)
print(f"生成 {len(chunks)} 个 late-chunked embeddings")
理论优势:chunk embedding 保留了全文档上下文信息,解决了"切分后丢失上下文"的根本问题。
实际限制:需要支持长上下文的 embedding 模型,对 GPU 内存要求更高,推理延迟也更高。目前更适合小规模、高精度场景。EmbedAnything 项目提供了高效的 embedding 管线,可以作为这类方案的底层基础设施。
检索优化:混合搜索与 Reranking
选对 chunking 策略解决了"检什么"的问题,但"怎么检"同样重要。纯向量相似度搜索在某些场景下会败给简单的关键词匹配。
混合搜索:Dense + Sparse
混合搜索结合语义检索(dense retrieval)和关键词检索(sparse retrieval / BM25),用倒数排名融合(Reciprocal Rank Fusion, RRF)合并结果。
from rank_bm25 import BM25Okapi
import numpy as np
def hybrid_search(
query: str,
chunks: list[str],
dense_embeddings: np.ndarray, # 预计算的 chunk embeddings
bm25: BM25Okapi,
embedding_model,
top_k: int = 10,
alpha: float = 0.5, # dense 权重,1-alpha 给 sparse
) -> list[tuple[int, float]]:
"""混合搜索:语义 + 关键词,用 RRF 融合"""
# Dense retrieval
query_emb = np.array(embedding_model.embed_query(query)).reshape(1, -1)
dense_scores = np.dot(dense_embeddings, query_emb.T).flatten()
dense_ranks = np.argsort(-dense_scores)
# Sparse retrieval (BM25)
tokenized_query = query.lower().split()
sparse_scores = bm25.get_scores(tokenized_query)
sparse_ranks = np.argsort(-sparse_scores)
# Reciprocal Rank Fusion
rrf_scores = {}
for rank, idx in enumerate(dense_ranks):
rrf_scores[idx] = rrf_scores.get(idx, 0) + alpha / (1 + rank)
for rank, idx in enumerate(sparse_ranks):
rrf_scores[idx] = rrf_scores.get(idx, 0) + (1 - alpha) / (1 + rank)
# 按 RRF 分数排序
sorted_results = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)
return sorted_results[:top_k]
# 预处理
tokenized_corpus = [chunk.lower().split() for chunk in chunks]
bm25_index = BM25Okapi(tokenized_corpus)
什么时候混合搜索收益最大:用户查询包含精确术语、产品名称、错误代码时。BM25 在精确匹配上完胜语义搜索,而语义搜索在模糊查询、同义词场景上更强。两者互补。
LlamaIndex 原生支持混合搜索——配置 VectorIndex + BM25 检索器并设置 RRF 融合参数即可。Lantern 作为 PostgreSQL 向量扩展,也支持在 SQL 层面实现 dense + sparse 联合查询。
Reranking 管线
初次检索(无论是纯向量还是混合搜索)拿到 top-k(比如 20 条),再用 cross-encoder 模型精排,选出最相关的 top-n(比如 5 条)。
from sentence_transformers import CrossEncoder
# Cross-encoder 比 bi-encoder 慢但更准确
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
def rerank(query: str, chunks: list[str], top_n: int = 5) -> list[dict]:
"""用 cross-encoder 对检索结果重新排序"""
pairs = [(query, chunk) for chunk in chunks]
scores = reranker.predict(pairs)
ranked = sorted(
[{"text": chunk, "score": float(score)} for chunk, score in zip(chunks, scores)],
key=lambda x: x["score"],
reverse=True,
)
return ranked[:top_n]
# 使用示例
initial_results = ["chunk1 内容...", "chunk2 内容...", "chunk3 内容..."]
reranked = rerank("用户的查询", initial_results, top_n=3)
for r in reranked:
print(f"Score: {r['score']:.4f} | {r['text'][:80]}")
关键点:Reranking 是当前性价比最高的检索优化手段。一个小的 cross-encoder(MiniLM 级别)就能带来 10-20% 的检索精度提升,代价是额外 10-50ms 延迟。
查询扩展(Query Expansion)
用户的查询往往太短、太模糊。用 LLM 生成子查询,从不同角度检索,再合并结果。
from openai import OpenAI
client = OpenAI()
def expand_query(original_query: str, n_sub_queries: int = 3) -> list[str]:
"""用 LLM 生成子查询,覆盖不同检索角度"""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": (
"你是一个查询扩展助手。根据用户的原始查询,生成 "
f"{n_sub_queries} 个不同角度的子查询,用于提高检索覆盖率。"
"每个子查询一行,不要编号,不要解释。"
),
},
{"role": "user", "content": original_query},
],
temperature=0.3,
max_tokens=200,
)
sub_queries = response.choices[0].message.content.strip().split("\n")
return [original_query] + [q.strip() for q in sub_queries if q.strip()]
expanded = expand_query("如何优化 RAG 检索效果")
for q in expanded:
print(f" -> {q}")
查询扩展在用户习惯简短提问(比如中文场景下"怎么配置"这种模糊查询)时效果最好。但要注意:每个子查询都会触发一次检索,检索延迟和成本是原来的 N 倍。生产中通常限制在 3-5 个子查询。
生产级管线:把所有东西组合起来
下面是一个完整的、可运行的 chunking + 检索管线,综合了结构感知切分、混合搜索和 reranking。
"""
生产级 RAG 检索管线
依赖:pip install langchain langchain-openai rank-bm25 sentence-transformers
"""
import numpy as np
from rank_bm25 import BM25Okapi
from sentence_transformers import CrossEncoder
from langchain.text_splitter import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
# --- 配置 ---
CHUNK_SIZE = 800
CHUNK_OVERLAP = 100
INITIAL_RETRIEVAL_K = 20
FINAL_TOP_N = 5
RERANK_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
# --- Step 1: 结构感知切分 ---
def chunk_documents(documents: list[str]) -> list[dict]:
"""按 Markdown 结构切分,保留元数据"""
header_splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=[("#", "h1"), ("##", "h2"), ("###", "h3")]
)
sub_splitter = RecursiveCharacterTextSplitter(
chunk_size=CHUNK_SIZE, chunk_overlap=CHUNK_OVERLAP
)
all_chunks = []
for doc in documents:
md_chunks = header_splitter.split_text(doc)
for chunk in md_chunks:
if len(chunk.page_content) > CHUNK_SIZE:
sub_chunks = sub_splitter.split_documents([chunk])
all_chunks.extend(sub_chunks)
else:
all_chunks.append(chunk)
return [
{"text": c.page_content, "metadata": c.metadata}
for c in all_chunks
]
# --- Step 2: 构建 Embedding + BM25 索引 ---
def build_index(chunks: list[dict]):
"""同时构建 dense 和 sparse 索引"""
texts = [c["text"] for c in chunks]
embedder = OpenAIEmbeddings(model="text-embedding-3-small")
dense_embeddings = np.array(embedder.embed_documents(texts))
tokenized = [text.lower().split() for text in texts]
bm25 = BM25Okapi(tokenized)
return dense_embeddings, bm25, embedder
# --- Step 3: 混合搜索 + Reranking ---
def retrieve(
query: str,
chunks: list[dict],
dense_embeddings: np.ndarray,
bm25: BM25Okapi,
embedder: OpenAIEmbeddings,
) -> list[dict]:
"""完整的检索管线:混合搜索 -> Reranking -> 返回 top-n"""
texts = [c["text"] for c in chunks]
# Dense retrieval
query_emb = np.array(embedder.embed_query(query)).reshape(1, -1)
dense_scores = np.dot(dense_embeddings, query_emb.T).flatten()
dense_ranks = np.argsort(-dense_scores)
# Sparse retrieval
sparse_scores = bm25.get_scores(query.lower().split())
sparse_ranks = np.argsort(-sparse_scores)
# RRF 融合
rrf_scores = {}
for rank, idx in enumerate(dense_ranks[:INITIAL_RETRIEVAL_K]):
rrf_scores[idx] = rrf_scores.get(idx, 0) + 0.5 / (1 + rank)
for rank, idx in enumerate(sparse_ranks[:INITIAL_RETRIEVAL_K]):
rrf_scores[idx] = rrf_scores.get(idx, 0) + 0.5 / (1 + rank)
# 取融合后 top-k
candidates = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)
candidate_texts = [texts[idx] for idx, _ in candidates[:INITIAL_RETRIEVAL_K]]
# Cross-encoder reranking
reranker = CrossEncoder(RERANK_MODEL)
pairs = [(query, text) for text in candidate_texts]
rerank_scores = reranker.predict(pairs)
results = []
for (idx, _), score in zip(candidates[:INITIAL_RETRIEVAL_K], rerank_scores):
results.append((idx, float(score)))
results.sort(key=lambda x: x[1], reverse=True)
return [
{**chunks[idx], "relevance_score": score}
for idx, score in results[:FINAL_TOP_N]
]
# --- 运行 ---
if __name__ == "__main__":
sample_docs = [
"# RAG 优化指南\n\n## 概述\n\nRAG(检索增强生成)是一种结合信息检索和文本生成的技术。\n\n## Chunking 策略\n\n选择合适的 chunking 策略是 RAG 质量的关键。\n\n### 固定大小切分\n\n最简单的方案,按固定 token 数切分。\n\n### 语义切分\n\n按语义相似度切分,保证每个 chunk 语义连贯。",
"# 向量数据库对比\n\n## 概述\n\n本文对比主流向量数据库的性能和特性。\n\n## Pinecone\n\n完全托管的向量数据库服务。\n\n## Milvus\n\n开源高性能向量数据库。",
]
chunks = chunk_documents(sample_docs)
print(f"共切分 {len(chunks)} 个 chunk")
dense_emb, bm25_idx, embedder = build_index(chunks)
print("索引构建完成")
results = retrieve("RAG 如何切分文档", chunks, dense_emb, bm25_idx, embedder)
for r in results:
print(f"\n[Score: {r['relevance_score']:.4f}] Metadata: {r['metadata']}")
print(f" {r['text'][:120]}...")
决策框架:什么文档用什么切分策略
| 文档类型 | 推荐切分策略 | 原因 | 检索方式 |
|---|---|---|---|
| API 文档 / 技术手册 | 结构感知(标题 + 代码块边界) | 自然按函数/类/模块划分,元数据可过滤 | 混合搜索 + Reranking |
| 法律合同 / 条款文档 | 递归字符切分 + 条款编号正则 | 条款间逻辑独立但长度不一 | 纯向量搜索 + Reranking |
| 论文 / 研究报告 | 语义切分 | 论述逻辑连贯,语义边界比结构边界更准 | 混合搜索 |
| FAQ / 问答对 | 按问答对切分,不拆分 | 每个 QA 对天然是一个检索单元 | BM25 即可 |
| 日志 / 无结构文本 | 固定大小 + overlap | 没有结构可用,固定切分是唯一选择 | BM25 + 关键词过滤 |
| 表格数据 | 按行切分,保留表头作为元数据 | 表格行是天然切分点,表头提供语义 | 向量搜索 + 元数据过滤 |
| 混合格式文档 | 结构感知 + 针对表格/代码特殊处理 | 不同部分需要不同策略 | 混合搜索 + Reranking |
通用建议:如果你不确定文档类型,从递归字符切分 + 混合搜索开始,这是最稳妥的 baseline。然后针对检索质量差的 case 分析原因,逐个优化。
AutoRAG 项目提供了自动化的 RAG 参数搜索功能,可以帮你自动测试不同的 chunking 参数和检索策略组合,省去手动调参的时间。
三个常见的生产坑
坑 1:忽略了 chunk 里的元数据
切分后每个 chunk 自带的元数据(来源文档、章节标题、页码)是最容易被忽略的检索增强手段。
# 错误做法:只存 text + embedding
vector_store.add(text=chunk, embedding=emb)
# 正确做法:存储并利用元数据
vector_store.add(
text=chunk,
embedding=emb,
metadata={
"source": "api-docs",
"section": "authentication",
"doc_type": "api_reference",
}
)
# 检索时用元数据过滤
results = vector_store.search(
query="如何认证",
filter={"doc_type": "api_reference"}, # 只搜 API 文档
top_k=10,
)
元数据过滤可以在向量搜索之前大幅缩小检索范围,不仅提高精度,还降低延迟。Pathway 的 llm-app 框架在这一点上设计得很好——它的数据管线原生支持在索引时附加丰富的元数据。
坑 2:Reranker 成为延迟瓶颈
Cross-encoder reranking 是串行操作,20 条候选 x 10ms/条 = 200ms 额外延迟。在要求 P95 < 500ms 的系统中,这占了近一半。
解决方案:
- 减少 initial retrieval 的 top-k(从 30 降到 15)
- 用更小的 reranker 模型(MiniLM 而不是 large)
- 对 reranker 做 batch 推理
- 考虑用 LLM 做轻量级 reranking(只输出 rank 不生成文本)
坑 3:Embedding 模型与查询分布不匹配
你用多语言 embedding 模型索引了中文文档,但用户查询是中英混合的(比如 "RAG chunking 策略")。很多 embedding 模型在混合语言输入上表现会下降。
解决方案:
- 索引时同时生成中英文 chunk(用 LLM 翻译后分别 embedding)
- 查询扩展时生成中英文子查询
- 选择明确支持跨语言的 embedding 模型(如 multilingual-e5、BGE-M3)
总结
- Chunking 决定了检索质量的上限。花在 chunking 策略选择上的时间,比花在调 embedding 模型上的时间回报更高。从结构感知切分开始,按需调整。
- 混合搜索是当前生产环境的标配。纯向量搜索在精确匹配场景下败给 BM25,纯 BM25 在语义理解场景下败给向量搜索。两者融合是最稳妥的方案。
- Reranking 是性价比最高的单点优化。加一个 cross-encoder reranker,检索精度通常提升 10-20%,代价是 10-50ms 延迟。
- 元数据不是可选项。在索引阶段就把来源、章节、类型等元数据挂上,检索时用元数据过滤,效果提升立竿见影。
- 没有万能的 chunking 策略。API 文档、论文、FAQ、法律合同需要不同的切分方式。根据你的文档类型选策略,不要照搬教程。
本文涉及的项目
Haystack
25.2k ⭐Haystack 是企业级 RAG 与搜索应用框架,支持文档处理、检索、生成与评估全链路。
LlamaIndex
49.3k ⭐LlamaIndex 是一个数据框架,用于构建 LLM 应用程序的数据连接层。它提供了强大的 RAG 能力,支持多种数据源和向量数据库。
EmbedAnything
1.2k ⭐EmbedAnything 是一个用 Rust 构建的高性能嵌入推理和索引框架,提供模块化、内存安全的 RAG 数据摄取和索引管道,支持本地和云端部署。
AutoRAG
4.8k ⭐AutoRAG 是开源 RAG 评估与优化框架,采用 AutoML 风格自动化流程,帮助开发者自动搜索最佳 RAG 管线配置并进行基准评测。
Lantern
881 ⭐Lantern 是一个 PostgreSQL 向量数据库扩展,为 PostgreSQL 添加高性能向量搜索能力,支持生成和索引嵌入向量,便于在现有数据库基础设施上构建 AI 应用。
Pathway LLM App
59.8k ⭐即开即用的 RAG 和 AI 管道云模板,支持 Docker 部署,实时同步 Sharepoint、Google Drive、S3、Kafka 等数据源。