背景

为「作业批改系统」的 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}

排查

  1. 插件代码完整,RAG 调用链路正常
  2. 后端接口可通,但 hasContext 始终为 false
  3. 数据库中确有文档数据

根因

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

代码

VectorStoreService 新增关键词检索:

1
2
3
4
5
6
7
8
9
10
public List<ScoredChunk> keywordSearch(String query, int topK) {
Set<String> keywords = extractKeywords(query);
// 动态构建 SQL:ILIKE 匹配计数
StringBuilder sql = new StringBuilder("""
SELECT ..., (CASE WHEN content ILIKE ? THEN 1 ELSE 0 END + ...) AS keyword_score
FROM document_chunk WHERE ...
ORDER BY keyword_score DESC LIMIT ?
""");
// ...
}

DocumentServiceImpl RRF 融合:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
public List<String> searchRelevantContent(String query, int topK) {
// 1. 语义向量检索
float[] queryEmbedding = embeddingService.embedQuery(query);
List<DocumentChunk> vectorResults = vectorStoreService.similaritySearch(...);
Map<String, Double> semanticRanks = rankByCosine(queryEmbedding, vectorResults);

// 2. 关键词检索
List<VectorStoreService.ScoredChunk> keywordResults = vectorStoreService.keywordSearch(...);
Map<String, Double> keywordRanks = rankKeyword(keywordResults);

// 3. RRF 融合
Set<String> allChunkIds = new LinkedHashSet<>();
// ... 按 RRF 得分排序取 Top-K
}

问题三: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\

关键代码

EmbeddingService:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@PostConstruct
public void init() {
Path modelDir = Path.of(System.getProperty("user.home"),
".djl", "models", "BAAI_bge-small-zh-v1.5");

// 优先本地加载
if (Files.exists(modelDir.resolve("model.onnx"))) {
loadFromLocal(modelDir);
return;
}
// 自动下载(具备断网回退哈希模式)
downloadModel("https://hf-mirror.com", modelDir);
loadFromLocal(modelDir);
}

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%。

OnnxEmbeddingTranslator:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
public NDList processInput(TranslatorContext ctx, String input) {
Encoding encoding = tokenizer.encode(input);
NDArray ids = manager.create(encoding.getIds()).reshape(1, seqLen);
NDArray mask = manager.create(encoding.getAttentionMask()).reshape(1, seqLen);
return new NDList(ids, mask);
}

@Override
public float[] processOutput(TranslatorContext ctx, NDList list) {
NDArray hidden = list.get(0);
// Mean pooling with attention mask
NDArray pooled = hidden.mul(maskExpanded).sum(axis).div(maskSum);
return l2Normalize(pooled.toFloatArray());
}

pengine 依赖

1
2
3
4
5
<dependency>
<groupId>ai.djl.onnxruntime</groupId>
<artifactId>onnxruntime-engine</artifactId>
<version>0.28.0</version>
</dependency>

下载脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$dir = "$env:USERPROFILE\.djl\models\BAAI_bge-small-zh-v1.5"
mkdir -Force $dir

# 用 Xenova 转换版(含 onnx/ 目录)
$mirror = "https://hf-mirror.com"
$base = "$mirror/Xenova/bge-small-zh-v1.5/resolve/main"

# 下载 onnx 目录下的模型
Invoke-WebRequest -Uri "$base/onnx/model.onnx" -OutFile "$dir\model.onnx"
Invoke-WebRequest -Uri "$base/onnx/model_int8.onnx" -OutFile "$dir\model_int8.onnx"

# 下载分词器与配置
Invoke-WebRequest -Uri "$base/tokenizer.json" -OutFile "$dir\tokenizer.json"
Invoke-WebRequest -Uri "$base/tokenizer_config.json" -OutFile "$dir\tokenizer_config.json"
Invoke-WebRequest -Uri "$base/special_tokens_map.json" -OutFile "$dir\special_tokens_map.json"
Invoke-WebRequest -Uri "$base/config.json" -OutFile "$dir\config.json"

问题五:pgvector 维度不匹配

现象

1
expected 384 dimensions, not 512

修复

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

注意

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


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

现象

LLM 回复:”文档只有标题和导出时间,但实际内容都是 C 语言知识点整理”

排查

数据库中有 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

建议

生产环境删除调试用的 C 语言文档,让业务数据独占知识库。


最终架构

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 消息

核心文件变更清单

文件 改动
pom.xml 新增 onnxruntime-engine 依赖
EmbeddingService.java 重写:ONNX 模型加载 + BGE 指令前缀 + 哈希回退
OnnxEmbeddingTranslator.java 新增:tokenizer → ONNX 推理 → mean pooling
VectorStoreService.java 新增 keywordSearch()、暴露 ScoredChunk
DocumentServiceImpl.java 重写 searchRelevantContent():RRF 双路融合
OnebotRagController.java RAG_TOP_K 3 → 6

经验教训

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