hybrid 和图检索按模式各跑一次:RRF 的 60 从哪来,中文分词缺在哪,图侧离 GraphRAG 还差几步。
检索接口先留一个 mode。同一个问题可以跑 hybrid,也可以跑 graphrag,生成节点接到的统一叫 recall_bundle。我想先在生成前停一下,把返回材料读明白,再看模型怎么用它。
一次只走一条路
hybrid 用 Qdrant 和 BM25 查同一批 Markdown 片段;图侧用 Neo4j 的实体与关系。一次请求选一种模式,不把两路都查完再融合。切换之后只看一条路径的返回材料,每次演示也不用等两套检索都跑完。
hybrid 按名次合并,不加原始分数
九月整理现存的 backend/adapters/retrieve.py 时,hybrid 的合并方式可以具体算出来:两路各自排序,再按名次加分,代码里的贡献是 1 / (60 + rank)。同一个 chunk 在两边出现,贡献相加;只出现在一路,也保留那一路的分数。
说明例:chunk AQdrant 排第 1:1 / 61BM25 排第 3: 1 / 63合并分约为 0.03227它没有把向量相似度和 BM25 原分数直接相加。两路分数来自不同算法,不能当成等价的相关性。名次是两路共有的尺度,可以放在一起算:排在任一路前列的片段都能拿到一份贡献,常数 60 让相邻名次的差距变得很小。
这套算法有名字,叫倒数排名融合(RRF),出自 Cormack 等人在 SIGIR 2009 的一篇短文(DOI 10.1145/1571941.1572114)。60 也有出处:Elasticsearch 的 RRF 检索器公式就是 1.0 / (k + rank),rank_constant 默认 60,k 越大,排得靠后的文档影响越大(Elasticsearch RRF 文档)。Qdrant 文档把不能直接加分的原因说得更具体:余弦分数有界,BM25 分数无界,两者的尺度还会随查询漂移,RRF 用名次绕开了这件事(Qdrant 混合查询)。
这解释了为什么两边都找到的片段会往前走,但前提是两路的 id 对得上。id 不一样时,先核对对应关系,才能判断两份贡献会不会落在同一段材料上。
还有一处粗糙的地方:BM25 的分词现在按非空字符拆分,中文分词还没接上。看到“关键词检索”几个字,切出来的并不是词。
这个坑不算 MediAC 独有。rank_bm25 文档写明它不做任何文本预处理,分词完全交给调用方,查询也要用同一套切法(rank_bm25 README);Qdrant 全文索引默认的 word 分词器按空格和标点切,同样对付不了中文,要换成 multilingual(Qdrant 全文索引)。要补上,有两条成熟路线:
- bigram:Lucene 的 CJK 分析器把相邻两个汉字切成重叠词元,“我是中国人”切成“我是、是中、中国、国人”,不用词典(Lucene CJK 包说明)。
- 词典分词:jieba 用前缀词典建词图、动态规划找最大概率切法,词典外的新词再交给 HMM(jieba README)。
医学资料里术语多,我倾向先上 bigram:改动小,不会因为词典缺一个药名就整个切错,代价是索引变大、会命中一些无意义的两字组合。
图侧的 graphrag 也很朴素
当前实现先找问题里包含名字的实体,再取它相邻的一条关系;找不到时,按名称和描述里的关键词匹配。返回的实体名、关系和说明被拼成文本。
这是项目里名为 graphrag 的检索路线,还没有实现任意多跳路径规划,也没有复现微软 GraphRAG 的社区摘要流程。
差多远,可以对着官方流程数。GraphRAG 建索引要先从原文抽实体、关系和 claims,再对实体做社区发现,然后按多个粒度生成社区摘要和报告(GraphRAG Indexing)。这里只有第一步的结果,而且是现成导进来的。查询侧,官方 local search 和这里起点相同,都从问题相关的实体进去,但会一起带上相连实体、关系、实体属性、社区报告和原文片段,再按优先级裁进一个固定大小的上下文窗口(GraphRAG Local Search)。
社区摘要不做,代价暂时有限。原论文的动机是常规 RAG 答不好针对整个语料的全局问题(arXiv:2404.16130),而问诊 demo 的问题多半围着某个症状或实体转。多跳在官方那边也不是图上一次游走:DRIFT search 先给一个宽泛的初步答案和几个后续问题,再用 local search 一轮轮细化(GraphRAG DRIFT Search)。
生成前,先看统一字段
上层收到的片段有 id、text、score、source、mode、metadata,库各自的原始返回留在 adapter 内部。生成前看这几个字段,就知道正文是否完整、来源从哪来。
某个关系根本没导进图里,切换接口不会替它补上;同一个问题两边表现不同,也可能是资料覆盖不同。
我暂时保留两条独立入口。以后要融合,得先决定重复片段怎么合、失败的一路怎么处理,再评估多查一套库值不值。
融合的写法本身已经现成。Qdrant 的 Query API 用 prefetch 起多路子查询,外层用 rrf 或 dbsf 合并;DBSF 保留原分数,但每次查询都要按返回结果的均值和标准差先归一化(Qdrant 混合查询)。RRF 也未必是最好的选择。Bruch 等人比较过,词法分和语义分的凸组合在域内、域外都优于 RRF,RRF 反而对自己的参数敏感,而凸组合只要一小批样本就能调好唯一的权重(arXiv:2210.11934)。
所以真到要融合那天,我会先攒一小批带标注的问题,再在 RRF 和凸组合之间选,而不是默认沿用 60。至于图这一路,钱主要花在建索引:微软称 LazyGraphRAG 的建索引成本和向量 RAG 相同,只有完整 GraphRAG 的 0.1%(微软研究院博客)。值不值,得先算入库这笔账。
留下几句话 读者来信
读到这里,有什么想法?欢迎留下你的看法。