RAG系列(四):RAG系统性能优化技巧

543 阅读16分钟

搭建基础的RAG系统只是第一步,要使其在实际应用中表现出色,性能优化至关重要。优化可以从检索模块、生成模块以及系统整体等多个层面进行。

检索模块优化 (Optimizing Retriever)

检索质量是RAG系统的基石,所谓“垃圾进,垃圾出”,如果检索不到相关的上下文,LLM也难以生成高质量的答案。

技巧1:选择更优的Embedding模型

  • 原理:  Embedding模型的质量直接决定了文本语义表示的准确性。一个好的Embedding模型能使语义相似的文本在向量空间中更接近,从而提高检索的相关性。

  • 实现要点/配置:

    • 参考榜单:  关注MTEB (Massive Text Embedding Benchmark) 和针对特定语言(如中文的C-MTEB)的评测榜单,选择在相关任务上表现优异的模型。
    • 模型大小与性能权衡:  通常,参数量更大的模型(如bge-large-zh-v1.5对比bge-small-zh-v1.5)效果会更好,但推理速度更慢,资源消耗也更大。需要根据实际硬件和延迟要求进行权衡。
    • 领域适应性:  如果有特定领域的语料,可以考虑使用在该领域数据上微调过的Embedding模型,或者自行微调通用模型以提升领域相关性。
    • 及时更新:  Embedding技术也在快速发展,定期关注是否有新的、效果更好的模型出现,并考虑升级。

技巧2:查询重写/扩展 (Query Rewriting/Expansion)

  • 原理:  用户的原始查询可能存在口语化、指代不明、信息不完整等问题,直接用于检索效果可能不佳。通过LLM对原始查询进行“预处理”,可以生成更适合向量检索的查询。

  • 实现要点/代码片段 (LangChain示例 - 查询重写):

    假设llm是一个已初始化的LLM实例 (如ChatOpenAIOllama)。

from langchain.chains import LLMChain
from langchain_core.prompts import PromptTemplate

# rewrite_llm = llm # 可以用与主生成LLM相同的模型,或一个更轻量的模型

rewrite_template_str = """
你的任务是将用户提出的原始问题改写成一个更清晰、更具体、更适合进行向量数据库检索的版本。
请保留原始问题的核心意图,但可以澄清模糊表达、补全省略的关键信息。
例如,如果用户问“那个新功能怎么样?”,假设你知道“那个新功能”指的是“智能摘要功能”,
你可以改写为“智能摘要功能有哪些优点和缺点?”。

原始问题:{original_query}
改写后的问题:"""
rewrite_prompt = PromptTemplate.from_template(rewrite_template_str)

# query_rewriter_chain = LLMChain(llm=rewrite_llm, prompt=rewrite_prompt) # 旧版LLMChain
# 使用LCEL风格构建
query_rewriter_chain = rewrite_prompt | llm | StrOutputParser()


# 假设 user_query 是原始用户输入
# original_user_query = "RAGFlow的部署麻烦吗?" 
# rewritten_query = query_rewriter_chain.invoke({"original_query": original_user_query})
# print(f"原始查询: {original_user_query}")
# print(f"改写后用于检索的查询: {rewritten_query}")
# # 之后,使用 rewritten_query 来调用 retriever.invoke()

查询扩展则可能涉及生成多个相关查询,然后并发检索并将结果合并。

技巧3:重排阶段 (Reranking Stage)

  • 原理:  初步的向量检索(也称“召回”)通常会返回Top-K个候选文档块,这些文档块在语义上与查询相似。但这种相似度并不总能完美代表“相关性”,尤其是在细微差别或特定约束条件下。重排阶段引入一个更精细(通常也更慢)的模型,对这K个候选文档块进行二次排序,以提升最终送入LLM的上下文质量。

  • 实现要点:

    • Cross-Encoder模型:  与Bi-Encoder(用于生成Embedding的模型,独立编码查询和文档)不同,Cross-Encoder会同时接收查询和单个文档块作为输入,并输出一个相关性得分。这使得它能更深入地理解查询与文档之间的交互关系。例如,BAAI/bge-reranker-largems-marco-MiniLM-L-12-v2是常用的Cross-Encoder模型。
    • 集成到LangChain:  LangChain提供了集成重排器的组件,如FlashRankRerank (基于轻量级FlashRank库) 或可以自定义封装sentence-transformersCrossEncoder
# 示例:使用 sentence-transformers 的 CrossEncoder (概念性)
# from sentence_transformers.cross_encoder import CrossEncoder
# reranker_model = CrossEncoder('BAAI/bge-reranker-base') # 选择一个合适的reranker模型

# # 假设: 
# # retrieved_docs: List[Document] 是初步检索得到的文档列表
# # user_query: str 是用户查询

