LangChain RAG 检索效果从 40% 提到 87%:我用向量重排 + 查询改写做了这三件事
LangChain RAG 检索效果从 40% 提到 87%:我用向量重排 查询改写做了这三件事说实话,我一开始是真没把 RAG 检索当回事。项目上 LLM 时,我们团队默认走的就是最标准的向量检索:文档切片 → OpenAI embedding → FAISS 向量库 → top-k 召回 → 丢进 prompt。结果上线一周,业务反馈比没接知识库还离谱:同样的问题,换种问法就答错;专有名词一多,直接开始胡说八道。我用官方示例数据集跑了一遍评估,命中率只有 40%。当时我就意识到:不是模型不行,是检索这一步太糙了。后面三周,我把 LangChain 的检索链路拆开重搭了一遍,从 40% 一路拉到 87%。今天把关键改动和踩坑整理出来,给正在做 RAG 的同学一个参考。先搞清楚:为什么纯向量检索会拉胯RAG 核心逻辑不复杂:把用户问题编码成向量,去知识库找语义最相近的文档片段。问题就出在这个语义相近上。向量检索擅长的是同义转换,但工程文档里有大量它搞不定的东西:缩写和专有名词(比如内部系统代号)数字、版本号、错误码多跳推理(“A 功能在 B 版本后的配置变更”)否定和条件限定更麻烦的是,用户问法和文档写法的分布天然不一致。文档是陈述句,用户是疑问句;文档用全称,用户用简称。只靠 embedding 的语义匹配,召回率根本撑不住。我当时的诊断结论很简单:向量检索不是万能的,得给它配两个搭档–查询改写和重排。第一件事:查询改写,把用户问题翻译成文档语言最直接的问题:用户问怎么接入支付网关,文档里写的是支付网关集成指南。embedding 会觉得它们像,但 top-3 里可能混进别的网关文档。我的做法是加一层 query expansion hyde(假设性文档)。用 LangChain 实现很简单:fromlangchain_openaiimportChatOpenAIfromlangchain.promptsimportPromptTemplate rewrite_promptPromptTemplate.from_template(你是一名搜索专家。请将用户问题改写成 3 个不同版本的检索查询, 要求更接近文档的表述风格,保留核心术语。 用户问题:{question} 输出要求:每行一个查询,不要编号。)llmChatOpenAI(modelgpt-4o-mini,temperature0)defexpand_queries(question:str)-list[str]:textllm.invoke(rewrite_prompt.format(questionquestion)).contentreturn[line.strip()forlineintext.strip().split(\n)ifline.strip()]但这只是多撒网。真正提升召回的是 hyde:让模型先根据问题生成一段理想的参考答案,再用这段答案去检索。hyde_promptPromptTemplate.from_template(请基于你的知识,用一段 100 字左右的文档回答以下问题。 这段回答将用于检索相关文档,所以请尽量包含专业术语。 问题:{question})defhyde_query(question:str)-str:returnllm.invoke(hyde_prompt.format(questionquestion)).content实际组合:原始问题 3 个扩展查询 1 个 hyde 文档,一起拿去检索,然后对结果去重合并。这步单独跑评估,命中率从 40% 提到了 61%。第二件事:混合检索,关键词打底 向量补充向量检索搞不定的,关键词检索反而能兜底。我们用 BM25 做全文检索,和向量检索结果做融合。LangChain 里可以搭一个 ensemble retriever:fromlangchain_community.retrieversimportBM25Retrieverfromlangchain_community.vectorstoresimportFAISSfromlangchain.retrieversimportEnsembleRetriever# 假设 docs 已经切好片bm25_retrieverBM25Retriever.from_documents(docs)bm25_retriever.k5vectorstoreFAISS.from_documents(docs,embedding)vector_retrievervectorstore.as_retriever(search_kwargs{k:5})ensembleEnsembleRetriever(retrievers[bm25_retriever,vector_retriever],weights[0.4,0.6])权重我是用验证集调出来的。BM25 0.4、向量 0.6 这个比例对我们内部的 API 文档最稳。不同领域要自己跑几组对比。这里有个细节:BM25 对中文分词很敏感。LangChain 默认按空格分,中文基本废掉。我换掉了 tokenizer:importjieba bm25_retrieverBM25Retriever.from_documents(docs)bm25_retriever.k5# 在检索时内部会调用 preprocess_funcbm25_retriever.preprocess_funclambdatext: .join(jieba.cut(text))这步上去之后,评估到了 72%。第三件事:重排,把真正相关的片段放到前面召回 10 条,如果不重排,LLM 可能看到第 7 条就已经烦了。特别是上下文窗口有限的时候,前 3 条的质量直接决定答案对不对。我们用的是 cross-encoder 重排模型。LangChain 可以接 sentence-transformers 的 cross-encoder:fromlangchain.retrievers.document_compressorsimportCrossEncoderRerankerfromlangchain_community.cross_encodersimportHuggingFaceCrossEncoder modelHuggingFaceCrossEncoder(model_nameBAAI/bge-reranker-v2-m3)rerankerCrossEncoderReranker(modelmodel,top_n3)fromlangchain.retrieversimportContextualCompressionRetriever compression_retrieverContextualCompressionRetriever(base_compressorreranker,base_retrieverensemble)cross-encoder 会拿 query 和每个候选片段一起打分,比双塔向量模型准得多。bge-reranker-v2-m3 在中文上表现不错,离线也能跑。最终流程变成defretrieve(question:str,top_n:int3):# 1. 查询改写queriesexpand_queries(question)[hyde_query(question),question]# 2. 混合检索召回candidates[]forqinqueries:candidates.extend(ensemble.get_relevant_documents(q))# 去重seenset()unique_docs[]fordincandidates:ifd.page_contentnotinseen:seen.add(d.page_content)unique_docs.append(d)# 3. 重排取 top-nreturnreranker.compress_documents(unique_docs,question)[:top_n]这步加上之后端到端命中率从 72% 拉到 87%top-3 精确率也到了 0.81。完整评估指标别只看命中率很多团队做 RAG 只测“正确答案在不在 top-k 里”也就是 Hitk。但用户体感更敏感的是正确答案排第几。所以我会同时看 MRR 和 NDCG。defmrr(relevant_positions:list[int])-float:returnsum(1.0/pforpinrelevant_positionsifp0)/len(relevant_positions)defdcg(scores:list[float])-float:returnsum((2**s-1)/math.log2(i2)fori,sinenumerate(scores))我们三个版本的对比方案Hit3MRR10NDCG3平均延迟纯向量0.400.320.28120ms查询改写0.610.480.45180ms混合检索0.720.590.56220ms重排0.870.780.74380ms延迟从 120ms 涨到 380ms但对我们的客服机器人场景完全可接受。如果你的场景是实时搜索需要把重排模型换成更小的轻量版或者只做重排不 hyde。评估方式:别用肉眼测,一定要跑数据集我们内部整理了 50 对问题-标准答案片段,格式就是 (question, expected_doc_ids)。评估脚本核心逻辑:defevaluate(retriever,dataset):hit0forq,expected_idsindataset:docsretriever.invoke(q)got_ids{d.metadata[doc_id]fordindocs}ifgot_idsset(expected_ids):hit1returnhit/len(dataset)我对照记录了三个版本:基线向量检索:40%查询改写:61%混合检索:72%重排:87%每次改动单独测,避免把多个变量混在一起。这是做 RAG 优化最容易犯的错。踩坑记录切片粒度太粗:一开始按 1000 token 切,结果重排也救不了。改成 256 token 64 token overlap,效果立刻提升。metadata 没加 doc_id:检索结果去重和评估都依赖 doc_id,前期偷懒没加,后面返工补全。重排模型太大:一开始用 bge-reranker-large,CPU 上 10 条要跑 2 秒。换成 v2-m3,延迟降到 200ms,精度只掉 1%。查询改写生成幻觉查询hyde 偶尔会生成包含虚假术语的段落。解决方式是用温度 0并把 hyde 结果和原问题一起检索而不是只用 hyde。没做版本回归测试第一次优化后效果不错但第二次改分词器时把之前调好的权重又打乱了。后来把评估脚本和 baseline 数据集固化成 GitHub Action每次改检索链路必须跑回归。写在最后RAG 不是接入向量库就完事的活儿。检索链路里的每一环–问题怎么表达、用什么方式召回、怎么排序–都会直接影响 LLM 最终答案的质量。我这三板斧:查询改写、混合检索、重排,都不需要什么 fancy 的模型,LangChain 基本都能串起来。但关键是要跑评估、做 ablation,别靠感觉调。如果你也在做 RAG,建议先用一个小数据集把 baseline 跑出来,然后一项一项加改动。数字不会骗人。

相关新闻