联合搜索:向量 + 关键词 + 知识图谱怎么并排跑

一、三种通道,三种盲区

给湖北理工计算机学院做官网助手,上了三套检索。很快发现各管各的,谁也覆盖不全。

学生问:"王教授带的研究生发了哪些深度学习论文?"

向量检索:搜到"深度学习课程大纲""王教授个人简介"——语义相关,但不精准
BM25:    搜到"深度学习""论文"精确命中——但不知道跟王教授什么关系
图谱检索:沿王教授→指导→研究生→发表→论文路径,捞出完整链——但可能漏掉没入图的论文

任何一个单独用都丢信息。方案:三个并排跑,结果合并去重,再交给 Rerank 统一排一次。

二、三种通道各负责什么

查询
  │
  ├── 通道1: 向量检索
  │    强项: 语义模糊的查询
  │    例: "有哪些偏理论性的课程"
  │    输出: top-20
  │
  ├── 通道2: BM25 关键词检索
  │    强项: 精确术语、编号、人名
  │    例: "CS201是什么课"
  │    输出: top-20
  │
  └── 通道3: 知识图谱
       强项: 实体关系查询
       例: "王教授带哪些课"
       输出: top-20
        │
        └── [合并去重] → ~40条
              │
              └── [Rerank重排] → top-5

三、为什么三通道中 BM25 最容易被忽视

向量检索有时候太聪明了,聪明到绕路。

学生搜"数据结构",向量可能返回"算法设计与分析""高级数据结构"——语义对,但不是学生要的那门课。BM25 直接按词匹配,精确命中"数据结构"这门课名。

向量在"长尾语义"上无敌,BM25 在"精确术语"上无敌。谁也别代替谁。

BM25 配置思路(伪代码):
  索引:
    字段: 标题(权重×3), 正文(权重×1)
    中文分词: 用你的分词器
    精确匹配: 标题字段额外开一个不拆词的子字段

  查询:
    if 查询看起来像个精确术语(短、有数字、含编号):
      优先匹配精确子字段
    else:
      常规 match + phrase

不用纠结具体 ES API——所有全文检索引擎都支持这个思路。

四、三通道并行跑

三个通道之间没有依赖,并行执行,总延迟 = 最慢那个通道的时长。

function 并行检索(查询):
  并行执行:
    future1 = 线程池.submit(向量检索, 查询, top_k=20)
    future2 = 线程池.submit(BM25检索,  查询, top_k=20)
    future3 = 线程池.submit(图谱检索,  查询, top_k=20)

  等待全部完成
  return {
    向量结果: future1.result(),
    BM25结果: future2.result(),
    图谱结果: future3.result(),
  }

三个通道之间没有依赖,就是三个独立检索,扔进线程池同时跑就行。

五、合并去重

三个通道可能返回同一条文档——同一段话可能同时被向量和 BM25 命中。需要去重。

function 去重(三个通道的结果):
  已见指纹 = {}
  合并列表 = []

  for each 结果 in 所有结果:
    指纹 = MD5(结果.内容.前200字)  // 前200字相同 ≈ 同一条
    if 指纹 not in 已见指纹:
      已见指纹.add(指纹)
      合并列表.append(结果)

  return 合并列表  // 大约30~50条

六、Rerank 收尾

合并后还有 30-50 条候选。全塞给 LLM token 太多,而且顺序不对——向量给的 0.85 分和 BM25 给的 0.85 分不可比。

Rerank 用 Cross-Encoder 把 query 和每条 doc 做一次真正的"对比打分"——不是看 embedding 距离,是看 query 和 doc 拼在一起后模型的判断。

function Rerank(查询, 候选列表, 最终保留数=5):
  送Rerank模型(query=查询, documents=候选列表.内容)
  模型返回每条候选的重排分数
  按分数从高到低取前N条
  return top-N

Rerank 延迟约 200ms,跟 LLM 生成的 1.5 秒比起来可以忽略。

七、容错——某个通道挂了不炸全局

生产环境不是怕某个通道效果差,是怕它挂了把整个检索拖死。

function 安全检索(通道名, 检索函数, 超时秒数=5):
  try:
    return 线程池.submit(检索函数).result(timeout=超时秒数)
  catch 超时 or 异常:
    记录日志("{通道名}挂了,返回空")
    return []   // 安静降级,不抛异常

图谱挂了 → 向量和 BM25 继续跑 → 效果略降但服务不中断。

八、什么时候该开哪个通道

不是所有查询都需要三通道齐开。判断逻辑:

function 选通道(查询):
  通道 = {"向量"}  // 向量是标配,始终开

  if 查询有精确术语(短、编号、专有名词):
    通道.add("BM25")

  if 查询有实体关系(人名+动作、先修课、谁教、属于):
    通道.add("图谱")

  return 只跑通道里选中的那些

消融实验数据(在学院官网 100 条查询上):

通道组合 语义型查询(40条) 关键词型(25条) 关系型(35条)
纯向量 0.78 0.52 0.33
向量+BM25 0.78 0.88 0.33
向量+BM25+图谱 0.78 0.88 0.86
+Rerank 0.83 0.92 0.92

一眼结论:BM25 救命于关键词,图谱救命于关系,Rerank 每类都提 3-5 分。

九、成本

检索环节        每查一次
─────────────────────────
向量检索          免费(本地跑)
BM25检索          免费(本地跑)
图谱查询          免费(本地跑)
Rerank API       ¥0.002
LLM 生成回答      ¥0.01
─────────────────────────
合计             ¥0.012

检索环节除了 Rerank 调一次 API,全是本地执行。

十、总结

联合检索的思路就三步:

  1. 并行跑:三种通道各搜各的,谁不依赖谁
  2. 合并去重:前 200 字指纹去重,不丢信息
  3. Rerank 收尾:Cross-Encoder 统一打分,取 top-5

不是为了炫技——是被真实查询逼出来的。学生问"做 NLP 的老师有哪些",纯向量返回的全是"NLP 课程介绍",没有一个教师名字。向量+图谱并排跑,图谱捞出教师列表,向量补课程细节,Rerank 排一出最终答案。从 0.33 到 0.92,这就是并排跑的价值。