# if retrieved_docs:
#     query_doc_pairs = [[user_query, doc.page_content] for doc in retrieved_docs]
#     try:
#         scores = reranker_model.predict(query_doc_pairs, show_progress_bar=False)
        
#         # 将分数与文档配对并按分数降序排序
#         reranked_docs_with_scores = sorted(
#             zip(scores, retrieved_docs), 
#             key=lambda pair: pair[0], 
#             reverse=True
#         )
        
#         # 获取重排后的文档列表
#         reranked_docs = [doc for score, doc in reranked_docs_with_scores]
        
#         # print("\n--- 重排后的文档 (Top 3) ---")
#         # for i, doc in enumerate(reranked_docs[:3]):
#         #     print(f"Rank {i+1} (Score: {reranked_docs_with_scores[i][0]:.4f}): {doc.page_content[:100]}...")
#         # # 后续使用 reranked_docs (或其Top-N) 作为LLM的上下文
#     except Exception as e:
#         print(f"重排失败: {e}. 将使用原始检索结果。")
#         # reranked_docs = retrieved_docs # 出错则回退
# else:
#    reranked_docs = []
  • 平衡效果与延迟:  重排会增加额外的计算开销。通常只对初步召回的一个小子集(如Top 10-20个文档)进行重排。

技巧4:混合检索 (Hybrid Search)

  • 原理:  向量检索(稠密检索)擅长捕捉语义相似性,但在精确匹配关键词(尤其是专有名词、ID或罕见词)方面可能不如传统的稀疏检索方法(如BM25, TF-IDF)。混合检索结合两者的优势,通常能获得更鲁棒的检索效果。

  • 实现要点:

    • 分别检索再融合:  分别使用向量检索和关键词检索(如Elasticsearch或基于BM25的库)获取两组结果,然后使用某种融合策略(如Reciprocal Rank Fusion - RRF,或简单的加权)合并和重排序结果。
    • 原生支持的数据库:  一些现代向量数据库(如Weaviate, Qdrant, Elasticsearch 8.x+)已经原生支持混合检索,允许在一次查询中同时指定向量和关键词条件。
    • LangChain支持:  LangChain也支持构建混合检索器,例如通过EnsembleRetriever组合多个不同类型的检索器。

技巧5:优化文本分块策略 (Chunking Strategy Optimization)

  • 原理:  文本分块是RAG流程的起点,分块的质量直接影响后续所有步骤。不恰当的分块(过大导致噪音,过小丢失上下文,切断语义)会严重损害RAG性能。

  • 实现要点:

    • 语义分块 (Semantic Chunking):  尝试使用模型(如小型LLM或专门的分割模型)或基于语义相似性的算法(如比较句子嵌入向量)来识别文本中的自然语义边界,而不是简单地按固定长度切分。

    • 父文档检索 (Parent Document Retriever) / 小块嵌入-大块检索:  这是一个重要的策略。具体做法是:

      1. 将文档分割成较小的、语义集中的子块(child chunks)用于生成Embedding和进行检索。
      2. 同时,保留这些子块与其所属的更大父块(parent chunks)或原始文档的关联。
      3. 当检索到相关的子块时,实际提供给LLM作为上下文的是其对应的父块或包含该子块的更完整段落。

      这样做的好处是:检索时利用小块的精确性,生成时利用大块的上下文完整性。LangChain的ParentDocumentRetriever 就是为此设计的。

    • RAPTOR (Recursive Abstractive Processing for Tree-Organized Retrieval):  一种更高级的分块和检索策略。它递归地对文本块进行聚类和摘要,构建一个多层次的摘要树。查询时,可以在树的不同层级进行检索,整合来自不同粒度(从详细文本块到高度概括的摘要)的信息,特别适合处理非常长的文档或需要多层次理解的任务。

    • 调整chunk_sizechunk_overlap:即使使用基础的RecursiveCharacterTextSplitter,也需要根据文档特性和模型能力仔细调整这两个参数。通常需要实验来找到最佳值。

image.png

生成模块优化 (Optimizing Generator)

即使检索到了高质量的上下文,LLM生成答案的环节也同样需要优化,以确保最终输出满足用户期望。

