Hybrid Search + RRF + Reranker:打造电商 RAG 的精准检索三件套
文章目录前言为什么需要混合检索整体架构三段式流水线Stage 1并行检索1.1 向量搜索 — vectorSearch()1.2 关键词搜索 — keywordSearchWithScore()Stage 2RRFReciprocal Rank Fusion融合Stage 3LLM Reranker设计思路Prompt 设计防抖设计完整流程searchSimilarDocuments()几个关键设计决策Embedding 缓存层总结下阶段优化建议2. Documents API 迁移到 Hybrid Search3. Query Rewriting查询改写4. 多轮对话上下文增强5. 检索结果缓存6. Chunk 上下文窗口Contextual Retrieval7. 元数据过滤增强8. 评估体系搭建优先级总结前言喜欢公众号阅读的玩家https://mp.weixin.qq.com/s/TM0hTLJMcXFY0yWdvYEYYw之前的AI客服系统电商 RAG 这一块1.支持上传相关知识库实现了向量搜索可以语义咨询2.优化了用户query这里使用的nodejieba库去掉无关的噪音询问与提取关键分词如你好啊我需要退货。这里的关键词就是 “退货“。3.添加了知识库文档召回逻辑让回答更加匹配。但是这里还是有一些问题例如有时候用户问苹果15这样比较精准的词走精准匹配搜索比较合适而不是向量搜索。解决这类问题的方案Hybrid Search RRF Reranker。接下来我们具体讨论下。为什么需要混合检索在构建 RAGRetrieval-Augmented Generation系统时检索质量决定了最终回答的上限。单一检索方式各有短板检索方式优势劣势向量搜索语义理解强能匹配同义词、近义表达对精确关键词如产品型号 “SKU-8843”可能漏检关键词搜索精确匹配专有名词、数字、ID无法理解同义词命中不到语义相近但用词不同的文档电商场景尤其典型—— 用户可能问iPhone 15 Pro 多少钱也可能问那款苹果最新手机的价位。前者需要关键词精确匹配型号后者需要语义理解苹果对应iPhone。Hybrid Search混合搜索正是解法同时执行向量和关键词两路检索再通过算法融合排序取长补短。整体架构三段式流水线RRF 融合大白话理解就是它把每个检索器的分数根据公式重新算分。k值一般固定。用户 Query │ ▼ ┌──────────────────────────────────────────────┐ │ Stage1:并行检索 │ │ ┌──────────┐ ┌──────────────────┐ │ │ │ 向量搜索 │ │关键词搜索(BM25)│ │ │ │ pgvector │ │ILIKEjieba │ │ │ │ recall3x│ │ recall3x │ │ │ └────┬─────┘ └────────┬─────────┘ │ └───────┼──────────────────────────┼───────────┘ │ │ ▼ ▼ ┌──────────────────────────────────────────────┐ │ Stage2:RRF融合 │ │1/(krank_vector)1/(krank_keyword)│ │ mergesort │ │ top-10候选 → │ └──────────────────────┬───────────────────────┘ │ ▼ ┌──────────────────────────────────────────────┐ │ Stage3:LLMReranker │ │ qwen3-8b对候选文档打分(0-10)│ │ 按 relevance 精排 │ │ → top-5结果 │ └──────────────────────────────────────────────┘Stage 1并行检索1.1 向量搜索 —vectorSearch()核心思路将 query 转为 1536 维向量使用qwen/qwen3-embedding-8b模型通过 pgvector 的余弦距离操作符在DocumentChunk表中检索召回topK × 3数量的候选多召回方便后续融合过滤similarity minSimilarity (0.35)的低质量结果关键 SQLSELECTdc.id,dc.title,dc.content,...,1-(dc.embedding$queryEmbedding::vector)ASsimilarityFROMDocumentChunkdcJOINDocumentdONd.iddc.documentIdWHEREdc.embeddingISNOTNULLORDERBYdc.embedding$queryEmbedding::vectorLIMIT$recallCount使用余弦距离并用1 - distance转换为相似度 [0, 1]。1.2 关键词搜索 —keywordSearchWithScore()两步走关键词提取 → BM25 风格打分。Step 1jieba TF-IDF 提取关键词constkeywordsjieba.extract(text,5).map(kk.word).filter(word!STOP_WORDS.has(word)!/^\d$/.test(word))使用 nodejieba 的 TF-IDF 算法提取 Top-5 关键词并过滤掉的、了、是等停用词和纯数字。Step 2ILIKE 数据库召回SELECTdc.id,dc.title,dc.content,...FROMDocumentChunkdcJOINDocumentdONd.iddc.documentIdWHEREdc.titleILIKEANY($likePatterns)ORdc.contentILIKEANY($likePatterns)ORDERBYdc.createdAtDESCLIMIT$recallCount每个关键词转为%keyword%通配模式使用 PostgreSQLILIKE ANY()批量匹配不区分大小写。Step 3BM25 风格评分// 归一化到分块长度避免长文档天然高分constnormalizedScorematchCount/Math.sqrt(doc.length)核心思想关键词在title中每命中一次权重×3关键词在content中统计出现次数用Math.sqrt(docLength)做长度归一化最终分数做[0, 1]Min-Max 归一化Stage 2RRFReciprocal Rank Fusion融合两路检索各自返回一个排序列表需要合并为一个统一排序。RRF 是业界验证的简洁有效方案RRF_score(d)Σ1/(krank_i(d))其中rank_i(d)是文档d在第i个检索结果中的排名k是常数本文取 60。为什么k60比较小较小的k让 keyword 结果在融合时权重更高。这对于中文电商场景很有意义——精确的产品型号、规格参数必须优先保证不会在向量搜索中丢失。functioncomputeRRFScores(vectorRanks:RankedItem[],keywordRanks:RankedItem[],):Mapstring,{rrfScore:number;vectorRank:number|null;keywordRank:number|null}{constscoreMapnewMap()// 登记两路结果的排名for(constitemofvectorRanks){scoreMap.set(item.id,{vectorRank:item.rank,keywordRank:null,rrfScore:0})}for(constitemofkeywordRanks){constexistingscoreMap.get(item.id)if(existing){existing.keywordRankitem.rank// 两路都有 overlap}else{scoreMap.set(item.id,{vectorRank:null,keywordRank:item.rank,rrfScore:0})}}// 计算 RRF 得分for(const[,scores]ofscoreMap){letscore0if(scores.vectorRank!null)score1/(RRF_Kscores.vectorRank)if(scores.keywordRank!null)score1/(RRF_Kscores.keywordRank)scores.rrfScorescore}returnscoreMap// 按 rrfScore 降序排列}RRF 的优势不需要对两路分数做归一化向量余弦相似度和关键词 BM25 分数尺度不同仅依赖排名天然抗异常值计算量极小无外部依赖Stage 3LLM RerankerRRF 融合后得到 top-10 候选但它们仍是机械组合。当候选数多于最终需要的数量时比如需要 5 个结果让 LLM 再做一次精排。设计思路exportasyncfunctionrerankResults(query,candidates,options{}){// 候选数 ≤ 目标数 → 跳过直接返回if(candidates.lengthtopK)returncandidates.slice(0,topK)// 构建 prompt让 LLM 打分constpromptbuildPrompt(query,candidates)constresponseawaitopenai.chat.completions.create({model:qwen/qwen3-8b,messages:[{role:user,content:prompt}],temperature:0.1,// 低温度稳定输出max_tokens:500,// 控制成本response_format:{type:json_object},// 要求返回 JSON})// 解析打分结果按 relevance 降序重排constparsedJSON.parse(content)constreorderedcandidates.map((doc,i)({...doc,similarity:relevanceMap.get(i)/10// 归一化})).sort((a,b)b.similarity-a.similarity)returnreordered.slice(0,topK)}Prompt 设计LLM 对每个候选文档从 0 到 10 打分并给出简短理由你是一个文档相关性评估专家。请判断以下文档与用户问题的相关程度。 用户问题: {query} 打分标准 - 0 完全不相关 - 5 部分相关 - 10 高度相关直接回答用户问题 只返回一个 JSON 对象 {scores: [{index: 0, relevance: 8, reason: 简短理由}, ...]}防抖设计整个 reranker 处处有兜底 — 任何环节失败都 fallback 到原始排序JSON 解析失败 → 返回原顺序LLM 返回空 → 返回原顺序API 调用异常 → 返回原顺序Reranker 默认关闭通过RERANKER_ENABLEDtrue环境变量或调用参数显式打开。开启后约增加 200ms 延迟和少量 token 消耗。完整流程searchSimilarDocuments()exportasyncfunctionsearchSimilarDocuments(query,options{}){const{modehybrid,reranker,topK5}options// 1. 并行检索const[vectorResults,keywordResults]awaitPromise.allSettled([vectorSearch(query,{topK,...}),keywordSearchWithScore(query,{topK,...}),])// 2. RRF 融合constrrfScorescomputeRRFScores(vectorRanks,keywordRanks)letfinalmerged.sort(byScore).slice(0,HYBRID_TOP_K)// top-10// 3. LLM Reranker可选if(rerankerfinal.lengthtopK){finalawaitrerankResults(query,final,{topK})}else{finalfinal.slice(0,topK)}returnfinal}几个关键设计决策1.Promise.allSettled而非Promise.all两端检索独立运行任意一端失败不阻塞另一端。比如关键词提取失败jieba 抽取不到有效关键词向量搜索结果仍然可用。2. 3 倍召回乘数constVECTOR_RECALL_MULTIPLIER3constKEYWORD_RECALL_MULTIPLIER3两路各自召回topK × 3条结果给 RRF 融合和 reranker 留足筛选空间。3. 文本降级兜底当两路检索都返回空时例如 jieba 抽取不到有效关键词 向量相似度过低系统会触发原始 ILIKE 降级查询确保不出现零结果。Embedding 缓存层每次调用generateEmbedding()前先查EmbeddingCache表命中则直接返回节省 API 调用和延迟exportasyncfunctiongenerateEmbedding(text:string):Promisenumber[]{consttextHashhashText(text)// MD5 哈希// 查缓存constrowsawaitprisma.$queryRawSELECT embedding::text FROM EmbeddingCache WHERE textHash ${textHash}AND model ${modelToUse}LIMIT 1if(rows.length0)returnparseEmbedding(rows[0].embedding)// 调 API 并写入缓存constembeddingawaitopenai.embeddings.create({...})awaitprisma.$executeRawINSERT INTO EmbeddingCache (...) VALUES (...) ON CONFLICT (textHash, model) DO NOTHINGreturnembedding}总结这套Hybrid Search RRF Reranker三段式架构在电商 RAG 场景下解决了单一检索的痛点阶段职责技术选型向量搜索语义检索pgvector qwen3-embedding-8b关键词搜索精确匹配jieba TF-IDF ILIKE BM25 评分RRF 融合两路结果合并Reciprocal Rank Fusion (k60)LLM Reranker精排qwen3-8b 打分 (0-10)兜底保障关键工程实践并行检索用Promise.allSettled做容错3 倍召回留足候选空间零结果时触发文本降级兜底Reranker 层层 fallback 不死链Embedding 缓存避免重复 API 调用下阶段优化建议2. Documents API 迁移到 Hybrid Search现状[documents/route.ts 的 GET](file:///Users/linruitao/Documents/100-study/200-reactjs/next-mobile/src/app/api/documents/route.ts#L26-L75) 仍使用老旧的单路向量搜索未复用searchSimilarDocuments()。建议将 documents 搜索也走统一的 hybrid search保持检索口径一致。3. Query Rewriting查询改写问题用户输入那个苹果手机多少钱其中那个是口语化指代jieba 可能提取出苹果手机但丢失了指代消解的需求。方案在检索前增加一个轻量级 LLM query rewriting 步骤原始: 那个苹果手机多少钱 改写: iPhone 最新款 价格可以用极低成本模型如qwen/qwen3-8b单次 $0.001做一次小请求显著提升关键词提取和向量检索质量。与 reranker 形成互补rewriter 改善召回reranker 改善排序。4. 多轮对话上下文增强现状[chat/route.ts](file:///Users/linruitao/Documents/100-study/200-reactjs/next-mobile/src/app/api/chat/route.ts#L130-L132) 只用最后一条 user 消息做检索constlastUserMessage[...enhancedMessages].reverse().find((msg)msg.roleuser)问题多轮对话中用户可能说那个怎么退款缺少上文我刚买了一件衣服的上下文。方案将最近 2-3 轮对话拼接为检索 query或对完整对话历史做 LLM 摘要用摘要代替单条消息检索5. 检索结果缓存问题同一 query 反复请求会重复执行完整的三段式流水线embedding 已有缓存但检索本身无缓存。方案对query, topK, category做短期缓存TTL 5-10 分钟可以用 Redis 或内存 LRU。电商场景下用户常问相同问题“退货政策”“运费多少”命中率预计 20-30%。6. Chunk 上下文窗口Contextual Retrieval问题当前分块互相独立检索只返回匹配的那一块丢失了前后文。方案返回匹配块的同时附带前一个和后一个 chunk 作为上下文窗口。成本极低仅多查两条 SQL但显著提升 LLM 对文档的整体理解。7. 元数据过滤增强现状仅支持category过滤。方案扩展为支持多维度过滤tags、dateRange、contentType、自定义 metadata key让检索在缩小范围的同时保持精度。8. 评估体系搭建问题无法量化这套检索效果好不好。方案构建一个小型 benchmark准备 30-50 个 QA pair问题 期望文档 ID计算 Recall5、MRR、NDCG5每次优化后跑一遍用数据而非感觉做决策优先级总结优先级任务预期收益实施成本P0接入 Reranker 到 Chat检索精度立刻提升低改 1 行P0Documents API 迁移 Hybrid统一检索口径低P1Query Rewriting提升口语化查询召回中增加一次 LLM 调用P1多轮上下文增强改善对话连续性中P1Chunk 上下文窗口改善 LLM 理解低P2检索结果缓存降低延迟和成本中P2元数据过滤增强精准过滤中P3评估体系量化优化效果高准备数据