背景 为「作业批改系统」的 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 }
排查
插件代码完整,RAG 调用链路正常
后端接口可通,但 hasContext 始终为 false
数据库中确有文档数据
根因 DocumentServiceImpl.java 中设置了 SIMILARITY_THRESHOLD = 0.75,但 EmbeddingService 使用的是哈希词频稀疏向量 :
1 2 3 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); 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) { float [] queryEmbedding = embeddingService.embedQuery(query); List<DocumentChunk> vectorResults = vectorStoreService.similaritySearch(...); Map<String, Double> semanticRanks = rankByCosine(queryEmbedding, vectorResults); List<VectorStoreService.ScoredChunk> keywordResults = vectorStoreService.keywordSearch(...); Map<String, Double> keywordRanks = rankKeyword(keywordResults); Set<String> allChunkIds = new LinkedHashSet <>(); }
问题三:SQL 拼接坑 坑 1:Java Text Block 吃掉行尾空格 1 2 3 4 sql.append(""" FROM document_chunk WHERE """ );
修: Java 15+ 用 \s 显式保留空格:
1 2 3 4 sql.append(""" FROM document_chunk WHERE\s""" );
坑 2:PostgreSQL LIMIT 类型 1 2 3 List<String> params = new ArrayList <>(); params.add(String.valueOf(topK));
修: 改成 List<Object>,直接传 int:
1 2 List<Object> params = new ArrayList <>(); params.add(topK);
问题四:哈希向量升级到 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 public float [] embedQuery(String query) { return predictor.predict("为这个句子生成表示以用于检索相关文章:" + query); } 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 ); 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 $mirror = "https://hf-mirror.com" $base = "$mirror /Xenova/bge-small-zh-v1.5/resolve/main" 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 private static final int RAG_TOP_K = 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
经验教训
Java Text Block 的 \s 陷阱 ——行尾空格默认被吃掉,SQL 拼接务必用 \s 或改用普通字符串
PostgreSQL LIMIT ? 参数必须是 int ——String.valueOf() 会导致类型错误
BGE 模型 query/document 侧需要不同处理 ——query 加指令前缀,document 不加
知识库噪音比嵌入精度更致命 ——94 个不相关 chunk 足以淹没 6 个相关 chunk,管好入库质量比调参更重要
Gateway 重启 = OneBot 重连 ——确保 Napcat 进程在 Gateway 之前启动,或配置自动拉起