技巧1:精细化Prompt调优 (Advanced Prompt Engineering)

  • 方法:  Prompt是与LLM沟通的桥梁,其质量直接影响LLM的行为和输出。

    • 角色扮演 (Role-playing):  在Prompt中明确赋予LLM一个角色,如“你是一位资深的[领域]专家顾问...”,这有助于LLM调整其语言风格和知识侧重。
    • 思维链 (Chain-of-Thought, CoT):  指导LLM在生成最终答案前,先进行一步步的思考和推理。例如,在Prompt中加入“请首先分析提供的上下文信息,识别出与用户问题直接相关的关键点,然后基于这些关键点组织你的回答。”这能引导LLM生成更有条理、更深入的答案。
    • Few-shot示例 (In-Context Learning):  在Prompt中提供几个高质量的“问题-上下文-答案”示例,LLM可以从中学习期望的回答格式和风格。
    • 结构化输出指令:  如果需要LLM以特定格式(如JSON对象、Markdown表格、列表)输出答案,需要在Prompt中明确指示,并最好提供一个格式示例。
    • 处理“我不知道”的情况:  正如我们之前Prompt模板中包含的,明确指示LLM在上下文中找不到答案时应如何回应(例如,直接说明信息不足,而不是猜测或编造),这对于控制幻觉非常重要。
  • 示例(CoT增强):

    基础Prompt可能只是简单要求基于上下文回答。加入CoT的Prompt可能如下:

... (其他部分同前) ...

【上下文信息】:
---
{context_str}
---
【用户问题】: {user_query}

【你的思考过程】: (请你在这里一步步思考如何回答问题,例如:1. 理解用户问题的核心。2. 在上下文中寻找相关信息。3. 如果找到,如何组织答案。如果没找到,如何回应。)
【你的回答】:
  • 虽然LLM不一定会显式输出“【你的思考过程】”这部分内容给用户(除非你要求),但这个指令会引导其内部处理过程。

技巧2:LLM参数调整 (LLM Parameter Tuning)

  • 关键参数及其影响:

    • temperature: 控制生成文本的随机性/创造性。值越低(如0.0-0.3),输出越确定性、越保守、越倾向于选择高概率词汇,适合事实性问答。值越高(如0.7-1.0),输出越随机、越有创造性,但可能增加不准确或跑题的风险。对于RAG,通常建议使用较低的temperature以确保答案的忠实度。
    • top_p (nucleus sampling): 另一种控制生成多样性的方法。它从概率总和达到top_p阈值的最小词汇集中进行采样。通常与temperature二选一或配合使用(例如,设置一个较低的temperature和一个较高的top_p)。
    • max_tokens / max_new_tokens: 控制LLM生成答案的最大长度(以token计)。需要合理设置以避免答案过长或被截断。
    • 其他参数如frequency_penaltypresence_penalty等可用于调整重复度。
  • 调整策略:  根据应用场景选择。如果RAG用于创意写作辅助,可以适当提高temperature;如果用于客服或知识查询,则应保持较低的temperature。参数的最佳值往往需要通过实验获得。

技巧3:选择更适合的LLM模型

  • 原理:  不同的LLM在遵循指令能力、总结归纳能力、特定语言(如中文)或特定领域知识的表现上存在差异。

  • 实现要点:

    • 上下文窗口:  RAG通常需要LLM处理较长的上下文(用户查询 + 检索到的文档块)。选择具有更大上下文窗口的LLM(如GPT-4-Turbo, Claude 3系列)可以容纳更多信息,可能提升复杂问题的回答质量。
    • 指令遵循能力 (Instruction Following):  RAG的效果很大程度上依赖LLM能否严格遵循Prompt中的指令(如“仅基于上下文回答”)。一些模型在这方面表现更好。
    • 成本与性能的平衡:  更强大的LLM通常也意味着更高的API调用成本或本地部署资源需求。需要在效果和预算之间找到平衡。
    • 中文场景:  对于主要处理中文内容的RAG系统,优先选择对中文原生支持好、在中文语料上训练充分的LLM,如通义千问、ChatGLM等。
    • 微调 (Fine-tuning):  (高级选项)如果预算和数据允许,可以考虑在特定任务或领域数据上对一个基础LLM进行微调(例如,微调其遵循RAG指令或总结特定风格上下文的能力),但这已超出了基础RAG的范畴。

系统整体优化 (Overall System Optimization)

技巧1:结果缓存 (Caching)

  • 缓存对象与原理:  对于重复的查询或相似的上下文组合,可以缓存中间或最终结果以减少重复计算和API调用,从而加快响应速度并降低成本。

    • 查询Embedding缓存:  用户查询的向量表示可以被缓存。
    • 检索结果缓存:  对于完全相同的查询(或经过规范化后相同的查询),其Top-K检索结果(文档ID或内容摘要)可以被缓存。
    • LLM生成结果缓存:  如果输入给LLM的完整Prompt(查询+精确的上下文组合)完全一致,其生成的答案也可以缓存。这需要非常谨慎,因为上下文的微小变化都可能导致答案不同。
  • 实现方式:

    • 内存缓存:  Python的functools.lru_cache装饰器可用于简单的函数结果缓存。
    • 外部缓存服务:  如Redis、Memcached,适合分布式或需要持久化缓存的场景。
    • LangChain缓存:  LangChain内置了对LLM调用结果的缓存机制(如InMemoryCacheSQLiteCacheRedisCache),可以方便地集成到链中。
