GEOZ

本文深入解析RAG技术链路中的关键环节,包括文档分块、索引增

2026/8/23
本文深入解析RAG技术链路中的关键环节,包括文档分块、索引增

AIAI Summary (BLUF)

本文深入解析RAG技术链路中的关键环节,包括文档分块、索引增强、Embedding、混合检索与重排序,指出RAG常见问题并给出优化策略,强调要结合场景进行模块调优,以平衡召回率与精确率。

核心洞察

这篇文章最有意思的点是,它把 RAG 拆成一条链路来看,不把它当成一个插上电就能用的黑盒。作者把分块、索引、编码、检索、重排每个环节都过了一遍,很多“效果不好”的案子,其实走到文档分块那一步就已经偏了。我比较认同这个判断:先别急着换模型,先把 RAG 管道里的每个节点都看清楚再说。

核心结论

  1. 在语义分块示例中,一篇文档最终被切成 46 个文本块,其中 41 个按语义边界切出,语义切分比例为 0.89;如果这个比例偏低,说明大量文本块是因达到最大 token 长度被硬切出来的,后续语义检索效果会明显变差。

  2. 文档分块的关键参数不只是最小/最大 token 数(示例中为 100/500),还包括相似度阈值(示例中为 0.31)和窗口大小(默认 5);相似度阈值越大,文本块内部相关性越强,窗口越大则上下文相关性越好,但计算量和文本块大小也会上升。

  3. 以英文为主的编码模型(如 all-MiniLM-L6-v2)处理中文时,大量 token 会被映射到编号 100,即“不可识别 token”的兜底编号,检索效果差;中文场景应使用专门的中文向量化模型。

  4. 混合检索合并稀疏(BM25)和稠密(向量)结果时,需要先分别归一化分数,再按权重加权;示例中稀疏权重为 0.2、稠密权重为 0.8,这样既能保留关键词精确匹配能力,又能利用语义召回。

  5. 重排序阶段使用交叉编码器(如 jina-reranker-v1-tiny-en)对查询与候选文档逐对打分,再选出得分最高的结果作为上下文交给模型,是提升 RAG 最终答案质量的关键一步。

一、导读

RAG 的实现和优化,细节不少。很多团队把 RAG 当成一个黑盒,出了问题都不知道该往哪个环节查。这篇文章从文档分块、索引增强、编码、混合检索、重排序几个关键环节讲一遍,核心是提醒大家结合自己的场景调优,在召回率和精确率之间找一个合适的平衡点,别老想着换个更强的工具。

二、写在前面

RAG 这个词现在太常见了。AI 应用开发火起来以后,它出镜率极高,谁都能聊两句。但我经常听到这样的声音:“RAG 效果不好,可能要微调模型。”“某某产品上的 RAG 不好用,召回不精准。”“我需要更强大的 RAG 工具,不然效果很难提升。”

这些话本身没什么问题。但往下多问几句,情况就不一样了。比如“你为什么觉得 RAG 不好用?”“有没有实际 case?什么样的查询召回了什么样的知识?”“你的文档是怎么组织的?有没有做结构和分块上的处理?”能答上来的人,其实不多。

我们比较容易把 RAG 当成一个黑盒:左边输入沉淀好的文档,右边输出 AI 应用的最后结果。这种方式早期能拿到一些收益,但要持续优化、扩展使用场景,问题就来了。没有评估手段,问题定位困难,连现在这条链路的诉求都说不清楚,后面的动作自然容易跑偏。

所以这篇文章想把 RAG 链路拆开,聊一聊里面的技术细节,给实践中的小伙伴一些参考。知道问题出在哪,才谈得上做合理的迭代。

三、重新介绍下 RAG

RAG 的核心功能很简单:用户的查询来了,把和查询相关、但模型本身不掌握的信息补进去。这里有两个维度要同时盯:

  • 召回率:最相关的信息能不能找回来。
  • 精确率:不相关的信息别混进来。

这两个指标在现实里是个权衡,想同时拉满得付出不小的代价。更实际的做法,是找到一个适合当前场景的值。

RAG 这个缩写对应三个核心动作:检索、增强、生成。另外还有一个贯穿其中的动作:编码。整个链路的框架,就是围绕这几个动作展开的。

下面挑几个我觉得实际影响最大的技术点,一个个说。

1. 文档分块

检索效果要好,先看文档怎么切。很多团队在内容沉淀上花了不少功夫,但对文档结构、段落划分、知识点之间的内聚性和正交性,考虑得不多。

先看一个语义分块的例子。这里针对一篇论文做切分,设置最小文本块和最大文本块的 token 数,切完以后会输出统计结果。

