RAG 应用构建与优化:解决检索召回与上下文窗口问题
RAG 系统构建中常面临检索召回率低与上下文窗口限制的问题。本文深入分析了 LLM 上下文窗口中间信息遗忘现象,提出通过优化文本切分策略和引入重排序(Rerank)技术来提升效果。详细介绍了中文文本分割器的实现原理、Embedding 与 Rerank 模型的区别及选型建议,并提供了基于 LangChain 和 FAISS 的完整代码示例。此外,还探讨了查询改写与混合搜索等进阶优化方案,旨在帮助开发者构建更精准、高效的个性化 RAG 应用。

RAG 系统构建中常面临检索召回率低与上下文窗口限制的问题。本文深入分析了 LLM 上下文窗口中间信息遗忘现象,提出通过优化文本切分策略和引入重排序(Rerank)技术来提升效果。详细介绍了中文文本分割器的实现原理、Embedding 与 Rerank 模型的区别及选型建议,并提供了基于 LangChain 和 FAISS 的完整代码示例。此外,还探讨了查询改写与混合搜索等进阶优化方案,旨在帮助开发者构建更精准、高效的个性化 RAG 应用。

前面的章节,我们已经完成了可用的基于知识库回答的 AI 助手。尽管 RAG(Retrieval-Augmented Generation)容易上手,但是要真正掌握其精髓却颇有难度。实际上,建立一个有效的 RAG 系统不仅仅是将文档放入向量数据库并叠加一个 LLM 模型那么简单,这种方式仅时而有效而已。比如我们问些复杂点问题,LLM 的回答往往不尽如人意,提示词的内容并不全面。
在揭开解决方案的神秘面纱之前,我们先来探索一下这个问题的核心。想象一下,你有一个巨大的图书馆,里面有数十亿本书,而你的任务是在这个图书馆中找到与你的研究主题最相关的资料。这就是 RAG 模型的工作——在大规模的文本海洋中进行语义搜索。
为了在这个巨大的图书馆中快速找到答案,我们使用了一种叫做向量搜索的技术。这就像是在一个多维空间中,把每本书的内容压缩成一个小小的向量,然后通过计算这些向量与你的查询向量之间的距离(比如用余弦相似度),来找到最接近的那本书。但是,这里有个小问题。当我们把书的内容压缩成向量时,就像是把一个丰富多彩的故事变成了黑白照片,总会有一些细节丢失。所以,有时候即使是最接近的三本书,也可能遗漏了一些关键的线索。如果那些排名靠后的书里藏着宝藏般的信息,我们该怎么办呢?
一个直观的想法是,把更多的书带回家(增加 top_k 值),然后把它们一股脑儿地交给我们的'大语言模型'。但是,我们真正关心的是召回率,也就是'我们找到了多少真正相关的书'。召回率并不在乎我们带回了多少本书,它只关心我们是否找到了所有相关的书。理论上,如果我们把图书馆里的每一本书都带回家,我们就能达到完美的召回率。然而,现实是残酷的。我们的'大语言模型'就像是一个只能装下有限信息的背包,我们称之为上下文窗口。即使是最先进的模型,比如 Anthropic 的 Claude,它的背包可以装下 100K Token,我们还是不能把所有的书都塞进去。
图表达的意思是如果信息被放置在上下文窗口的中间位置,那么模型回忆或检索这些信息的能力会降低,其效果甚至不如这些信息从未被提供给模型。这里的'上下文窗口'指的是模型在处理语言时能够考虑的文本范围,通常是一个固定长度的序列。这种现象可能是因为在上下文窗口中间的信息相比于靠近窗口开始或结束位置的信息,更容易被后续输入的信息所覆盖或干扰,从而导致模型在需要时难以准确地回忆起这些信息。
假设我们有一个大型语言模型(LLM),它的上下文窗口长度为 10 个句子。我们想要模型根据一段对话来回答问题。对话内容如下:
小明说:'我昨天去了图书馆。'
小华问:'你借了什么书?'
小明回答:'我借了一本关于历史的书。'
小华又问:'那本书是关于哪个时期的?'
小明说:'是关于古罗马的。'
小华说:'听起来很有趣。'
小明补充:'是的,书中有很多关于罗马帝国的细节。'
小华问:'你打算什么时候还书?'
小明回答:'下周三。'
小华说:'我可能也会去借那本书。'
现在,我们要求模型回答问题:'小明借的书是关于什么的?'
如果我们将这个问题放在上下文窗口的中间(例如,在第 5 句和第 6 句之间),模型可能会因为后续的对话内容(如小华对书的兴趣、还书日期等)而分散注意力,导致它回忆起小明借的书是关于古罗马的能力降低。相比之下,如果问题紧跟在第 3 句或第 5 句之后,模型可能更容易直接关联到小明借的书的内容,因为它还没有被后续的对话内容所干扰。
这个例子说明了在上下文窗口中间存储的信息可能会受到后续信息的干扰,从而影响模型回忆这些信息的能力。这也强调了在设计交互式或连续对话系统时,合理安排信息在上下文中的位置对于提高模型性能的重要性。
LLM 的回忆能力指的是它从其上下文窗口内的文本中检索信息的能力。研究表明,随着我们在上下文窗口中放置更多的令牌(tokens),LLM 的回忆能力会下降。当我们过度填充上下文窗口时,LLM 也更不可能遵循指令——因此,过度填充上下文窗口是一个糟糕的想法。
我们可以通过增加向量数据库返回的文档数量来提高检索回忆率,但我们不能在不损害 LLM 回忆能力的情况下将这些文档传递给 LLM。
解决这个问题的方法是,通过检索大量文档来最大化检索回忆率,然后通过最小化传递给 LLM 的文档数量来最大化 LLM 的回忆能力。为了做到这一点,可以采用以下方案:
在前面的例子中,我使用了 RecursiveCharacterTextSplitter。此文本拆分器是推荐用于通用文本的拆分器。它通过一个字符列表参数化,并尝试按顺序在这些字符上拆分,直到块足够小。默认的字符列表是 [\n\n, \n, , ``]。这样做的效果是尽可能长时间地保持所有段落(然后是句子,然后是单词)在一起,因为这些通常看起来是最具有语义相关性的文本部分。
合理地分割文档需要考虑以下因素:
总之,合理地分割文档是构建高效、准确的 RAG AI 助手的关键步骤之一。为了更加贴合中文文档的格式,我们可以尝试使用重写过的切分器:
from langchain.text_splitter import CharacterTextSplitter
import re
from typing import List
# 该方案出自开源项目
class ChineseTextSplitter(CharacterTextSplitter):
def __init__(self, pdf: bool = False, sentence_size: int = 100, **kwargs):
super().__init__(**kwargs)
self.pdf = pdf
self.sentence_size = sentence_size
def split_text(self, text: str) -> List[str]:
if self.pdf:
text = re.sub(r"\n{3,}", r"\n", text)
text = re.sub('\s', " ", text)
text = re.sub("\n\n", "", text)
text = re.sub(r'([;;.!?。!??])([^''])', r"\1\n\2", text) # 单字符断句符
text = re.sub(r'(.{6})([^"'"」』])', r"\1\n\2", text) # 英文省略号
text = re.sub(r'(\…{2})([^"'"」』])', r"\1\n\2", text) # 中文省略号
text = re.sub(r'([;;!?。!??]["'"」』]{0,2})([^;;!?,。!??])', r'\1\n\2', text)
# 如果双引号前有终止符,那么双引号才是句子的终点,把分句符\n放到双引号后
text = text.rstrip() # 段尾如果有多余的\n就去掉它
ls = [i for i in text.split("\n, , ele)
ele1_ls = ele1.split()
ele_ele1 ele1_ls:
(ele_ele1) > .sentence_size:
ele_ele2 = re.sub(\n, , ele_ele2)
ele2_id = ele2_ls.index(ele_ele2)
ele2_ls = ele2_ls[:ele2_id] + [i i ele_ele3.split() i] + ele2_ls[
ele2_id + :]
ele_id = ele1_ls.index(ele_ele1)
ele1_ls = ele1_ls[:ele_id] + [i i ele2_ls i] + ele1_ls[ele_id + :]
= ls.index(ele)
ls = ls[:] + [i i ele1_ls i] + ls[ + :]
ls
对比效果,几乎全做到了按照段落去切分。
为了解决检索精度问题,我们需要对检索到的文档进行重新排序,并只为我们的 LLM 保留最相关的文档。简单理解的意思就是:对 embedding 检索器出来的 chunks 再次通过重排序模型 rerank 按照分数排序后,筛选出相似度最高的 chunks 作为提示词输入。这也叫两阶段检索系统。
| 特性 | Embedding 模型 | Rerank 模型 |
|---|---|---|
| 检索原理 | 1. 把文档 A 向量化 2. 把问题 B 向量化 3. 对比向量值,检索出类似的文档 | 1. 将查询和某个文档直接输入到 Transformer 中 2. 进行推理步骤,生成相似度分数 |
| 优点 | 检索速度快 | 准确性高,能更准确理解上下文的意思 |
| 缺点 | 准确性低,高维文本压缩到低维向量空间导致信息丢失 | 检索速度慢 |
| 总结 | 提供粗排,快速筛选候选集 | 提供细排,精确排序结果 |
在实际应用中,这两种模型通常是互补的,结合使用可以提高整个信息检索系统的性能。
常见的 Rerank 模型包括:
| 模型名称 | Reranking Score | 平均得分 |
|---|---|---|
| bge-reranker-base | 57.78 | 57.78 |
| bge-reranker-large | 59.69 | 59.69 |
| bce-reranker-base_v1 | 60.06 | 60.06 |
以下是基于 LangChain 和 FAISS 的完整实现流程,包含了文本分割、索引构建、检索及重排序逻辑。
import os
from typing import List
import nltk
from langchain_community.document_loaders import UnstructuredWordDocumentLoader
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_community.vectorstores import FAISS
from langchain_text_splitters import RecursiveCharacterTextSplitter
from pypinyin import pinyin, Style
from LlmClient import LlmClient
from RerankModel import RerankerModel
from configs import rerank_model_path, embedding_path, filepath
from splitter.chinese_text_splitter import ChineseTextSplitter
nltk_data_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'zhipu_chat/nltk_data')
nltk.data.path.insert(0, nltk_data_path)
# 创建嵌入模型
embeddings = HuggingFaceEmbeddings(model_name=embedding_path)
# 获取文件名并生成数据库 id
file_name = os.path.basename(filepath)
pinyin_names = pinyin(file_name, style=Style.NORMAL)
kb_id = ''.join([item[0] for item in pinyin_names]).replace('.', '_')
faiss_index_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), kb_id, 'faiss_index')
def merge_splits(docs) -> List:
new_docs = []
for doc in docs:
if not new_docs:
new_docs.append(doc)
:
last_doc = new_docs[-]
(last_doc.page_content) + (doc.page_content) < :
last_doc.page_content += + doc.page_content
:
new_docs.append(doc)
splitter = RecursiveCharacterTextSplitter(
separators=[, , , , , , , , , , , , , , , ],
chunk_size=,
chunk_overlap=,
)
end_docs = splitter.split_documents(new_docs)
end_docs
os.path.exists(faiss_index_path):
(, faiss_index_path)
index = FAISS.load_local(folder_path=faiss_index_path, embeddings=embeddings, allow_dangerous_deserialization=)
:
loader = UnstructuredWordDocumentLoader(filepath)
text_splitter = ChineseTextSplitter()
splits = loader.load_and_split(text_splitter)
splits = merge_splits(splits)
index = FAISS.from_texts(
texts=[doc.page_content doc splits],
embedding=embeddings
)
index.save_local(folder_path=faiss_index_path)
(, faiss_index_path)
llm_client = LlmClient()
:
user_input = ()
user_input.lower() == :
()
doc_score = index.similarity_search_with_score(user_input, k=)
doc, score doc_score:
doc.metadata[] = score
docs = [doc doc, score doc_score]
retrieval_documents = (docs, key= x: x.metadata[], reverse=)
reranker_model = RerankerModel(rerank_model_path)
scores = reranker_model.score_pairs([(user_input, doc.page_content) doc retrieval_documents])
doc, score (retrieval_documents, scores):
doc.metadata[] = score.tolist()
rerank_documents = (retrieval_documents, key= x: x.metadata[], reverse=)
rerank_documents = [doc doc rerank_documents doc.metadata[] > ]
rerank_documents = rerank_documents[: ]
llm_client.query(prompt=.join(doc.page_content doc rerank_documents),
user_input=user_input)
除了上述基础优化外,还可以考虑以下策略进一步提升 RAG 效果:
构建高效的 RAG 系统需要平衡检索召回率和 LLM 上下文窗口的限制。通过精细化的文本切分、引入重排序模型以及采用混合搜索等进阶策略,可以显著提升问答系统的准确性和用户体验。开发者应根据实际业务场景,不断调试和优化各个模块的参数,以达到最佳效果。

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
生成新的随机RSA私钥和公钥pem证书。 在线工具,RSA密钥对生成器在线工具,online
基于 Mermaid.js 实时预览流程图、时序图等图表,支持源码编辑与即时渲染。 在线工具,Mermaid 预览与可视化编辑在线工具,online
解析常见 curl 参数并生成 fetch、axios、PHP curl 或 Python requests 示例代码。 在线工具,curl 转代码在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online