# import langchain
# from langchain.cache import InMemoryCache
# langchain.llm_cache = InMemoryCache() # 设置全局LLM缓存 (示例)

# # 之后,对同一个 prompt 的 LLM 调用结果会被缓存
# # llm.invoke("相同的prompt") # 第二次调用会从缓存读取 (如果provider和参数不变)
                    

技巧2:流水线异步化与批处理 (Asynchronous Pipeline & Batching)

  • 适用场景与原理:  RAG链中通常包含多次网络I/O操作(如调用Embedding服务API、向量数据库API、LLM API)。在处理高并发请求时,同步阻塞的方式会导致请求堆积和响应缓慢。异步化可以将这些I/O等待时间利用起来处理其他请求。批处理则可以在调用外部服务(尤其是Embedding和LLM API)时,将多个独立请求打包成一个批量请求,通常能提升总吞吐量并可能降低单位成本。

  • 实现方式:

    • 异步处理 (Asynchronous Programming):  使用Python的asyncio库和async/await语法。FastAPI等现代Web框架原生支持异步请求处理函数。LangChain的许多组件和链也提供了异步版本的方法(如ainvokeaget_relevant_documents)。

      
      # # 示例:LangChain组件的异步调用 (概念性)
      # # async def process_query_async(query: str):
      # #   retrieved_docs = await retriever.ainvoke(query)
      # #   # ... 后续异步处理 ...
      # #   answer = await rag_chain.ainvoke(query) # 假设rag_chain支持异步
      # #   return answer
                          
      
    • 批处理 (Batching):

      • Embedding:  HuggingFaceBgeEmbeddings等通常有embed_documents方法,可以一次性处理一个文档列表,这比逐个调用embed_query(或单个文档的embed_documents)要高效得多。在构建索引时,应尽可能批量处理文档块。
      • LLM调用:  一些LLM API提供方支持批量请求,或者可以通过并发异步调用的方式模拟批量效果。

技巧3:知识库的持续更新与维护

  • 原理:  RAG的一大优势在于能够利用最新的知识。因此,确保知识库内容的时效性和准确性至关重要。这需要一个自动或半自动的机制来更新向量数据库中的索引。

  • 实现方式:

    • 定期重建/增量更新索引:

      • 完全重建:  对于变化非常频繁或难以追踪变更的小型知识库,可以定期(如每天、每周)完全重新加载所有文档,重新进行分割、向量化和索引构建。
      • 增量更新:  更理想的方式。对于新增的文档,执行完整的索引流程并添加到现有数据库中。对于修改的文档,需要先删除旧版本的相关chunks(如果可以定位),然后处理新版本。对于删除的文档,需要从数据库中移除其对应的chunks。这要求能够跟踪文档的变更状态,并对向量数据库有精细的增删改查能力。
    • 数据漂移监控 (Data Drift Monitoring):  监控知识库中的数据分布、主题变化等,确保索引内容与当前业务需求和用户查询模式保持一致。如果发现显著偏移,可能需要调整数据源、预处理逻辑或Embedding模型。

    • 版本控制与回滚:  对知识库的索引建立版本控制机制,以便在更新出现问题时能够快速回滚到稳定版本。

技巧4:针对中文场景的特定优化

  • 中文分词/分块:

    • 分隔符选择:  RecursiveCharacterTextSplitter中的separators参数对于中文尤其重要。除了常见的标点符号(。\n!?,、),还可以考虑加入针对中文段落结构特点的分隔符。
    • 专业分词工具:  对于某些类型的中文文本(如古文、无明显标点的段落、或需要更精细控制词边界的场景),可以考虑在LangChain的文本分割器之前,先使用专业的中文分词工具(如jiebapkusegLTP)对文本进行预分词。然后,文本分割器可以在这些预分词的基础上进行分块,或者调整其分割逻辑。但这会增加流程的复杂性。
    • 字符 vs. Token:  注意chunk_size是以字符计还是以token计。对于中文,一个汉字通常被多数LLM的tokenizer视为一个或多个token。使用如tiktoken库可以估算文本的token数量,以更好地匹配LLM的上下文窗口。
  • 中文字符友好的Embedding模型和LLM:

    • 如前所述,选择明确支持中文且在中文任务上表现良好的模型至关重要。例如,BAAI的BGE系列、M3E系列,以及国内厂商(阿里、智谱等)推出的Embedding和LLM模型。
  • 混合检索的中文适配:

    • 如果使用BM25等基于词频的稀疏检索方法,必须配合中文分词器对查询和文档进行分词处理,否则无法正确匹配。

    (腾讯云ES RAG实践中也强调了中文场景下向量+文本混合搜索的重要性)

通过上述优化技巧的组合应用,并结合持续的监控和评估,可以显著提升RAG系统的性能、稳定性和用户体验。