
做过 RAG(检索增强生成)项目吗?我做过,上线一周后系统在凌晨三点崩了,回答自信但完全错误,日志里看不出任何问题。
大多数 RAG 教程教你搭 demo。这篇文章讲的是 demo 能跑之后会发生什么。
我踩过足够多次坑,所以现在遇到问题不会先怀疑 LLM(大语言模型),而是逐层检查 pipeline。
为了具体说明,我用一个贯穿全文的例子:一个值班 AI 助手,帮助工程师实时排查故障。它会摄入运维手册、历史故障复盘、内部架构文档和最近的告警。凌晨三点被叫醒时,你问"Redis P99 延迟飙升,我该先查什么?",它能给你一个真正有用的回答——基于你们团队文档的回答。
第一次被分词搞崩 RAG pipeline 时,我索引了大约 3000 份文档,却找不到检索质量为什么会随着文档变长而下降的原因。原来是我的嵌入模型的 tokenizer 静默截断了每个长文本块末尾大约 15% 的内容。我不得不重新导入所有文档。
这件事让我学到了:在任何一个词进入嵌入模型之前,它会被转换成 token(词元)。而分词方式会塑造下游的一切——决定文本块的行为,也决定模型如何理解一个句子。
可以把分词想象成 Scrabble 字母牌。英语约有 17 万个单词,但你不是一个字母牌对应一个单词。你得到的是一组固定的字母牌,覆盖常见的字母组合。"unbelievable" 可能被切成三个字母牌:"un"、"believ"、"able"。技术词汇分得更碎,因为它们在训练数据中出现频率较低。
大多数现代分词器的工作方式是反复合并常见字符模式,生成可复用的 token。对 RAG 来说,具体分词算法没那么重要,重要的是实际影响:你的文本块大小是以模型 token 数量来衡量的,而不是人类理解的单词数。如果不测量这一点,你的检索语料在索引开始前可能就已经损坏了。
import tiktoken enc = tiktoken.get_encoding("cl100k_base") text = "Retrieval-augmented generation uses vector embeddings"
tokens = enc.encode(text)
decoded = [enc.decode([t]) for t in tokens] print(f"Token count: {len(tokens)}")
print(decoded)
# ['Ret', 'riev', 'al', '-', 'augmented', ' generation', ' uses', ' vector', ' embed', 'dings']
"512 token 的文本块"不等于 512 个单词。约等于正常散文的 350–400 个单词,代码或领域术语会更少。如果你的嵌入模型有 512 token 限制,而你输入了 600 token 的文本块,它可能会静默截断末尾部分。没有报错,就是上下文缺失了。
运维手册是这种情况的重灾区。错误代码分词清晰,但行内 shell 命令会碎成 8 个以上的 token。一个单词数看起来很小的手册文本块,可能很快就会超过 token 限制——而被截掉的部分通常是解决方案。
在任何生产环境的 RAG pipeline 中,这一层悄悄决定了你的长文档是否能完整保留。在摄入阶段记录 token 数量——每次都要记录。
⚡ 生产环境要点:摄入阶段一定要记录 token 数量。静默截断可能悄悄删掉模型所需的上下文。
分词奠定了基础。下一个决策决定了你的文本能否被搜索:如何把文档切成文本块。
有个事我希望早点知道:如果文本块质量差,其他层都没用。我见过团队花两周调优嵌入模型和重排器,真正的问题其实是他们的文本块把表格劈成了两半。先把分块做对,其他都是在这个基础上优化。
把分块想象成把教科书切成抽认卡。太大了每张卡片信息量太大。太小了卡片上写着"它引用了前一个实体",你根本不知道是什么。
下面的示例用 LangChain 演示思路,相同的分块策略也适用于自定义摄入任务。
from langchain.text_splitter import TokenTextSplitter
splitter = TokenTextSplitter(chunk_size=256, chunk_overlap=32)
chunks = splitter.split_text(document)
对大多数文档类 RAG pipeline 来说,设置重叠比硬切安全。没有重叠的话,跨边界的句子会被切分,含义分散到两个文本块——两个都检索不到。
固定大小分块简单、可预测,但它可能在不理解文档结构的情况下切断表格、列表、流程或段落。
先尝试按段落拆分,然后按句子,然后按单词,然后按字符。生成语义连贯的文本块,而不需要把每个句子都嵌入进去。对任何 RAG pipeline 来说都是可靠的默认选择。另一个选择是语义分块,由语义本身决定在哪里切分。
嵌入每个句子,找出相邻句子之间余弦相似度急剧下降的位置——那是主题转换点,在那里切分。
语义分块听起来比实际表现更好。大多数真实项目中,质量提升是有的,但摄入成本会出乎团队意料。我会先用递归分块,上线后测量检索质量,只有当指标告诉你需要时才升级。对于生产文档 RAG,我通常更偏好父子分块方案。
用小文本块(128 token)做索引以提高检索精度,返回大父文本块(512 token)给 LLM 作为生成上下文。
small_chunk = vectorstore.similarity_search(query, k=5)[0]
parent_id = small_chunk.metadata["parent_id"]
full_context = parent_store.get(parent_id)
小文本块精确匹配,大父文本块给 LLM 足够的上下文来连贯回答。这是一个在追求更复杂方案之前的强力默认选择。
权衡在于父文本块可能添加额外上下文,所以你仍然需要限制父文本块的大小。
对值班助手来说,这对分步骤的运维手册非常合适。将每一步索引为小文本块,以便检索能精确定位具体操作,但返回完整的手册部分,让工程师了解为什么这个操作很重要。
⚡ 生产环境要点:糟糕的文本块在嵌入模型、向量数据库或重排器还没发挥作用之前就已经搞崩检索了。
现在你有了格式良好的文本块。下一层把它们变成可搜索的东西。
嵌入把文本转换成向量:数百或数千个浮点数,代表语义。语义相似的文本在这个空间里几何距离接近。经典例子是 king - man + woman ≈ queen——嵌入能把语义关系编码为几何模式,即便真实的嵌入空间比这个例子混乱得多。
但检索表示并不都是一样的。实际上有三种模式值得了解。
稀疏词法检索:BM25 和 TF-IDF使用词汇量大小的向量,大部分值为零。只有文本中实际出现的词才是非零的。
BM25 是一个排名函数,根据查询词出现的频率、词的罕见程度和文档长度对文档评分。它被广泛应用于搜索引擎,因为它快速、可解释,对精确关键词匹配非常有效。
但当用户和文档用不同的词表达同一个概念时,它就失效了。
有一段时间我调试的几乎每个检索 bug 都源于 BM25 和稠密检索的不匹配。用户用了同义词,BM25 返回空结果,稠密检索找到了语义相关但不够精确的内容。一旦你了解了每种方法的适用场景,就不用再瞎猜了。
对值班助手来说这一点很关键。像 ECONNREFUSED、OOMKilled、503 这样的精确错误码需要词法搜索才能快速命中。"数据库查询在高负载下超时"这样的症状描述需要稠密嵌入来匹配写"连接池耗尽"的手册。单独任何一种都覆盖不了凌晨三点你会输入的查询。
稠密嵌入(BERT、Sentence Transformers)为每个文本生成一个紧凑向量——每个维度都非零,语义意义都压缩在里面。这才是大多数人说"嵌入模型"时的意思。
from sentence_transformers import SentenceTransformer
import numpy as np model = SentenceTransformer("all-MiniLM-L6-v2") docs = [ "The automobile industry is shifting to electric vehicles", "Car manufacturers are investing in battery technology", "BM25 is a keyword-based retrieval algorithm",
] query = "battery-powered auto makers" doc_vectors = model.encode(docs)
query_vector = model.encode([query]) similarities = np.dot(doc_vectors, query_vector.T).flatten()
print(similarities)
# [0.721, 0.683, 0.201]
即便措辞变化,稠密嵌入仍能找到正确的文档。BM25 在这里要脆弱得多,因为它依赖词汇重叠。
弱点在另一边:稠密模型可能错过精确匹配。查询 GPT-4o,稠密检索器可能命中关于 GPT-3 的文档,因为语义接近,即使精确版本很重要。
晚期交互(ColBERT)为每个 token 保留独立向量,而不是将整个文本压缩成一个向量。查询时,每个查询 token 找到最佳匹配的文档 token,这些匹配组合成最终分数。这可以提高长文档的精度,因为模型不会丢失那么多 token 级别的细节。权衡是存储和服务复杂性。
我在一个项目中评估过 ColBERT,检索质量是瓶颈。基准测试确实更好——测试集上约提高 8%。但存储成本是 14 倍。我们选择了稠密检索加交叉编码器重排器,质量和 ColBERT 差距在 2% 以内,基础设施账单也合理。知道它存在是值得的。但也要诚实地说:稠密检索加重排在很多生产用例中用一小部分复杂度就能覆盖。
模型选择很重要,但更重要的教训是:在你自己的查询上做基准测试。排行榜不知道你的文档、用户或失败模式。
⚡ 生产环境要点:稠密嵌入擅长语义。BM25 擅长精确词匹配。生产系统通常两者都需要。
现在可以把文本块转换成向量了。下一个问题是在有数百万向量的情况下,如何在 100 毫秒内找到正确的那些。
去年我上线了一个 pipeline,测试时表现很好——亚秒响应,准确回答。六个月后,当我们已经摄入了约 5 倍的文档时,查询耗时 7-8 秒。代码没有任何改动。我花了两个整天以为是嵌入模型质量下降了,最后才意识到向量索引是瓶颈。
我从来没认真去了解我调用的向量数据库里面到底是什么——当它出问题的时候,我没有调试框架。
大多数 RAG 教程对这一层一笔带过:"用 阿里云 OpenSearch 就好了。"在你 pipeline 变慢、检索到错误上下文、或者模型因为召回率太低而只能瞎猜之前,这样就够了。
有了数百万个嵌入,暴力比较很快就会对交互系统来说太慢。生产系统通常需要近似最近邻(ANN)索引。ANN 意味着:系统不是检查每个向量,而是更快地找到接近最优的匹配,用少量召回率换取大幅提速。
HNSW(分层可导航小世界)是最常用的索引类型之一。它构建一个多层图:顶层节点较少,有长距离连接,像高速公路;底层连接更密,像本地街道。查询从顶层进入贪婪地向答案方向导航,然后逐层下沉获得更精细的分辨率。
当团队说"调优索引",通常指的是调优召回率、延迟、内存和构建时间之间的权衡。具体参数取决于你使用的索引算法。
对于基于 HNSW 的索引,常用参数包括:
M / m —— 每个向量在图中的连接度。值越高通常召回率越好,但占用更多内存。ef_construction / efConstruction —— 索引构建时投入的工作量。值越高索引质量可能越好,但索引会更慢。ef_search / efSearch / hnsw_ef —— 查询时搜索探索的候选数。值越高通常召回率越好,但延迟增加。import hnswlib index = hnswlib.Index(space="cosine", dim=384)
index.init_index(max_elements=1_000_000, ef_construction=200, M=16)
index.set_ef(50) # 在查询时调优,权衡召回率和延迟
其他索引类型暴露不同的参数。IVF(倒排文件索引)把向量空间分成簇,只搜索最近的几个——用 nprobe 这样的参数调优探查的簇数。量化索引调优压缩设置。一些托管向量数据库隐藏了大部分细节,只暴露更高级的设置。
在生产环境中,你还需要考虑元数据过滤——只搜索受影响服务、地域、环境或时间窗口的手册——因为过滤会同时改变延迟和召回率。
重要的不是参数名。是权衡:更好的召回率通常意味着更多内存、更长的索引时间或更高的查询延迟。
用大白话说:向量索引是捷径。它们不把你的查询和每个向量比较,只搜索空间中最有希望的部分。你走的捷径越多,搜索越快,但你越可能错过最佳结果。索引调优就是决定你愿意用多少准确率换速度。
对值班助手来说,这是硬性要求。检索必须足够快,让整个助手感觉是交互的。如果向量搜索本身就要 8 秒,工程师已经手动打开手册了。
⚡ 生产环境要点:向量索引是速度-召回率的权衡。只有在测量了延迟和检索质量之后才调优。
现在可以快速检索到正确的向量了。下一个问题是:哪种检索策略?
我的第二个 pipeline 遇到的第一个生产问题是:用户输入"年假政策",文档里写的是"年度请假权利"。稠密检索找到了它,但排在第 4 位。BM25 没有任何贡献,因为词汇不重叠。
技术上系统是正常的。实际上却失败了——没有人会在真实任务中滚动浏览五个 RAG 引用。
混合搜索把正确文档移到了可见结果中,那一刻我就不再信任任何单一的检索方法了。
并行运行 BM25 和稠密检索。用互惠排名融合(RRF)合并——一个简单的算法,通过奖励在任一列表中排名靠前的文档,把多个排名列表合并成一个:
def reciprocal_rank_fusion(rankings, k=60): scores = {} for ranking in rankings: for rank, doc_id in enumerate(ranking): scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1) return sorted(scores, key=scores.get, reverse=True) bm25_results = ["doc3", "doc1", "doc5"]
dense_results = ["doc1", "doc2", "doc3"]
fused = reciprocal_rank_fusion([bm25_results, dense_results])
# ["doc1", "doc3", "doc2", "doc5"]
在两个列表中都排名靠前的文档,胜过只在其中一个排名第一的文档。BM25 捕获稠密遗漏的精确匹配。稠密捕获 BM25 忽略的同义词。组合排名通常优于单独任何一种,尤其当你的查询混合了精确词和语义描述时。
想象对值班助手的作用:工程师输入"Redis P99 延迟飙升",对应标题为"Redis 尾延迟调查"的手册。稠密检索接近(排第 4)。BM25 完全遗漏(没有共享关键词)。混合搜索把它推到第 1 位,因为两种方法从不同角度给它投票了。
MMR 是一种排名策略,选择既与查询相关又相互不同的结果。
实际上,当你的语料包含同一概念的重复版本时,它最有用——多份同一故障的复盘、类似的手册、重复的 wiki 页面、复制的故障排除步骤。没有 MMR,你的 top-5 会变成同一文本块的五个近重复版本。
retriever = vectorstore.as_retriever( search_type="mmr", search_kwargs={"k": 5, "fetch_k": 20, "lambda_mult": 0.7},
)
lambda_mult 是调节旋钮——接近 1 优先相关性,接近 0 优先多样性。0.7 是合理的默认值。
⚡ 生产环境要点:混合搜索和 MMR 帮助你的系统检索到既相关又多样的上下文,而不是同一文本块的五个近重复版本。
混合搜索给你一个好的 top-20。下一层把它变成一个出色的 top-5。
这是我搭建过的任何 RAG pipeline 中,单次改进带来最大质量提升的一层。
重排之前,我们的 RAGAS 忠实度分数是 0.71。加上交叉编码器重排器对 top-20 候选集进行重排后,跳升到 0.86——比切换到更大的嵌入模型加上调优文本块大小加起来的效果都大。这是任何 pipeline 评审中我会首先推动的一个改动。
RAGAS 是 RAG 系统的评估框架;我会在第 8 层再展开讲。这里重要的是:重排改变了我们发给 LLM 的内容质量。
你的双编码器搜索独立计算查询和文档向量。这让它很快,但不够精确,因为模型从未同时看到查询和文档。
交叉编码器是一个模型,把查询和文档作为单一输入,计算它们匹配程度的分数——比独立编码慢,但精确得多,因为它可以直接关注它们之间的交互。
from sentence_transformers import CrossEncoder reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
candidates = [chunk1, chunk2, chunk3, ...] # 第5层的 Top-20
scores = reranker.predict([(query, doc) for doc in candidates])
reranked = sorted(zip(candidates, scores), key=lambda x: -x[1])
权衡是延迟,你不能对数百万文档运行交叉编码器,只能对你已经检索到的 top-20 运行。宽泛检索,精确重排。
对值班助手来说,这就是"这里有 5 个可能相关的东西"和"这里是你首先要检查的"之间的区别。重排让助手感觉有用,而不是像个花哨搜索栏。
⚡ 生产环境要点:宽泛检索,精确重排,只把最好的文本块发给 LLM。