# 设置本地数据保存目录
local_data_dir = pathlib.Path("/Users/jiangdanyang/workspaces/python/MarioPython/src/RAG/dataset/ai-arxiv2")
# 加载数据集(如果本地存在则从本地加载,否则下载)
dataset = load_dataset("jamescalam/ai-arxiv2", split="train", cache_dir=str(local_data_dir))
# 初始化编码器
encoder = OpenAIEncoder(
    name="text-embedding-3-small",
    openai_api_key=os.getenv("AI_API_KEY"),
    openai_base_url=os.getenv("AI_API_BASE_URL")
)
chunker = StatisticalChunker(
    encoder=encoder,
    min_split_tokens=100,
    max_split_tokens=500,
    plot_chunks=True,
    enable_statistics=True,
)
chunks_0 = chunker(docs=[dataset["content"][0]], batch_size=500)

这段代码里,最小 token 数设为 100,最大 token 数设为 500。切完的结果大概是下面这样:

  • 文档总数:474(这里可以理解成句子数)
  • 切分出来的文本块数:46
  • 按相似度阈值切出的文本块:41
  • 因为达到最大块大小被切出的文本块:4
  • 最后剩余的文本块:1
  • 最小文本块 token 数:54
  • 最大文本块 token 数:495
  • 语义切分比例:0.89

这里的 0.89 就是 41 除以 46,意思是有 89% 的文本块是按语义边界切出来的。如果这个比例低,说明很多文本块是因为长度到了上限被硬切出来的,后面做语义检索效果不会好。

所以分块的时候要调的不只是 token 大小,还有两个参数值得注意:

  • 相似度阈值:也就是文本块内部相关性的下限,上面例子是 0.31。这个值越大,切出来的文本块内部相关性越好。
  • 窗口大小:计算相似度时使用的文档数量,默认是 5。窗口越大,上下文相关性越好,但计算量也上去了,文本块会偏大。

除了语义分块,还有一些别的思路。一种是对不同内容类型做差异化切分,文本、表格、代码、图片各用各的策略;另一种是让能力强的模型读完整篇文档,再由它给出切分方案。《元分块:基于逻辑感知的文本切分与语义补全》这篇论文也给出了一个基于逻辑和语义的切分方法,还带了完整的效果评测。工具都在那里,重要的是结合自己的场景、知识现状和时间成本选择,然后对着效果调。

2. 索引增强

索引增强这里说两种做法:语义增强和反向 HyDE。

语义增强

语义增强的思路,是把一个文本块和它所在的整篇文档一起发给模型,让模型结合全文给这个文本块写一段概述,再把这句概述拼到文本块内容里。后续做语义检索的时候,这段概述能帮上忙。

DOCUMENT_CONTEXT_PROMPT = """
<document>
{doc_content}
</document>
"""

CHUNK_CONTEXT_PROMPT = """
这是需要放到整篇文档里理解的片段:
<chunk>
{chunk_content}
</chunk>
请用一句简短的话说明这个片段在整篇文档里的位置和背景,目的是改善检索召回。
只输出这句背景说明,不要输出其他内容。
"""

这一步对模型的要求不低,最好选能力强的模型。如果用的模型支持提示词缓存,能省下不少调用成本。还有一种做法,是把前后两个文本块也一起拼进去,对文档很长、前后文关联度高的场景,效果会更好。

反向 HyDE

正向 HyDE 里,模型会针对用户的查询先生成一段假设答案,或者做查询扩写,再用这些中间内容去检索。反向 HyDE 反过来做:对一个文本块,让模型生成“这段内容可能会回答哪些问题”,然后拿这些问题去建索引,关联到具体的文本块。

这样做的好处是离线就能把索引建好,不影响线上调用耗时。

针对下面的文本片段,生成 {n} 个不同的问题,让这个片段可以作为这些问题的答案。

片段:{chunk}

问题(用 1. 2. 编号):

这种方法比较适合问答型知识库,比如一堆真实的答疑记录,每条有明确的问和答。它生成的问题,也可以拿去做混合检索里的关键词扩写,提升召回效果。

3. 编码

编码就是把文本转换成向量。流程大体是:文本切成 token,token 在词汇表里对应编号,每个编号都有对应维度的向量。

举个例子:

first_sentence = "直播分享会AI Living第一场"
second_sentence = "直播鲁班小组分享第77期"
model = SentenceTransformer("/Users/jiangdanyang/workspaces/python/Model/all-MiniLM-L6-v2")
tokenized_first_sentence = model.tokenize([first_sentence])
tokenized_second_sentence = model.tokenize([second_sentence])

