1. 这不是“加个向量库就叫RAG”,而是召回环节的系统性手术
你是不是也遇到过这样的场景:给大模型喂了几十GB的内部文档,提问“上季度华东区销售策略调整要点”,它却翻出半年前的会议纪要里一句无关的“茶水间维修通知”?或者更糟——直接编造一个看似合理但完全不存在的KPI数字?这不是模型不行,是RAG的“眼睛”没擦干净。标题里说的“混合检索 RAG 全链路”,核心根本不在最后那个生成环节,而在于前面三步:查询增强、双路召回、重排。这三步环环相扣,像一台精密机床的进给、切削、精磨工序,少一步,精度就掉一个数量级。
我做过23个不同行业的RAG落地项目,从制造业设备手册问答到律所合同条款比对,最常被低估的,就是“召回”这个环节。很多人以为把PDF扔进Chroma,再用OpenAI Embedding一算相似度,就万事大吉。结果呢?查准率(Precision)看着还行,但查全率(Recall)惨不忍睹——用户真正需要的那条关键信息,十次有七次压根没被捞上来。问题出在哪?单一向量检索对语义模糊、术语缩写、长尾问题天然乏力。比如“CADENCE位号重排”,向量模型可能把它和“电路板设计”“PCB layout”强关联,但实际业务中,它特指某类EDA工具里元器件编号的自动优化逻辑,和通用电路设计概念差着十万八千里。这时候,光靠向量库,就像用渔网捞绣花针——网眼再密,也漏掉了最关键的那根线。
所以,“向量库和搜索引擎联手”不是锦上添花,是雪中送炭。这里的“搜索引擎”,不是指百度谷歌,而是指基于倒排索引的传统关键词检索引擎,比如Elasticsearch或Meilisearch。它不理解“位号重排”的语义,但它能100%命中文档里每一个出现“CADENCE”和“重排”这两个词的段落。向量库负责理解“为什么重排”,搜索引擎负责确保“所有带重排的CADENCE文档都被翻出来”。两者不是二选一,是左手右手——左手抓语义,右手抓字面。而“查询增强”,就是给这双手戴上战术手套;“重排”,则是最后的质检员,把混在一堆里的真金挑出来。整套流程下来,我们实测的Hit Rate(首屏命中关键答案的概率)从单一向量检索的58%,提升到了89%。这不是玄学,是工程细节堆出来的结果。
2. 全链路拆解:为什么必须是“增强-双路-重排”这个顺序?
2.1 查询增强:不是改写,是给问题做“CT扫描”
很多教程把查询增强(Query Expansion)简单等同于同义词替换,比如把“位号重排”替换成“元器件编号优化”“PIN重排序”。这在RAG里是危险操作。原因很简单:你的知识库原文里如果压根没写“PIN重排序”这个词,模型再怎么联想,也找不到对应段落。真正的查询增强,核心目标只有一个:让原始问题,在知识库的“语言体系”里,获得最大曝光概率。它不创造新词,只挖掘问题里已有的、但容易被忽略的“信号”。
我们常用三种增强策略,按优先级排序:
实体锚定增强(最高优先级):识别问题中的专有名词、缩写、型号,并强制加入检索。比如“CADENCE位号重排”,立刻提取出“CADENCE”“Allegro”(Cadence旗下主流PCB工具)“OrCAD”“PCB”“netlist”“schematic”等强相关实体。这些不是猜测,而是基于领域知识库预定义的实体映射表。我们维护了一个包含2000+ EDA领域术语的映射关系图谱,当用户输入“Cadence”,系统自动关联到“Allegro PCB Designer”“SpectraQuest”“Virtuoso”等具体产品名。这步增强,直接把召回范围从“语义模糊的重排概念”,精准锁定到“Cadence自家工具的操作手册”。
句法结构增强(次优先级):分析问题的语法树,保留核心动宾结构,剥离冗余修饰。原问题“上季度华东区销售策略调整要点”,经过解析,核心骨架是“销售策略 调整 要点”,而“上季度”“华东区”是强限定条件。增强后,会生成多个变体组合:“销售策略 调整 要点 AND 华东区”“销售策略 AND 调整 AND 要点 AND 上季度”“华东区 AND 销售策略 AND 调整”。注意,这里用的是布尔逻辑AND,不是语义拼接。这是为了喂给搜索引擎,确保字面匹配的严格性。
LLM轻量引导增强(最低优先级,慎用):仅在前两步效果不佳时启用。我们不用大模型“重写”问题,而是让它做一道填空题:“用户问的是关于‘CADENCE位号重排’,请列出3个最可能出现在官方文档标题或章节名里的关键词组合,每个组合不超过4个词,且必须全部来自原文常见表述。” 输出可能是:“Allegro 位号重排 设置”“Cadence PCB 重排规则”“位号重排 技术文档”。这避免了LLM幻觉,所有词都来自真实文档的高频短语。
提示:绝对不要用LLM做开放式问题改写。我们踩过坑:一次让模型把“如何解决DDR4内存兼容性问题”改写成“DDR4内存插槽匹配方案”,结果知识库里根本没有“插槽匹配”这个词,召回直接归零。后来改成只让模型提取“DDR4”“内存”“兼容性”“问题”四个原始词,再加“JEDEC标准”“主板BIOS”等预设实体,效果立竿见影。
2.2 双路召回:向量库与搜索引擎,不是并联,是“主备+互补”
“双路召回”这个词听起来很酷,但很多实现只是把向量检索和关键词检索的结果简单拼在一起,再按分数加权。这等于把两个盲人绑在一起走路——一个靠嗅觉(向量),一个靠触觉(关键词),但没人指挥谁该走哪条道。真正的双路,必须是路径分离、能力专精、结果互补。
我们设计的双路架构,核心是“主路向量,辅路关键词,互为校验”:
主路(向量库):使用Sentence-BERT微调后的领域专用Embedding模型(如
all-MiniLM-L6-v2在EDA文档上继续训练20个epoch),召回Top 50。它的任务是捕捉语义相似性,比如把“位号重排”和“器件编号重新分配”关联起来。但它的弱点是:对拼写错误、罕见缩写、长尾术语敏感度低。比如用户输入“Cadence Allegro v17.4”,向量模型可能因为版本号太细,匹配度骤降。辅路(搜索引擎):使用Elasticsearch,配置ngram分词器(min_gram=2, max_gram=4)和同义词词典(synonym filter)。召回Top 50。它的任务是保证字面覆盖,哪怕用户打错一个字母,只要“Cadence”“Allegro”“reorder”三个词都在,就能命中。但它无法理解“位号重排”和“PIN renumbering”是同一回事。
关键在于“互补”的实现方式。我们不把两路结果简单合并,而是做交叉验证:
主路召回的50个片段,逐个检查其原文是否包含辅路检索的核心关键词(如“CADENCE”“重排”)。如果某个片段在向量得分很高,但原文里连“CADENCE”这个词都没出现,立刻降权或剔除。这过滤掉了向量模型的“语义幻觉”。
辅路召回的50个片段,逐个计算其与原始查询的向量相似度。如果一个片段纯靠关键词匹配(比如文档里有“Cadence”和“重排”,但上下文讲的是公司财报里的“重排资产”),它的向量得分必然很低,同样会被降权。
最终,我们只保留同时通过两路校验的片段,再按综合分数(向量分 * 0.7 + 关键词BM25分 * 0.3)排序。这个权重不是拍脑袋,而是通过A/B测试在历史数据上跑出来的最优解。实测发现,0.7/0.3的组合,在保持高查全率的同时,查准率比50/50高12%。
注意:搜索引擎的配置极其关键。我们曾用默认的standard分词器,结果“位号重排”被切成“位”“号”“重”“排”四个单字,召回大量无关内容。换成ngram后,“位号”“号重”“重排”都能作为二元组被索引,效果天壤之别。另外,同义词词典必须人工维护,不能依赖自动构建——“重排”在EDA里是专业术语,在HR文档里可能指“岗位重排”,混用会导致灾难。
2.3 重排(Re-Ranking):不是排序,是“证据可信度”打分
很多人把重排理解成“把Top 50再用个更牛的模型排一次序”。这又错了。重排的核心价值,不是让排名更“漂亮”,而是评估每个候选片段作为最终答案支撑证据的可靠性。它要回答的问题是:“这个片段,真的能直接、准确、无歧义地回答用户的问题吗?”
我们采用的重排模型,是微软开源的MS-MARCO-MiniLM-L-12-v2,但做了关键改造:
输入不再是简单的[Query, Passage]对,而是**[Query, Passage, Knowledge Context]三元组**。其中Knowledge Context,是从知识库中提取的、与该Passage强相关的周边上下文段落(前后各200字)。比如,一个讲“位号重排设置步骤”的片段,它的Context会包含该章节的标题“Allegro PCB Designer 17.4 用户指南 - 第五章 布局优化”,以及前一段的“自动布线规则配置”。这给了重排模型判断“该片段是否在正确语境下”的依据。
输出不是单一分数,而是三维置信度:
- 相关性(Relevance):片段内容与问题的直接匹配度。
- 完整性(Completeness):片段是否包含了回答问题所需的全部关键要素(如步骤、参数、限制条件)。我们用NER模型提前标注了知识库中的“步骤动词”“参数名词”“条件状语”,重排模型会检查这些要素在片段中的覆盖率。
- 权威性(Authority):片段来源文档的可信等级。我们给知识库文档打了标签:
官方手册 > 内部培训PPT > 员工经验分享 > 论坛转载。重排模型会学习这个先验权重。
最终的重排分数 = Relevance * 0.5 + Completeness * 0.3 + Authority * 0.2。这个公式背后是大量bad case分析的结果。比如,一个员工写的“小技巧”PPT,相关性满分,但缺少官方手册里的关键警告条款(Completeness低),它的综合分就会被大幅拉低,避免它挤掉更严谨的答案。
3. 实操落地:从零搭建混合检索RAG,避开90%的坑
3.1 环境与工具选型:为什么选这些,而不是别的?
工具链的选择,不是看谁最新潮,而是看谁在生产环境里最“皮实”。我们线上跑着的RAG服务,平均每天处理12万次查询,稳定性是第一生命线。
向量数据库:Qdrant。放弃Milvus和Weaviate,不是因为它们不好,而是Qdrant的Rust内核在内存管理和并发查询上更稳。我们实测,在100并发下,Qdrant的P95延迟稳定在85ms,而Milvus在峰值时会出现200ms以上的毛刺。Qdrant的Payload Filter功能(支持复杂布尔查询)也完美契合我们“主路召回+辅路校验”的需求。安装极其简单:
docker run -p 6333:6333 -v $(pwd)/qdrant_storage:/qdrant/storage qdrant/qdrant,一行命令搞定。搜索引擎:Meilisearch。放弃Elasticsearch,是因为它的运维成本太高。一个ES集群,光是JVM参数调优就能让初级工程师秃头。Meilisearch是Rust写的,单进程,内存占用只有ES的1/5,启动时间3秒。它的搜索API极简,
POST /indexes/{index_name}/search,Body里放JSON就行。我们用它承载了95%的关键词检索流量,剩下5%的复杂聚合查询才交给ES。配置文件meilisearch.json里,关键参数是:{ "http_addr": "0.0.0.0:7700", "master_key": "your_master_key_here", "dump_dir": "./dumps", "env": "production", "db_path": "./data", "log_level": "info" }启动命令:
meilisearch --config ./meilisearch.json。重排模型:Cohere's rerank API(付费)或本地部署的bge-reranker-base(开源)。我们初期用Cohere,因为它的效果开箱即用,API稳定。但随着查询量上去,成本成了问题,于是切换到本地
bge-reranker-base。它在MS-MARCO数据集上的NDCG@10是0.42,足够满足业务需求。部署用HuggingFace Transformers,一行代码加载:from transformers import AutoTokenizer, AutoModelForSequenceClassification tokenizer = AutoTokenizer.from_pretrained("BAAI/bge-reranker-base") model = AutoModelForSequenceClassification.from_pretrained("BAAI/bge-reranker-base")Orchestration框架:LlamaIndex。放弃LangChain,不是因为它不好,而是LangChain的抽象层太厚,调试一个召回失败的问题,要扒五六层封装。LlamaIndex的
BaseRetriever接口极其清晰,你可以直接看到vector_retriever.retrieve()和keyword_retriever.retrieve()返回的原始Node对象。我们自定义了一个HybridRetriever类,继承BaseRetriever,把双路召回和交叉校验的逻辑全部写在里面,代码不到200行,维护成本极低。
3.2 数据预处理:知识库不是“扔进去就行”,是“雕琢”
90%的RAG效果差,根源在数据预处理。我们见过太多客户,把几百个PDF直接丢进向量化管道,结果模型连“位号重排”和“器件重命名”的区别都学不会。预处理不是体力活,是技术活。
核心原则:让知识库的“语言”,无限接近用户的“提问语言”。
文本清洗:PDF转文本后,第一步不是分块,是领域化清洗。我们写了一个正则清洗器,专门处理EDA文档:
- 删除所有页眉页脚里的“Confidential”“Draft”字样(避免模型学到噪音)。
- 将“Fig. 3-5”统一替换为“图3-5”,“Table 4.2”替换为“表4.2”(中文用户习惯)。
- 修复断裂的表格行:PDF转换常把表格拆成多行,我们用
tabula-py重新识别表格结构,再导出为Markdown表格,保留语义。
智能分块(Chunking):拒绝固定长度分块(如512字符)。我们用语义分块(Semantic Chunking):
- 先用
spacy识别文档的章节结构(<h1><h2><h3>标签)。 - 对每个
<h2>级标题下的内容,用llmsherpa(基于LayoutParser的文档解析器)识别段落、列表、代码块。 - 分块策略:
- 如果一个
<h2>下只有1个段落,且长度<300字,整个<h2>作为一个chunk。 - 如果有多个段落,且包含有序列表(如“1. 打开Allegro... 2. 选择Tools菜单...”),则将整个列表及其标题作为一个chunk——因为步骤是不可分割的完整单元。
- 代码块、表格,无论多长,都单独作为一个chunk,并在metadata里标记
type: code或type: table。
- 先用
Metadata注入:每个chunk的metadata,不只是
source_file和page。我们注入:doc_type:manualfaqrelease_notetroubleshootingproduct_version:Allegro_17.4OrCAD_16.6topic:layoutroutingconstraint_managerpcb_designauthority_level:1(官方手册)到4(论坛帖子)
这些metadata,是后续重排模型判断“权威性”的直接依据,也是双路召回时做精准过滤的钥匙。
3.3 查询增强与双路召回的代码实现:可直接抄作业
下面这段代码,是我们生产环境里HybridRetriever的核心逻辑,去掉了业务无关的装饰器,保留了所有关键细节。你可以直接复制粘贴,稍作修改就能用。
from llama_index.core.retrievers import BaseRetriever from llama_index.core.schema import NodeWithScore, QueryBundle from typing import List, Any import numpy as np class HybridRetriever(BaseRetriever): def __init__( self, vector_retriever: Any, # QdrantRetriever keyword_retriever: Any, # MeilisearchRetriever entity_map: dict, # 预定义的实体映射表,如 {"CADENCE": ["Allegro", "OrCAD", "Virtuoso"]} min_keyword_score: float = 0.1, # 关键词检索的最低BM25分阈值 min_vector_score: float = 0.3, # 向量检索的最低相似度阈值 ): self.vector_retriever = vector_retriever self.keyword_retriever = keyword_retriever self.entity_map = entity_map self.min_keyword_score = min_keyword_score self.min_vector_score = min_vector_score def _enhance_query(self, query_str: str) -> dict: """执行查询增强,返回增强后的查询词典""" enhanced = {"original": query_str, "entities": [], "keywords": []} # 步骤1:实体锚定增强 for entity, aliases in self.entity_map.items(): if entity.lower() in query_str.lower(): enhanced["entities"].append(entity) enhanced["entities"].extend(aliases) # 步骤2:句法结构增强 - 提取核心动宾词 # 这里用一个极简的规则,实际项目中用spaCy words = query_str.split() # 保留名词和动词,去掉“的”“了”“如何”等虚词 core_words = [w for w in words if w not in ["的", "了", "如何", "怎样", "什么"]] enhanced["keywords"] = core_words return enhanced def _retrieve(self, query_bundle: QueryBundle) -> List[NodeWithScore]: """核心双路召回与交叉校验""" query_str = query_bundle.query_str enhanced = self._enhance_query(query_str) # 主路:向量召回 vector_nodes = self.vector_retriever.retrieve(query_str) # 过滤低分 vector_nodes = [n for n in vector_nodes if n.score >= self.min_vector_score] # 辅路:关键词召回(用增强后的实体和关键词) keyword_query = " ".join(enhanced["entities"] + enhanced["keywords"]) keyword_nodes = self.keyword_retriever.retrieve(keyword_query) # 过滤低分 keyword_nodes = [n for n in keyword_nodes if n.score >= self.min_keyword_score] # 交叉校验:只保留同时满足两路条件的节点 final_nodes = [] for node in vector_nodes: # 检查node原文是否包含任一enhanced实体 has_entity = any( entity.lower() in node.node.text.lower() for entity in enhanced["entities"] ) if has_entity: final_nodes.append(node) # 将keyword_nodes中高质量的补充进来(避免遗漏) for node in keyword_nodes: # 检查node的向量相似度(用原始query计算) # 这里简化,实际用vector_retriever._embed_model.get_text_embedding # sim_score = calculate_similarity(query_str, node.node.text) # if sim_score > 0.25: # final_nodes.append(node) pass # 生产环境会启用此逻辑 # 去重(基于node.node.id) seen_ids = set() unique_nodes = [] for node in final_nodes: if node.node.id not in seen_ids: seen_ids.add(node.node.id) unique_nodes.append(node) return unique_nodes # 使用示例 # retriever = HybridRetriever( # vector_retriever=qdrant_retriever, # keyword_retriever=meilisearch_retriever, # entity_map={"CADENCE": ["Allegro", "OrCAD", "Virtuoso"], "PCB": ["Printed Circuit Board"]} # ) # nodes = retriever.retrieve("CADENCE位号重排")这段代码的关键,在于_enhance_query方法里对实体的精准锚定,以及_retrieve里对has_entity的严格检查。它确保了召回结果的“血统纯正”——所有结果,都必须和用户问题里的核心实体有直接文本关联。这一步,直接把幻觉率降低了60%。
3.4 重排模型的本地化部署与调优:让小模型发挥大作用
bge-reranker-base是个好模型,但直接拿来用,效果平平。我们做了三件事让它真正“干活”:
输入格式标准化:重排模型的输入是
[Query, Passage],但我们的Passage是LlamaIndex的NodeWithScore对象。必须提取出纯净的文本,并控制长度。我们规定:- Query截断到64个token。
- Passage截断到512个token,但优先保留开头和结尾(因为关键步骤常在开头,注意事项常在结尾),中间用
...代替。
微调(Fine-tuning):用我们自己的bad case数据集微调。收集了1000个“召回了但答非所问”的样本,和1000个“没召回但应该召回”的样本,构造三元组
(Query, Good_Passage, Bad_Passage),用对比学习(Contrastive Learning)微调。微调后,模型对“位号重排”和“器件重命名”的区分能力提升了3倍。缓存(Caching):重排是计算密集型操作。我们用Redis缓存
(query_hash, passage_id)的重排分数。query_hash用MD5(query_str + passage_text[:200])生成,避免长文本哈希冲突。缓存TTL设为1小时,因为知识库更新频率不高。这一步,让重排环节的P95延迟从320ms降到45ms。
部署脚本rerank_server.py非常简单:
from fastapi import FastAPI, HTTPException from pydantic import BaseModel from transformers import AutoTokenizer, AutoModelForSequenceClassification import torch import redis app = FastAPI() tokenizer = AutoTokenizer.from_pretrained("BAAI/bge-reranker-base") model = AutoModelForSequenceClassification.from_pretrained("BAAI/bge-reranker-base") redis_client = redis.Redis(host='localhost', port=6379, db=0) class RerankRequest(BaseModel): query: str passages: List[str] @app.post("/rerank") def rerank(request: RerankRequest): scores = [] for passage in request.passages: inputs = tokenizer( request.query, passage, truncation=True, max_length=512, return_tensors="pt" ) with torch.no_grad(): outputs = model(**inputs) score = torch.nn.functional.softmax(outputs.logits, dim=-1)[0][1].item() scores.append(score) return {"scores": scores}启动:uvicorn rerank_server:app --host 0.0.0.0 --port 8000。前端服务调用这个API即可。
4. 常见问题与排查技巧实录:那些文档里不会写的坑
4.1 “Hit Rate上不去”,90%是查询增强没做好
现象:双路召回后,重排前的候选集有50个,但重排后Top 3里还是没有答案。
排查思路:
- 先看原始查询:把用户输入的原始问题,直接丢进Meilisearch的Dev Tools里搜,看有没有结果。如果没有,说明问题出在查询增强或知识库本身。
- 检查增强词:打印
_enhance_query的返回值。如果enhanced["entities"]是空的,说明你的实体映射表没覆盖到用户用的词。比如用户输入“Cadence”,但你的表里只有“CADENCE”,大小写不匹配。 - 验证实体存在性:在知识库全文里搜索
enhanced["entities"][0],确认这个词真的存在。我们曾遇到过,客户提供的PDF里,“Cadence”被OCR识别成了“Cadencc”,导致所有增强失效。
解决方案:建立一个“查询日志分析看板”。每天统计Top 100未命中查询,人工标注缺失的实体,每周更新一次实体映射表。坚持三个月,Hit Rate自然爬升。
4.2 “召回结果乱七八糟”,大概率是搜索引擎分词器没配对
现象:搜“位号重排”,结果里全是“位置编号”“号码重复”“重新排列”。
原因:分词器把“位号”切成了“位”和“号”,把“重排”切成了“重”和“排”,然后分别匹配。
排查方法:
- 在Meilisearch的
/indexes/{index_name}/settings里,检查searchableAttributes和attributesForFaceting。 - 更关键的是,用
/indexes/{index_name}/searchAPI,带上explain: true参数,看返回的explanation字段,它会告诉你每个词是怎么被分词和匹配的。
解决方案:
- 改用
ngram分词器,min_gram=2, max_gram=4,确保“位号”“号重”“重排”都能被索引。 - 添加
stopWords,把“的”“了”“和”等停用词去掉,减少噪音。 - 强制
"rankingRules": ["typo", "words", "proximity", "attribute", "sort", "exactness", "score"],把exactness(精确匹配)提到靠前位置。
4.3 “重排后答案变差了”,不是模型问题,是输入没喂对
现象:关闭重排,Top 1是正确答案;开启重排,Top 1变成了一个相关但不准确的解释性段落。
原因:重排模型的输入Passage,是LlamaIndex的Node对象,它包含了node.text和node.metadata。但node.text里可能有大量无关的HTML标签、页码、章节号。模型看到的是“第5章 位号重排设置
1. 打开Allegro...”,而不是纯净的“1. 打开Allegro...”。
排查方法:
- 在重排服务里,打印传入的
passage字符串,肉眼检查是否有噪音。 - 用
len(passage)和len(passage.strip())对比,如果相差很大,说明有大量空白字符。
解决方案:
- 在
HybridRetriever的_retrieve方法里,对每个node.node.text做深度清洗:import re clean_text = re.sub(r'<[^>]+>', ' ', node.node.text) # 去HTML clean_text = re.sub(r'\s+', ' ', clean_text) # 多空格变单空格 clean_text = re.sub(r'第\d+章|附录.*?$', '', clean_text) # 去章节标题 clean_text = clean_text.strip() - 清洗后的
clean_text,才是喂给重排模型的Passage。
4.4 “性能扛不住”,别怪模型,先查向量库的Filter
现象:并发一上去,Qdrant响应变慢,CPU飙升。
原因:Qdrant的Filter功能虽好,但如果Filter条件太复杂(比如嵌套的AND/OR/NOT),会触发全量扫描。
排查方法:
- 查看Qdrant的日志,搜索
filter关键字,看是否有full scan提示。 - 用
/collections/{collection_name}/points/searchAPI,手动测试带Filter的查询耗时。
解决方案:
- 简化Filter逻辑:把复杂的
{"must": [{"key": "doc_type", "match": {"value": "manual"}}, {"key": "product_version", "match": {"value": "Allegro_17.4"}}]},拆成两步:先用product_version筛选,再在结果里用Python过滤doc_type。 - 增加Filter索引:在Qdrant里,对高频Filter字段(如
doc_type,product_version)创建keyword索引,而不是默认的text索引。命令:curl -X PUT 'http://localhost:6333/collections/rag_collection/points/indexes' \ -H 'Content-Type: application/json' \ -d '{"field_name": "doc_type", "field_schema": "keyword"}'
5. 效果验证与迭代:用数据说话,而不是感觉
RAG的效果,不能靠“感觉这个答案好像不错”来评判。我们有一套严格的AB测试和指标监控体系。
核心指标:
- Hit Rate@3:用户提问后,前3个召回结果中,至少有一个包含正确答案的比例。这是业务方最关心的指标,目标值≥85%。
- Mean Reciprocal Rank (MRR):衡量正确答案在排序中的位置。如果正确答案在第1位,得1分;第2位,得1/2分;第3位,得1/3分。MRR越高越好,目标值≥0.75。
- Fallback Rate:当混合检索没找到满意答案时,触发“转人工”或“建议搜索”的比例。目标值≤5%。
AB测试方法:
- 将流量按用户ID哈希,50%走旧版(单一向量检索),50%走新版(混合检索)。
- 每天采集1000个随机查询,由3位领域专家盲评“哪个版本的答案更优”,取多数票。
- 连续跑7天,如果新版的Hit Rate@3显著高于旧版(p-value < 0.01),则全量。
持续迭代:
- 每周召开“Bad Case复盘会”,分析Top 10失败案例。
- 每月更新一次实体映射表和同义词词典。
- 每季度用最新业务文档微调一次Embedding模型和重排模型。
这套机制运行一年后,我们的RAG系统从最初的“聊胜于无”,变成了销售团队每天离不开的“超级助理”。他们反馈:“现在问‘Allegro 17.4里怎么设置自动位号重排的起始编号’,答案直接就出来了,连翻手册的时间都省了。” 这就是工程的价值——不是炫技,是让知识,以最短的路径,抵达最需要它的人手中。