背景

为「作业批改系统」的 OneBot QQ Bot 接入 RAG(检索增强生成),让学生能通过 QQ 直接向知识库文档提问。插件侧配置已开启,但后端 RAG 接口返回异常。

时间线

时间 事件
10:09 发现 RAG 接口返回 {"enhancedMessage":"????","hasContext":false}
11:00 定位根因:SIMILARITY_THRESHOLD = 0.75 对哈希向量过高
11:13 实现 RRF 双路混合检索
11:27 修复 SQL 拼接空格/LIMIT 类型匹配
11:32 修复 LIMIT 参数类型
11:50 Gateway 重启导致 OneBot 断开
14:59 升级到 ONNX 真实嵌入模型 bge-small-zh-v1.5
15:27 模型下载完成,512 维向量就绪
15:31 pgvector 列维度不匹配报错,改表后修复
15:40 检索精度被 C 语言文档淹没,Top-K 从 3 提到 6

问题一:RAG 返回空结果

现象

调用 POST /api/onebot/rag 返回:

1
{"enhancedMessage":"????","hasContext":false}

根因

DocumentServiceImpl.java 中设置了 SIMILARITY_THRESHOLD = 0.75,但 EmbeddingService 使用的是哈希词频稀疏向量:

1
2
3
// 旧 EmbeddingService - 伪向量
int idx = Math.abs(keyword.hashCode()) % 384;
vector[idx] += frequency;

这种向量的余弦相似度通常在 0.05 ~ 0.3 之间,0.75 的阈值导致 100% 结果被过滤。

修复

去掉硬阈值,改用 RRF 双路融合排序(见下文)。

问题二:RRF 混合检索实现

方案

采用业界通用的 Reciprocal Rank Fusion(RRF)双路召回:

1
2
3
4
查询
├── 语义向量路(pgvector cosine distance)
├── 关键词路(ILIKE 匹配计数)
└── RRF 融合 → Top-K

RRF 公式

1
2
RRF_score(doc) = Σ 1 / (k + rank_i)
k = 60(Elasticsearch 默认值)

某 chunk 在语义路排第 1、关键词路排第 5:

1
RRF = 1/(60+1) + 1/(60+5) = 0.0164 + 0.0154 = 0.0318

问题三:SQL 拼接坑

坑 1:Java Text Block 吃掉行尾空格

1
2
3
4
sql.append("""
FROM document_chunk
WHERE """);
// 生成: "WHEREContent" — 粘在一起!

修:Java 15+ 用 \s 显式保留空格:

1
2
3
4
sql.append("""
FROM document_chunk
WHERE\s""");
// 生成: "WHERE content" — 正确

坑 2:PostgreSQL LIMIT 类型

1
2
3
List<String> params = new ArrayList<>();
params.add(String.valueOf(topK));
// Pg: "LIMIT 的参数必需是类型 bigint, 而不是类型 character varying"

修:改成 List<Object>,直接传 int:

1
2
List<Object> params = new ArrayList<>();
params.add(topK); // 原生 int → JDBC 自动映射 bigint

问题四:哈希向量升级到 ONNX 真实嵌入

方案

  • 模型:BAAI/bge-small-zh-v1.5(中文优化,512 维)
  • 引擎:DJL ONNX Runtime(纯 CPU,无需 PyTorch)
  • 下载:hf-mirror.com(国内镜像),~90MB
  • 本地路径:%USERPROFILE%\.djl\models\BAAI_bge-small-zh-v1.5\

BGE Query 指令前缀:

1
2
3
4
5
6
7
8
9
// Query 侧:加指令前缀
public float[] embedQuery(String query) {
return predictor.predict("为这个句子生成表示以用于检索相关文章:" + query);
}

// Document 侧:不加前缀
public float[] embedDocument(String text) {
return predictor.predict(text);
}

这是 BGE 官方推荐的最佳实践,前缀能提升检索精度 5-10%。

问题五:pgvector 维度不匹配

现象

1
expected 384 dimensions, not 512

修复

1
ALTER TABLE document_chunk ALTER COLUMN embedding_vec TYPE vector(512);

注意:旧文档的哈希向量(384 维)与 ONNX 向量(512 维)语义空间完全不同,需删除旧文档并重新上传。

问题六:检索精度被噪音淹没

数据库中有 94 个 C 语言文档 chunk + 6 个仪表盘 chunk,比例 15:1。即使 ONNX 模型精度足够,Top-K=3 时,仪表盘的有效数据被 C 语言 chunk 挤出。

1
2
// OnebotRagController.java
private static final int RAG_TOP_K = 6; // 从 3 提到 6

最终架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
QQ 消息

OneBot 插件(OpenClaw)
↓ POST /api/onebot/rag
RAG Controller
↓ embedQuery()
EmbeddingService (bge-small-zh-v1.5 / ONNX)
↓ 512-dim 语义向量
├── pgvector cosine search
├── ILIKE keyword search
└── RRF 融合 → Top-6 chunks
↓ 组装增强 prompt
OpenClaw Agent (deepseek-v4-flash)
↓ 生成回复
QQ 消息

经验教训

  • Java Text Block 的 \s 陷阱——行尾空格默认被吃掉,SQL 拼接务必用 \s 或改用普通字符串
  • PostgreSQL LIMIT ? 参数必须是 int——String.valueOf() 会导致类型错误
  • BGE 模型 query/document 侧需要不同处理——query 加指令前缀,document 不加
  • 知识库噪音比嵌入精度更致命——94 个不相关 chunk 足以淹没 6 个相关 chunk,管好入库质量比调参更重要
  • Gateway 重启 = OneBot 重连——确保 Napcat 进程在 Gateway 之前启动,或配置自动拉起