这个例子里的模型在处理中文的时候效果比较差。返回的编号里有很多 100,这个 100 是模型里“不可识别 token”的兜底编号。

影响编码效果的因素主要有三个。

第一是语言覆盖。不同的语言,分词方式不一样,词汇表也不一样。像上面这种以英文为主的模型,处理中文自然不行。中文场景要找专门的中文向量化模型。但也不是所有语言都有对应模型,语种太多,有些语料太少,根本训练不出来。

第二是词汇表大小。有些模型的词汇表只有 3 万左右,主流模型一般在 5 万以上,有的甚至到 10 万。词汇表小,很多词表示不了,只能用一个兜底编号代替,后面处理效果会受影响。词汇表大,文本表达更精准,但编码完的 token 数量也会跟着涨。

第三是语义空间。每个编码模型都有自己的词汇表和语义空间,语义空间的效果取决于训练数据。现在大多数通用模型学的是日常化、大众化的语义关联。如果你想在一个特定领域里拥有特殊的语义空间,那就得找领域数据训练过的模型,或者自己微调一个,不然预期的效果和实际效果会有不小的差距。

顺带说一句图片知识。如果直接把图片当知识,处理过程通常是 OCR 提取文本,或者靠模型描述图片内容。这个过程干扰很大。中间产出的文本是不是你想要的样子,描述维度是不是你关心的,都需要把关,不然后面的检索召回肯定是笔糊涂账。

4. 混合检索

混合检索把两种检索方式合在一起:一种是基于关键词的稀疏向量检索,一种是基于语义的稠密向量检索。

稀疏向量这边,代表算法是 BM25。BM25 的核心是词频和逆文档频率,最后输出的是查询相对每个文档的分数。代码大致是这样:

# 加载分块后的文档
corpus_json = json.load(open('/Users/jiangdanyang/workspaces/python/MarioPython/src/RAG/dataset/corpus.json'))
corpus_text = [doc["text"] for doc in corpus_json]

# 可选:创建一个词干提取器
english_stemmer = snowballstemmer.stemmer("english")

# 初始化 Tokenizer,传入词干提取器
sparse_tokenizer = Tokenizer(
    stemmer=english_stemmer,
    lower=True,          # 转成小写
    stopwords="english", # 去掉停用词
    splitter=r"\w+",     # 分词规则
)

# 对语料做分词
corpus_sparse_tokens = sparse_tokenizer.tokenize(
    corpus_text,
    update_vocab=True,   # 分词时更新词汇表
    return_as="ids"
)

# 创建 BM25 检索器,把语料挂上去
sparse_index = bm25s.BM25(corpus=corpus_json)

# 用分词后的语料建索引
sparse_index.index(corpus_sparse_tokens)

# 返回最相关的 10 篇文档
sparse_results, sparse_scores = sparse_index.retrieve(query_tokens, k=10)

稠密向量这边,用基于 Transformer 架构的向量化模型把文档编码成向量。查询的时候,对查询用同一个模型编码,再做相似度比对,找出最相似的若干个结果。

# 创建向量数据库客户端
qdrant = QdrantClient(path="/Users/jiangdanyang/workspaces/python/MarioPython/src/RAG/dataset/qdrant_data")

# 创建编码器
dense_encoder = SentenceTransformer('/Users/jiangdanyang/workspaces/python/Model/all-MiniLM-L6-v2')

collection_name = "hybrid_search"
qdrant.recreate_collection(
    collection_name=collection_name,
    vectors_config=models.VectorParams(
        size=dense_encoder.get_sentence_embedding_dimension(),
        distance=models.Distance.COSINE
    )
)

# 把文档编码成向量并上传
qdrant.upload_points(
    collection_name=collection_name,
    points=[
        models.PointStruct(
            id=idx,
            vector=dense_encoder.encode(doc["text"]).tolist(),
            payload=doc
        ) for idx, doc in enumerate(corpus_json)
    ]
)

query_vector = dense_encoder.encode(query).tolist()
dense_results = qdrant.search(
    collection_name=collection_name,
    query_vector=query_vector,
    limit=10
)

最后要把两边的结果合并。常见的做法是:先对稀疏和稠密各自算出来的前几个分数做归一化,然后按权重算一个综合分,比如稀疏占 0.2,稠密占 0.8,最后返回综合分最高的几个文本块。

# 对两类分数做归一化
dense_scores = np.array([doc.get("dense_score", 0) for doc in documents_with_scores])
sparse_scores = np.array([doc.get("sparse_score", 0) for doc in documents_with_scores])
dense_scores_normalized = (dense_scores - np.min(dense_scores)) / (np.max(dense_scores) - np.min(dense_scores))
sparse_scores_normalized = (sparse_scores - np.min(sparse_scores)) / (np.max(sparse_scores) - np.min(sparse_scores))

