1. 项目概述:从“能聊”到“懂行”的Agent进阶之路
上次我们聊了RAG(检索增强生成)的基础搭建,让Agent能“开口说话”,从自己的知识库里找答案。但很多朋友在实际操作后反馈,效果远不如预期:要么检索出来的文档驴唇不对马嘴,要么生成的回答依然在胡编乱造,知识库仿佛成了摆设。这太正常了,因为一个能用的RAG系统,和一个好用的RAG系统,中间隔着一道巨大的鸿沟。今天,我们就来填这道鸿沟,聚焦于RAG系统的“进阶能力”建设。
简单说,基础RAG解决了“有没有”的问题,而进阶RAG要解决“准不准”和“好不好”的问题。这就像给你的Agent从“实习生”升级为“资深专家”。实习生只能照本宣科,而专家懂得在浩如烟海的资料中,快速找到最相关的那几页,并且结合自己的经验和上下文,给出精准、可靠、有深度的解答。我们这次的目标,就是通过一系列核心组件的优化与组合,打造这样一个“专家级”的Agent。整个过程会涉及更精细的检索策略、更智能的上下文处理,以及如何让大模型(LLM)更好地“消化”我们喂给它的信息。我会基于Python生态下的常见工具链,如LangChain、LlamaIndex等,结合我踩过的无数个坑,把每一步的原理、选择和实操细节掰开揉碎讲清楚。
2. 核心架构深化:超越简单的“检索-拼接-生成”
在基础版本中,我们的流程通常是:用户提问 -> 将问题转换为向量 -> 去向量数据库做相似度搜索 -> 把Top K个结果拼接到Prompt里 -> 交给LLM生成答案。这个流程的瓶颈非常明显:检索精度和上下文利用率。
2.1 检索策略的多元化与混合检索
单一向量检索(Embedding Search)严重依赖于文本嵌入模型的质量和文本分块的合理性。对于专业术语、缩写、数字等,向量检索可能失效。因此,引入混合检索(Hybrid Search)是进阶的第一步。
2.1.1 关键词检索的王者回归:BM25别觉得传统方法过时了。BM25是一种基于词频和文档长度的概率检索模型,对于精确匹配关键词、术语、实体名(如“Spring Boot 2.7.0”、“Transformer架构”)的效果,往往比语义向量更直接、更稳定。它的原理是计算查询词与文档的统计相关性分数,不依赖语义理解,因此不存在“语义漂移”问题。
在Python中,我们可以使用rank_bm25库。它的集成并不复杂,核心是构建一个所有文档分块的词条(Token)列表,并为每个查询计算BM25分数。
from rank_bm25 import BM25Okapi import jieba # 中文分词示例,英文可用nltk # 假设 `text_chunks` 是你的文档分块列表 tokenized_corpus = [list(jieba.cut(chunk)) for chunk in text_chunks] bm25 = BM25Okapi(tokenized_corpus) # 对用户查询进行检索 query = "如何配置Spring Boot的数据源?" tokenized_query = list(jieba.cut(query)) bm25_scores = bm25.get_scores(tokenized_query) # 获取Top K个结果的索引 top_k_indices = np.argsort(bm25_scores)[::-1][:k]注意:BM25对分词质量非常敏感。中文必须使用可靠的分词工具(如jieba、HanLP),并考虑是否加入自定义词典(专业术语)。英文则需要注意词干还原(Stemming)和停用词过滤。
2.1.2 混合检索的艺术:分数融合拿到向量检索的相似度分数(假设是余弦相似度,范围[-1,1]或[0,1])和BM25的分数(理论上无上限)后,不能直接相加。我们需要对它们进行标准化(Normalization)和加权融合。
一种常见且有效的方法是使用倒数排名融合(Reciprocal Rank Fusion, RRF)。它不关心分数的绝对数值,只关心排名,避免了分数标准化带来的偏差。
def reciprocal_rank_fusion(vector_results, bm25_results, k=60, c=60): """ vector_results: list of (doc_id, score) from vector search bm25_results: list of (doc_id, score) from BM25 search k: 最终返回的文档数量 c: 常数,通常设为60,用于平滑排名 """ scores = {} # 处理向量检索结果 for rank, (doc_id, _) in enumerate(vector_results): scores[doc_id] = scores.get(doc_id, 0) + 1 / (c + rank + 1) # 处理BM25检索结果 for rank, (doc_id, _) in enumerate(bm25_results): scores[doc_id] = scores.get(doc_id, 0) + 1 / (c + rank + 1) # 按融合分数排序 fused_results = sorted(scores.items(), key=lambda x: x[1], reverse=True) return fused_results[:k]在实际操作中,我通常会先分别进行向量检索和BM25检索,各取Top 20-30个候选文档,然后通过RRF融合得到最终的Top 10。这样可以确保既捕捉到语义相似性,又不漏掉关键词精确匹配的文档。
2.2 查询理解与改写:让Agent“听懂”问题
用户的问题往往是模糊、简短或包含指代的。直接用于检索,效果很差。我们需要一个“查询理解”层。
2.2.1 查询扩展(Query Expansion)利用LLM本身的能力,对原始查询进行扩展或生成多个相关的查询变体。例如,用户问“电脑卡怎么办?”,可以扩展为“计算机运行速度缓慢的解决方案”、“提升PC性能的方法”、“清理系统垃圾以加速电脑”。
from langchain.prompts import ChatPromptTemplate from langchain.chat_models import ChatOpenAI prompt_template = ChatPromptTemplate.from_messages([ ("system", "你是一个专业的查询优化助手。请根据用户原始问题,生成3个不同角度但语义相同的搜索查询,用于文档检索。直接输出查询,用分号隔开。"), ("human", "{original_query}") ]) llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0.3) query_expansion_chain = prompt_template | llm original_query = "Python安装失败" expanded_queries_str = query_expansion_chain.invoke({"original_query": original_query}).content # 输出可能为:“Python安装过程中报错;安装Python时遇到问题如何解决;Python installer failed” expanded_queries = [q.strip() for q in expanded_queries_str.split(';')]然后,我们可以用这多个查询分别进行检索,最后合并去重。这显著提高了召回率。
2.2.2 查询重写(Query Rewriting)在多轮对话中,用户的问题可能是省略的。例如,之前讨论了“Spring Boot”,用户接着问“那数据源配置呢?”。我们需要将当前查询与对话历史结合,重写为一个完整的、可独立检索的查询。
rewrite_prompt = ChatPromptTemplate.from_messages([ ("system", "请根据以下对话历史和最新的人类问题,生成一个完整的、独立的搜索查询。这个查询将用于在一个知识库中检索相关文档。不要回答问题本身,只输出查询语句。"), ("human", "对话历史:{chat_history}\n\n人类最新问题:{question}") ]) rewrite_chain = rewrite_prompt | llm chat_history = "人类:如何创建一个Spring Boot项目?\n助手:你可以使用Spring Initializr网站或IDE的插件。" current_question = "数据源怎么配?" standalone_query = rewrite_chain.invoke({"chat_history": chat_history, "question": current_question}).content # 输出可能为:“Spring Boot项目中配置数据源的方法”这个“完整查询”再送入检索器,效果远好于直接检索“数据源怎么配?”。
3. 上下文构建与优化:给LLM喂“精饲料”
检索到相关文档后,如何把它们组织成有效的Prompt上下文,是决定生成质量的关键。不是简单拼接就完事了。
3.1 智能分块(Chunking)策略回顾与优化
基础分块通常用固定大小的字符窗口(如500字)滑动。但这会切断完整的句子或段落,破坏语义。
3.1.1 基于语义的分块使用句子分割器(如nltk的sent_tokenize或sentence-transformers的SentenceTransformer)先将文本分成句子。然后,计算句子嵌入,根据句子间的余弦相似度进行动态合并,直到块达到预设的大小阈值。这样可以保证块内的句子在语义上是紧密相关的。
3.1.2 基于层次结构的分块如果文档有明确的结构(如Markdown的标题#,##),优先按标题进行分块。一个##二级标题下的所有内容作为一个块,这比固定窗口更符合人类的认知逻辑。在LlamaIndex中,可以使用SimpleNodeParser并设置chunk_size和包含MetadataMode.ALL的Metadata来保留层级信息。
3.2 上下文压缩与选择性注入
当检索到的文档总长度超过LLM的上下文窗口限制时,我们必须做出选择。全部截断是下策。
3.2.1 最大边际相关性(MMR)去重检索到的文档之间可能存在大量重复内容。MMR算法可以在保证相关性的同时,增加结果的多样性。LangChain内置了MMRRetriever。其核心思想是,在选择下一个文档时,不仅考虑它与查询的相关性,还考虑它与已选文档集合的相似度,优先选择相关性高且与已选文档区别大的。
3.2.2 提取式摘要(Extractive Summarization)对于较长的相关文档块,在注入Prompt前,先对其进行摘要压缩。这里不是用LLM生成摘要(那太慢),而是使用更快的提取式方法,例如:
- Lead-3:直接取文档的前三句话。对于新闻、报告类文档往往有效。
- 基于嵌入的句子选择:计算文档中每个句子的嵌入,同时计算查询的嵌入。选择与查询嵌入最相似的Top N个句子,按原顺序拼接。这能直接从长文档中“抽”出最相关的部分。
from sentence_transformers import SentenceTransformer, util import numpy as np def extract_relevant_sentences(document, query, model, top_n=5): sent_encoder = SentenceTransformer(model) sentences = sent_tokenize(document) # 假设已分割 sentence_embeddings = sent_encoder.encode(sentences, convert_to_tensor=True) query_embedding = sent_encoder.encode(query, convert_to_tensor=True) # 计算余弦相似度 cos_scores = util.cos_sim(query_embedding, sentence_embeddings)[0] # 按分数排序并取Top N top_results = np.argsort(-cos_scores.cpu().numpy())[:top_n] # 按原文顺序排序后返回 top_results_sorted = sorted(top_results) return ' '.join([sentences[i] for i in top_results_sorted])3.2.3 元数据过滤与排序在检索时,除了内容,我们还可以利用元数据(如文档类型、创建日期、作者、章节标题)进行过滤和排序。例如,在技术文档中,优先返回“故障排除”章节的内容;在知识库中,优先返回最近更新的文档。这需要在构建索引时,就将这些元数据存储起来,并在检索查询中指定过滤器。
4. Prompt工程的精细化设计
上下文准备好了,如何“问”LLM,同样至关重要。一个糟糕的Prompt会让前面的努力付诸东流。
4.1 RAG专用Prompt模板
不要用通用的“请根据以下上下文回答问题”这种Prompt。一个强大的RAG Prompt应该包含以下几个明确指令:
- 角色与任务定义:明确告诉LLM它要扮演什么角色(如“资深技术专家”)。
- 上下文来源说明:明确指出提供的上下文来自可信知识库,并要求答案必须基于此。
- 答案格式与约束:要求答案结构化(如分点)、简洁、或包含引用。
- 不确定性处理:明确指示如果上下文未提供足够信息,应如实回答“不知道”,严禁编造。
- 引用要求:要求答案中的关键信息,尽可能指出是来自哪一段上下文(例如用【1】、【2】标注)。
一个示例模板如下:
你是一个严谨的技术支持专家。请严格根据提供的“参考上下文”来回答用户问题。 参考上下文:{context}
用户问题:{question} 请遵循以下规则生成答案: 1. 答案必须完全基于上述参考上下文。如果上下文没有提供足够信息,请直接说“根据现有资料,无法确定该问题的答案”。 2. 答案应清晰、有条理,优先使用分点列表。 3. 如果答案中的关键信息对应上下文中的特定部分,请在信息后标注出处,例如【上下文1】。 4. 不要提及“根据上下文”这类字眼,直接将信息融入答案。4.2 少样本示例(Few-Shot)注入
对于复杂或格式要求严格的问答,在Prompt中提供1-2个输入(上下文+问题)和输出(理想答案)的示例,能极大地引导LLM的输出格式和质量。这被称为“少样本学习”。
在你的Prompt模板中,可以在系统指令后加入“示例:”部分。例如,如果你希望答案总是以“核心原因是:”开头,那么就展示一个这样做的例子。
4.3 后处理与验证
生成答案后,工作还没完。增加一个答案验证步骤可以大幅提升可靠性。
- 引用校验:检查答案中声称引用的【上下文X】是否真实存在于提供的上下文中,并且引用的信息是否准确。可以写一个简单的正则表达式匹配和交叉验证程序。
- 幻觉检测:让另一个LLM(或同一个LLM的不同调用)扮演“验证者”,判断生成的答案是否严格基于提供的上下文,并标记出任何可能编造的部分。这虽然增加了成本,但对高可靠性场景是值得的。
- 自我一致性(Self-Consistency):用相同的Prompt和上下文,让LLM生成多个答案(通过调整
temperature参数),然后比较这些答案的核心事实是否一致。如果不一致,可能意味着上下文不充分或问题模糊,需要触发重新检索或向用户澄清。
5. 系统评估与迭代优化
搭建完系统不是终点,必须建立评估体系,持续迭代。没有度量,就没有优化。
5.1 构建评估数据集
不要用感觉评判。你需要一个测试集(QA对):
- 来源:从你的知识库中,人工构造一批“问题”和对应的“标准答案”(或至少是答案所在的文档片段)。
- 类型:应覆盖简单事实型、复杂推理型、多跳问答型(需要结合多个文档)等不同问题类型。
5.2 核心评估指标
对于每个测试问题,运行你的RAG系统,从以下维度评估:
检索阶段指标:
- 命中率(Hit Rate @ K):标准答案所在的文档,出现在检索返回的Top K个结果中的比例。这衡量检索的召回能力。
- 平均精度均值(Mean Average Precision, MAP):不仅关心是否命中,还关心命中文档的排名位置。排名越靠前,分数越高。
生成阶段指标:
- 忠实度(Faithfulness):生成的答案在事实上是否与提供的上下文一致,是否存在幻觉。可以用LLM作为评判员(LLM-as-a-Judge),或者用基于NLI(自然语言推理)的模型自动判断。
- 答案相关性(Answer Relevance):生成的答案是否直接、完整地解决了用户的问题,是否答非所问。
- 引用精度(Citation Precision/Recall):答案中的引用是否准确指向了上下文中支持该信息的具体位置。
5.3 端到端评估与A/B测试
使用LLM(如GPT-4)作为裁判,让它对比系统生成的答案和标准答案(或根据上下文判断),在“正确性”、“完整性”、“清晰度”等维度上进行打分(例如1-5分)。虽然主观,但GPT-4的评判通常与人类有较高一致性。
在线上环境,可以进行A/B测试。将用户流量随机分到新旧两个RAG策略版本,核心观测指标可以是:
- 问题解决率:用户得到满意答案后,是否结束了会话。
- 人工转接率:用户是否要求转接人工客服。
- 平均对话轮次:解决一个问题需要的交互次数。
通过分析评估结果,你可以定位瓶颈:是检索不准?还是上下文组织不好?或者是Prompt指令不清?然后有针对性地进行下一轮优化。例如,发现“忠实度”低,可能是上下文过长导致LLM注意力分散,需要加强上下文压缩;发现“命中率”低,则需要调整检索策略(如引入混合检索)或优化文本分块方式。
整个RAG系统的进阶,就是一个“检索 -> 构建上下文 -> 生成 -> 评估 -> 优化”的持续闭环。没有一劳永逸的银弹,只有基于数据和实验的持续打磨。这个过程虽然繁琐,但当你看到你的Agent从“经常胡说”变得“言之有据”时,那种成就感是实实在在的。