1. 为什么"单路召回"不够用了,以及混合架构到底在混什么
先说一个我上周刚经历的场景:线上有个知识库问答系统,用户问了一句"上次说的那个能自动生成报表的工具,后来怎么部署的?"——这句话里没有产品名、没有版本号、也没有标准术语,全是口语化指代。团队里负责维护的同学当时就愣住了,因为这玩意儿用纯关键词压根召回不到,用向量召回呢,语义是够近了,但"自动生成报表"和"部署"这两个概念被稠密向量揉成了一个语义点,召回结果里排前面的全是"报表工具怎么选""报表生成原理",真正想找的那篇部署文档被挤到了二十名开外。
这就是典型的单路召回瓶颈:稀疏检索懂精确匹配但不懂语义,稠密向量懂语义但容易把关键限定词"揉碎",而图关系能捕捉实体之间的连接,但单独使用又对长尾表达无能为力。混合架构这个词在工程圈喊了三四年,真正落地的难点从来不是"把三路结果合并到一起",而是"合并之后怎么保证延迟还在毫秒级,精度还不掉"。
这篇文章基于我在一个真实检索系统上的改造过程,梳理一套可以抄作业的工程层方案。核心指标先说清楚:改造前单路稠密向量召回,TP99延迟约180ms,召回率(Recall@20)在87%左右;改造后采用稠密向量+稀疏检索+图关系三路混合,TP99压到98ms,Recall@20做到96%,线上A/B实验里用户点击率提升了约9个百分点。
这套架构没有用上亿参数的重排序模型,也没有上GPU推理集群,纯粹靠工程层的设计把三路召回组织起来。适合的场景是:已有一套基础向量检索系统,想在不动模型、不动数据底座的条件下,通过工程架构调整把召回质量再往上顶一截的团队。下文所有方案、参数、踩坑记录都可以直接复现,不涉及商业闭源组件。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 架构全景:三路召回不是"都要有",而是"各干各的活"
2.1 三路各自的定位,以及为什么不能互相替代
先建立一个基本共识:稠密向量、稀疏检索、图关系,这三者在信息检索里对应的能力维度完全正交。
- 稠密向量(Dense Retrieval):解决"语义相近但字面不同"的问题。比如"怎么让电脑自己写周报"和"自动化生成工作汇报",字面没有公共词,但语义上是一回事。模型把文本映射到高维向量空间,空间距离近就代表语义近。工程上常见的有BERT系做embedding,也有更轻量的双塔模型。
- 稀疏检索(Sparse Retrieval):解决"精确匹配和关键词限定"的问题。它没有语义泛化能力,但擅长处理专有名词、型号、代码片段、人名地名这些"必须一个字都不能错"的信息。传统的BM25是代表,工程上也有可学习的稀疏表示,比如SPLADE,但落地最多的还是BM25家族的变体。
- 图关系(Graph Relation):解决"实体之间的关联路径"问题。比如"A文档里提到的方案在B文档里有完整实现"——这种跨文档的引用关系、共现关系、类别归属关系,是向量和关键词都很难直接表达的。图关系依赖一个预先构建的知识图谱或文档关系图,检索时从种子实体出发做多跳扩展。
这三路各有不可替代的能力边界。混合架构的核心思路不是什么"三个都要所以更牛",而是让每一路负责自己最擅长的召回子集,最后在合并阶段互相补漏。
2.2 整体链路:召回、粗排、精排,谁负责什么
工程层的混合架构,一定不是"三路各自召回一堆结果,然后全部堆在一起让下游排序"。那样在数据量大的时候,合并列表动辄几百条,排序成本和下游过滤压力根本扛不住。我最终落地的链路分四段:
code复制查询解析(Query Understanding)
→ 三路并行召回(Dense / Sparse / Graph)
→ 合并与粗排(Merge & Coarse Ranking)
→ 精排与过滤(Fine Ranking & Filter)
每一段都有明确职责。查询解析负责判断"这个query适合走哪几路",不是所有query都值得三路全跑——比如纯关键词查询"MySQL 索引失效",稀疏检索基本够用;而口语化长句就必须稠密向量主抓。召回阶段三路各自返回Top N;合并阶段做去重、加权、混合排序,产出候选集(一般是50~100条);精排阶段用更精确的打分模型或规则对候选集做最终排序,输出Top 20给业务方。
这套链路的关键在"查询路由+加权合并"这两个环节,后面核心实现部分会重点展开。
3. 工程层核心实现:三路召回的具体落地方式
3.1 稠密向量路:模型选型与索引参数
稠密向量路在这套架构里是基础盘,承担语义召回的基本盘。我选用的方案是 bge-large-zh-v1.5(中文场景),embedding维度是1024,如果是英文场景可以用E5或GTR系列。选它的原因不光是效果,更多是它的向量分布相对均匀,不需要做额外的归一化预处理,对后续多路分数融合友好。
索引层面用的是 FAISS的IVF-PQ配置,不是HNSW。原因后面延迟优化部分会细说。核心参数:
nlist = 4096:聚类中心数量,这个值大约是文档量(约600万条)的1/1500左右。nprobe = 32:查询时探测的聚类数,这个值直接影响召回质量和延迟的平衡。dim = 1024:向量维度。- 存储格式是PQ128,压缩后单条向量约128字节,600万条向量总索引约768MB,全部常驻内存,无压力。
查询时只做内积相似度计算,取Top 50作为稠密路召回结果。这里的Top 50不是随便拍的——后面合并阶段需要给其他两路留出融合空间,如果单路只取Top 20,合并后可能某些路贡献的长尾结果根本进不了最终列表。
3.2 稀疏检索路:BM25的工程化改造
稀疏检索使用基于 Elasticsearch 的BM25实现,但做了两个改造:
改造一:字段加权。 原始BM25对所有字段一视同仁,但这在文档检索里明显不合理——标题里出现关键词和正文里出现关键词,重要性差一个量级。我在ES的检索配置里对标题字段加权5倍,对摘要字段加权3倍,正文和代码块保持原始权重。这个权重不是拍脑袋定的,是通过小批量标注数据做的网格搜索,大概试了 (3,2,1) (5,3,1) (7,4,2) 三组,最终 (5,3,1) 在验证集上F1最高。
改造二:融合了精确短语匹配的boost。 用户query里的连续短语如果能在文档中精确配到,这通常比分散的关键词匹配更可靠。ES的match_phrase查询配合boost=3.0,专门把这种高置信度匹配顶上。
改造后BM25单路召回在验证集上Recall@20大约67%,单独看不高,但它和高分匹配的精确性互补性极强——稠密向量路召不回的那些"必须一字不差"的结果,BM25路几乎从不失手。
3.3 图关系路:从文档图谱到多跳扩展
图关系路是整个架构里工程细节最复杂的一环,也是大多数人混合架构落地时"最容易糊弄过去"的一环。
底层的图结构是离线构建的,我用 Neo4j 来存储,节点分两类:文档节点(doc)和实体节点(entity)。实体节点从文档中用规则+NLP抽取,抽取的实体类型包括:产品名、版本号、人名、项目代号、API名称。边的关系分三种:
mentions:文档提到了某实体references:文档A引用了文档B(通过链接、脚注、文档内部跳转识别)similar_to:文档之间的内容相似(用向量相似度阈值0.85以上建边,但只建边不做召回用,作为辅助)
查询时流程是这样的:
- 从query中用同样的实体抽取规则识别实体,比如"上次说的那个能自动生成报表的工具",至少能稳定抽出"自动生成报表"这个特征短语(在不完美但有实用价值的前提下,这步约65%的查询能抽到至少一个实体)。
- 如果抽到实体,把这些实体作为种子节点做一次一跳到两跳的BFS扩展,取所有关联文档。
- 如果没抽到实体(大约35%的查询),走兜底方案:用稠密向量路的Top 5结果作为"伪种子文档",在这5篇文档的基础上沿
references关系扩展一跳。
我测试过,图关系路单独召回的召回率只有41%,听起来很弱,但它召回的那部分结果里有相当一部分是另外两路完全召不回的——合并之后对总召回率贡献了约6~7个百分点。这就是混合架构的价值:不是每路都要强,而是每路的"独特贡献"要有意义。
3.4 查询解析与路由:不是所有query都需要三路全跑
这一节是决定混合架构性价比的核心。
如果每个查询都三路跑,平均延迟会明显变高,因为图关系路的BFS扩展在实体多、边密集的情况下耗时不稳定。我的方案是做一个轻量级的查询意图分类器,只分三类:
- Type A(关键词精确型):查询以专有名词、代码、命令、型号为主,例如"MySQL explain 用法""bge-large-zh 向量维度"。这类查询直接走稀疏检索路,不同时跑向量和图。
- Type B(语义模糊型):口语化描述、指代不清、缺少专有名词,例如"怎么让电脑自动写周报"。这类查询以稠密向量路为主,同时跑图关系路做实体补充。
- Type C(综合型):既含实体又含模糊表述,例如"上次说的自动生成报表工具怎么部署"。这类查询三路全跑。
分类器本身不需要复杂模型,我用的是一个规则+轻量分类模型的组合:先做实体抽取,如果抽到≥2个强实体,判为Type A;否则用一个小型文本分类模型(基于6层Transformer蒸馏,约120M参数)分Type B和Type C。这个分类器本身也要控制延迟,实測在CPU上单条约8ms,可接受。
路由带来的收益非常明显:线上流量里Type A约占30%,这些查询省掉了向量和图两路的耗时;Type B约占35%,省掉了稀疏路的耗时;只有Type C(约35%)走完整三路。整体平均查询量降了约40%。
4. 多路分数合并:从"拍脑袋加权"到"可解释的融合公式"
4.1 分数分布差异问题:为什么不能直接相加
三路召回产生的分数天然不在同一个量纲里。BM25的分数理论上无上界,实际可能从几分到几十分;稠密向量的内积分数范围是[-1, 1](余弦相似度);图关系路的BFS扩展产出的不是"相似度分",而是"路径相关度"——一跳直接引用的文档和两跳间接引用的文档,在意义上就差着量级。
直接相加的结果就是量纲大的那一路完全主导合并排序,混合架构形同虚设。我最早跑的一版就是这个问题:BM25的分数普遍在10~30之间,稠密向量分数在0.3~0.7之间,合并后整个列表基本就是BM25结果的翻版,向量那路的语义召回贡献被完全压制。
4.2 Min-Max归一化 + 路级权重 + 位置加成
我最终的融合公式长这样:
code复制final_score(doc) = w_dense * norm(dense_score)
+ w_sparse * norm(sparse_score)
+ w_graph * norm(graph_score)
+ position_bonus(doc, rank_in_route)
其中norm()是min-max归一化,但最关键的一个细节是:归一化的范围不是全局的,而是在每次查询结果内做的。也就是说,每次查询拿到三路各自的Top N列表后,在列表内部做min-max归一化,而不是用全量索引的统计值。原因很直接:BM25对不同query的分数绝对值差异极大,"MySQL"这种高频词的分数可以冲到50,"稀疏检索工程实践"这种长尾query可能只有8分,用全局min-max会导致长尾query的归一化结果全部挤在一个很窄的区间,区分度全丢。
每次查询内做归一化的代码大概长这样(Python伪码):
python复制import numpy as np
def min_max_norm(scores):
arr = np.array(scores, dtype=np.float32)
min_v, max_v = arr.min(), arr.max()
if max_v == min_v:
return np.ones_like(arr) * 0.5
return (arr - min_v) / (max_v - min_v)
路级权重我最终定的是 w_dense=0.4, w_sparse=0.35, w_graph=0.25。这组权重是在一个500条标注查询的评测集上通过坐标下降法调出来的,初始值从 (0.4, 0.3, 0.3) 开始迭代,每次固定两路调一路。权重本身没有普适性,不同业务需要重新调,但坐标下降调权重的方法可以复用。
position_bonus是一个容易被忽略但实际很有用的项。思路是:某一路召回列表中排第1的文档,和排第30的文档,虽然归一化后分数可能一样,但可信度显然不同——排第1说明这一路对该文档的信心极高。所以我对每路的第1~3名额外加0.1的bonus,第4~10名加0.05。这个bonus在A/B实验里大概贡献了0.5~0.8个百分点的召回率提升。
4.3 去重策略:合并时如何判定"这是同一篇文档"
三路召回经常产出同一篇文档,合并时必须去重。但"同一篇"的判断不是简单的doc_id去重,因为可能存在内容完全相同的不同版本文档(比如一个功能的README和它的英文版)。我的策略是三层判定:
- 如果
doc_id相同,直接合并。 - 如果
doc_id不同,但标题完全相同,且正文长度差异小于20%,视为同一篇。 - 如果标题不同,但正文的MinHash指纹(用8个permutation,阈值0.7)相同,视为同一篇。
去重时分数取各路中的最高分,而不是取平均。原因是:某一路给出高分通常意味着它在它的视角里确实有把握,平均反而会稀释这种把握。去重后合并列表保留Top 80,其中同时被两路以上召回的文档打上multi_route标记,这个标记在精排时会作为加分项。
5. 毫秒级响应:延迟瓶颈的定位与优化过程
5.1 初始延迟排查:是哪一路拖了后腿
改造完成后我第一件事就是压测。压测结果是TP99延迟约290ms,比预期差得远。逐段打点排查后,三段的数据如下:
| 阶段 | P50 | P99 | 备注 |
|---|---|---|---|
| 查询解析与路由 | 8ms | 20ms | 分类器耗时稳定 |
| 稠密向量召回(IVF-PQ, nprobe=32) | 22ms | 85ms | 存疑,P99偏高 |
| 稀疏检索召回 | 15ms | 60ms | 正常 |
| 图关系召回(BFS+Neo4j) | 45ms | 180ms | 严重瓶颈 |
| 合并与粗排 | 3ms | 8ms | 正常 |
| 精排与过滤 | 12ms | 25ms | 正常 |
图关系路是最大瓶颈,P99高达180ms。进一步打点发现耗时主要在两个地方:一是Neo4j查询的网络往返开销,每次BFS扩展要发1~2次Cypher查询,每次查询在Neo4j内部的执行时间其实只有20~30ms,但加上连接建立、结果序列化、网络传输,总耗时被放大到60~90ms;二是当种子实体在图中是"超级节点"(比如"API"这种几乎所有文档都提到的实体,或者公司内部某个高频项目代号)时,BFS扩展出来的邻接文档数量能到上万条,对这些文档做排序再截断Top 50的逻辑耗时激增。
5.2 图关系路重构:从在线BFS到预计算候选集
图关系路我做了两个改动,把P99从180ms降到了55ms:
改动一:BFS结果缓存。 不是简单缓存整个query的最终结果,而是缓存每个种子实体节点的BFS扩展结果。因为实体节点在文档图谱里是相对稳定的,文档不更新,实体的邻接关系就不会变。我在Neo4j前面加了一层Redis缓存,key是entity_bfs:{entity_id}:{hops}==2,value是扩展出的文档id列表和路径分数,TTL设6小时。线上命中率大约75%——实体查询有很高的复用性(比如"API"这个实体被大量不同query提到,BFS结果都一样,没必要每次现算)。
改动二:超级节点截断预计算。 针对"API"这类超级节点,在线BFS必然爆炸。我改成了离线预计算:在构建图谱时,对每个实体节点的邻接文档数做统计,如果超过500篇,就在离线任务里预先算好"该实体最相关的Top 50文档"(相关度通过在图上跑Personalized PageRank,种子是实体本身),结果存Redis。在线查询时如果命中超级节点逻辑,直接读预计算结果,不走BFS。
这两步做完,图关系路的P99从180ms降到55ms,P50从45ms降到25ms。
5.3 密集向量路索引参数再调优:IVF-PQ的nprobe是最大的杠杆
图关系路搞定后,重新压测总延迟TP99还有约140ms,稠密向量路的85ms P99成为了主要短板。IVF-PQ的延迟主要由nprobe决定——查询时探测的聚类中心越多,精度越高,耗时越长。
我把nprobe从32调到16后,P99从85ms降到42ms,但召回率掉了约1.2个百分点,不能接受。于是换了个思路:把IVF-PQ换成HNSW64(M=32, efSearch=128)。
同样是P99约40ms的水平,HNSW的召回率比IVF-PQ高约0.8个百分点——因为HNSW的图索引结构在近距离检索上的精度天然优于IVF的聚类近似。但代价是内存:IVF-PQ的768MB索引换成HNSW后约2.1GB,翻了将近三倍。好在服务器内存充裕,这个代价可接受。
这段调整的结论是:在索引内存允许的前提下,HNSW的延迟-精度曲线优于IVF-PQ;内存受限时,IVF-PQ配合适当的nprobe才是可行解。 不能在网上看到某篇技术文推荐HNSW就无脑切,得先算内存账。
5.4 并行化改造:三路召回并发执行而非串行
这看起来是个蠢问题,但很多团队第一版混合架构都是串行跑的——查询解析完先跑稠密向量,跑完再跑稀疏检索,再跑图关系。我第一版也这样,因为逻辑上"每一步为下一步生成输入",实现简单。
但实际这三路是互不依赖的,完全可以并发。用Python的ThreadPoolExecutor(max_workers=4)就能轻松改造,因为三路的瓶颈都在I/O(ES查询、Redis查询、Neo4j查询、FAISS检索),GIL带来的影响有限。改造后理论延迟应该由最慢的一路决定(约50ms),加上查询解析和合并排序,总延迟理论PVC约70ms,实测TP99约98ms,基本吻合。
注意:并行化改造里有个隐藏坑——线程池的线程数不等于越开越多越好。如果三路检索服务的连接池没有跟着调大,开太多线程反而会因为连接等待而拖慢总延迟。ES的HTTP连接池、Neo4j的连接池都跑满的话,8线程比4线程反而慢15%。实测4线程在这个场景下是最优点。
6. 96%召回率的调优过程:评估集、消融实验与"最后一公里"
6.1 怎么定义召回率:Recall@20 和 Recall@indirect
先明确评估口径,否则调优无从谈起。我们的线上产品逻辑是:用户输入一个query,系统返回20条结果。所以最核心的指标是Recall@20——标注出的"相关文档"里,有多大比例出现在最终Top 20里。
但纯Recall@20有个漏洞:如果相关文档总数为5,模型召回4个,Recall@20=80%,看起来不错;但这4个可能都排在1~3位,也可能挤在18~20位,用户看到的效果天差地别。所以我还加了两个辅助指标:
- MRR(平均倒数排名):第一个相关文档出现的位置的倒数,衡量"要翻几屏才能看到想要的"。
- Recall@indirect:相关文档里通过图关系"间接关联"到的比例。这个指标是混合架构独有的,用来验证图关系路是否真的贡献了"新信息",而不是在重复其他路的结果。
最终线上评测结果:
| 版本 | Recall@20 | MRR | TP99延迟 |
|---|---|---|---|
| 仅稠密向量 | 87.2% | 0.41 | 35ms |
| 稠密+稀疏 | 91.5% | 0.46 | 68ms |
| 三路混合(初版) | 94.3% | 0.52 | 140ms |
| 三路混合(优化后) | 96.1% | 0.55 | 98ms |
6.2 消融实验:每一路的"独立贡献"是多少
为了让"三路混合"不是玄学,我做了一组消融实验,方式是把某一路的召回结果直接丢弃,看指标怎么变:
| 去掉的路 | Recall@20 | MRR | 相对完整方案下降幅度 |
|---|---|---|---|
| 去掉图关系路 | 90.2% | 0.48 | 召回率≈5.9个百分点 |
| 去掉稀疏检索路 | 89.7% | 0.47 | 召回率≈6.4个百分点 |
| 去掉稠密向量路 | 82.4% | 0.39 | 召回率≈13.7个百分点 |
| 完整方案 | 96.1% | 0.55 | — |
数据很清楚:稠密向量路贡献最大,这符合预期,因为它是泛化能力最强的一路。但稀疏和图关系两路各自贡献了约6个百分点的"独特召回"——而且这两路的贡献大部分不重叠。换句话说,混合架构的价值不是简单的1+1+1=3,而是每一路都在覆盖其他路够不到的死角。
6.3 失败案例驱动的迭代:把"漏掉的文档"拉回来
调优最有效的方式不是盯着整体指标空想,而是逐个看失败的case,找共性。我记得一个典型case:query是"如何给订单表加索引",标注的相关文档里有篇文章标题叫"千万级订单表的慢查询优化实录"——这标题里既没有"加索引"也没有"订单",所以BM25召不回;语义上和"加索引"确实关联,但稠密向量模型在"订单表加索引"这种组合概念上泛化不够好,向量相似度只排到第47名;图关系路呢,这篇文章确实引用了某篇"订单表设计最佳实践",但因为query里的实体抽取只抽出了"订单表",没有抽到"加索引"——所以图扩展了"订单表"的相关文档,但没覆盖到"慢查询优化"这条线。
针对这类case,我做了三处调整:
- 实体抽取阶段增加关键动词短语的识别,比如"加索引""优化""部署"这类行为短语也要抽出来作为准实体。这直接提升了图关系路的种子质量。
- 减少图关系路BFS中"去重"的激进程度——之前如果文档D跟种子实体的关联路径不够"强"(比如只通过第三跳间接关联),会被直接剪掉。我放宽了第二跳的限制,前提是第二跳文档与第一跳文档之间有
references强关系。 - 稠密向量路的召回Top数从50改成80,给合并阶段更多"备选空间"。代价是合并阶段排序时间从3ms涨到5ms,可接受。
这些调整每次做一点,在500条标注集上迭代测试,最终从94.3%爬到96.1%。说实话,96%之后再往上每0.1个百分点的代价都会急剧上升,到96.5%左右我停下来了,因为边际收益已经低于标注成本。
7. 踩坑实录:工程层混合架构最容易翻车的6个细节
7.1 线上环境的score分布和离线评测集不一致
我用离线500条标注query调出来的权重 (0.4, 0.35, 0.25) 一上线就露馅了:线上实际流量里Type B(口语化模糊查询)的比例比评测集高得多,导致稠密向量路的权重相对不足,线上召回率大约只有离线的八成水平。解决办法是按线上流量采样重新构建评测集,别用自造的测试集。这是混合架构落地最容易被低估的坑。
7.2 三路并发导致的下游服务压力陡增
前面提到并行化改造让延迟从140ms降到98ms,但代价是ES、Neo4j、Redis的QPS同步翻了三倍。原先ES集群每秒3000次查询,改造后飙到9000次,触发了ES的熔断保护。解决办法是给每路检索加独立限流:稠密向量路限流1500 QPS、稀疏检索路限流2000 QPS、图关系路限流800 QPS,超过的部分直接丢弃该路结果,而不是阻塞等待。丢弃一路结果在召回率上损失极小(毕竟每路独特贡献也就6个百分点),但能保证延迟不劣化。
7.3 图关系BFS的递归深度爆炸
Neo4j的Cypher查询在深度为3的BFS时,如果遇到强连通子图,返回的路径数量可以到百万级别。我在测试环境跑出一个让Neo4j CPU打满的查询——match (a)-[*1..3]-(b) where a.id='some_id' return distinct b——结果OOM了。约束是必须在Cypher里加limit,并且不能用递归变长路径,要改成受限步数的一跳+二跳分开查,每跳单独limit 100。虽然多写两个查询,但执行计划稳定得多。
7.4 去重算法的MinHash阈值太敏感
MinHash指纹阈值设0.8时,去重太激进,把两篇内容相似但实际是不同的文档合并了,导致召回率掉1个点;设0.6时,去重又太弱,大量重复内容占着候选集名额。0.7最终验证在业务场景下最优。这个参数没有普适值,要基于自己数据集的重复比例去调。
7.5 粗排阶段的"多路标记加分"导致排序震荡
我给同时被两路以上召回的文档加multi_route加分项,本来是想模拟"多路共识更可信"的先验,但加了之后在部分query上出现了排序震荡——因为某篇文档偶尔因另一个路的一个极低的分数进入了多路标记,挤掉了更合理的排序。后来我把多路标记从实时计算改成离线预计算:文档之间的多路共识关系在离线任务里先建好,上线时直接查表,避免了在线打分的不稳定性。
7.6 权重调优时,评测集大小勉强够用
坐标下降调权重在500条评测集上容易过拟合。做了一次置信度检验:把500条随机切成两份250条,分别调权重,两组的权重差在±0.03以内才算"值得信任"。实际跑下来w_dense在两份样本上分别是0.41和0.39,说明0.4这个值置信度还可以;但w_graph分别是0.22和0.28,波动偏大,原因是评测集里图关系能贡献独特召回的样本太少。后来我把评测集扩充到1200条,重点补充了含实体引用的长尾query,w_graph的估计才稳定下来。
8. 最终工程参数清单(可直接抄作业)
最后把整套方案的核心参数做一个汇总,方便对接落地。这些值都基于约600万文档、线上日均查询量约200万次的场景,仅供参考,实际需要按自己的数据量重调。
| 模块 | 参数/配置 | 值 | 备注 |
|---|---|---|---|
| 稠密向量模型 | 模型 | bge-large-zh-v1.5 | 中文场景;英文可换E5/GTR |
| 稠密向量索引 | 索引类型 | HNSW64 (M=32, efSearch=128) | 内存够用时的最优解 |
| 稠密向量召回 | 每路Top K | 80 | 给合并阶段留空间 |
| 稀疏检索 | 引擎 | Elasticsearch 8.x | BM25 + match_phrase boost |
| 稀疏检索 | 字段权重 | title=5, abstract=3, body=1 | 按业务字段重要性调整 |
| 稀疏检索 | 每路Top K | 50 | — |
| 图谱存储 | 数据库 | Neo4j 5.x | 社区版可用 |
| 图关系召回 | 扩展跳数 | 1~2跳(分开查询) | 禁止变长路径一次查 |
| 图关系召回 | 每跳Limit | 100 | 防止爆炸 |
| 图关系缓存 | 缓存介质 | Redis (TTL=6h) | 键为实体ID+跳数 |
| 查询路由 | 分类 | 规则+120M蒸馏分类器 | Type A/B/C三分类 |
| 分数融合 | 归一化 | 查询内Min-Max | 不能全局归一化 |
| 分数融合 | 权重 | dense=0.4, sparse=0.35, graph=0.25 | 按评测集反复迭代 |
| 分数融合 | 位置加成 | Top1-3: +0.1, Top4-10: +0.05 | 小幅影响排序 |
| 去重 | MinHash阈值 | 0.7 | 内容级去重 |
| 并发 | 线程数 | 4 | 受下游连接池限制 |
| 精排 | 策略 | 规则+梯度提升树(可选) | 本文主链路为规则精排 |
整套架构的代码量估算:查询路由+分配逻辑约500行Python,检索服务(三路封装)约1000行,合并排序约300行,离线图谱构建约2000行。部署不需要GPU,CPU服务器即可,内存大约10GB(HNSW索引2.1GB,Neo4j占4GB,Redis缓存占2GB,ES自有集群不算在内)。
我自己的体会是,这套架构最大的收益不是那个96%的数字,而是给后续优化留出了清晰的抓手:模型升级可以单独替换稠密向量路,不需要动其他链路;图谱更新可以离线做,不影响在线延迟;每一路的单独指标(每路召回Top K的质量)都可以独立监控和优化。如果后续要把召回率往97%、98%推进,方向大概在三个地方:一是把精排从"规则+轻量模型"换成更重的交叉编码器,在候选集上做更精细的排序;二是给图关系路增加更丰富的边类型,比如"用户行为共现边";三是针对Type B查询引入query改写模型,提升口语化表达在稀疏检索路的表现。这些就留给下一个迭代去做了。