alpha = 0.2
weighted_scores = (1 - alpha) * dense_scores_normalized + alpha * sparse_scores_normalized

什么时候需要考虑混合检索?当你的场景既要看关键词,又想吃语义,就可以试试。跟纯关键词检索比,混合检索对查询的规范性要求低一些,拼写错了也能容错。跟纯语义检索比,混合检索能更精准地匹配领域专有信息,准确率会好一些。

5. 重排序

检索擅长在海量知识里快速捞出一批相关文档,但这批文档里总有一些跟查询关联度不高。这时候需要重排序,把真正相关的排到前面,最后挑出得分最高的几个结果作为上下文交给模型。

RAG 链路里,常用的重排序技术是交叉编码器。它其实是一个只有编码器的 Transformer 模型,输入查询和文档,计算每个文档的相关性,输出一个 0 到 1 之间的分数,1 表示最相关。

from sentence_transformers import CrossEncoder

cross_encoder = CrossEncoder("/Users/jiangdanyang/workspaces/python/Model/jina-reranker-v1-tiny-en")

hybrid_search_results = {}
with open('/Users/jiangdanyang/workspaces/python/MarioPython/src/RAG/dataset/dense_results.json') as f:
    dense_results = json.load(f)
for doc in dense_results:
    hybrid_search_results[doc['id']] = doc

with open('/Users/jiangdanyang/workspaces/python/MarioPython/src/RAG/dataset/sparse_results.json') as f:
    sparse_results = json.load(f)
for doc in sparse_results:
    hybrid_search_results[doc['id']] = doc

# 这是我们之前检索用的查询
query = "Mixtral 的上下文窗口大小是多少?"
pairs = [[query, doc['text']] for doc in hybrid_search_results.values()]
scores = cross_encoder.predict(pairs)

排序之后,选出分数最高的几个结果拼到上下文里,再调用模型拿最终回答:

client = OpenAI(
    api_key=os.getenv("AI_API_KEY"),
    base_url=os.getenv("AI_API_BASE_URL")
)

completion = client.chat.completions.create(
    model="qwen_max",
    messages=[
        {"role": "system", "content": "你是一个研究助手,首要任务是帮助用户理解研究论文。"},
        {"role": "user", "content": query},
        {"role": "assistant", "content": str(search_results)}
    ]
)

四、结语

AI 应用开发现在确实热闹,大部分团队还是在做基建平台、开发编排工具、现成基础产品的整合,再结合业务场景做链路设计。这个阶段靠的是快速组装的实现能力。

但时间长了,还是得往深水区走。RAG 只是 AI 架构里的一块,其他技术也一样。先快速用起来,再了解技术细节,然后理解产品实现,接着在应用里迭代设计,最后对着效果做循环优化。快速上手有捷径,现有的基础设施已经把门槛压得很低。但要真正把效果抠出来,提升效率,还是得一个环节一个环节地看。

常见问题(FAQ)

RAG效果不好,优化应该从哪些环节入手?

RAG效果不好时,应从文档分块、索引增强、编码、混合检索和重排序等关键环节逐一排查,结合场景调优,平衡召回率和精确率,而非盲目更换模型。

文档分块时语义切分比例低怎么办?

语义切分比例低说明很多块因长度上限被硬切,可调大相似度阈值提升块内相关性,或调整窗口大小改善上下文,也可对文本、表格等不同类型采用差异化切分策略。

向量编码效果差,可能是什么原因?

向量编码效果差可能因模型语言覆盖不足、词汇表过小或语义空间不匹配。英文模型处理中文会失效,需选中文专用模型;小词汇表导致兜底token多,应选词汇量大的模型,或按领域微调。

晓婷深圳
本文由 晓婷 审核,最后更新于 2026年8月23日
联系编辑 →
← 返回文章列表
分享到:微博

版权与免责声明:本文仅用于信息分享与交流,不构成任何形式的法律、投资、医疗或其他专业建议,也不构成对任何结果的承诺或保证。

文中提及的商标、品牌、Logo、产品名称及相关图片/素材,其权利归各自合法权利人所有。本站内容可能基于公开资料整理,亦可能使用 AI 辅助生成或润色;我们尽力确保准确与合规,但不保证完整性、时效性与适用性,请读者自行甄别并以官方信息为准。

若本文内容或素材涉嫌侵权、隐私不当或存在错误,请相关权利人/当事人联系本站,我们将及时核实并采取删除、修正或下架等处理措施。也请勿在评论或联系信息中提交身份证号、手机号、住址等个人敏感信息。