diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java index d7baf7d7..ed0b42c4 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java @@ -720,7 +720,17 @@ public class AihrSopSeedService { } } + private static final String OCR_PROMPT = + "请识别图片中的所有文字内容,保持原有格式和段落结构,直接输出识别到的文字,不要添加任何解释说明。如果图片不含可识别文字,返回空字符串。"; + /** 视频关键帧:文字 + 一句画面描述,让纯视觉信息(动作/场景演示)也能进入文本向量检索。 */ + private static final String FRAME_PROMPT = + "这是一段物业培训/工作视频的画面截图。请输出:1)画面中的可见文字(保持原文);2)另起一行,以「(画面:…)」格式用一句话描述画面正在发生的内容(场景、人物动作、演示要点)。不要添加其他解释。"; + private String callVisionOcr(ChatRuntime runtime, byte[] imageBytes, String mimeType) throws Exception { + return callVisionOcr(runtime, imageBytes, mimeType, OCR_PROMPT); + } + + private String callVisionOcr(ChatRuntime runtime, byte[] imageBytes, String mimeType, String prompt) throws Exception { String dataUrl = "data:" + mimeType + ";base64," + Base64.getEncoder().encodeToString(imageBytes); ObjectNode body = objectMapper.createObjectNode(); @@ -735,7 +745,7 @@ public class AihrSopSeedService { ObjectNode textPart = content.addObject(); textPart.put("type", "text"); - textPart.put("text", "请识别图片中的所有文字内容,保持原有格式和段落结构,直接输出识别到的文字,不要添加任何解释说明。如果图片不含可识别文字,返回空字符串。"); + textPart.put("text", prompt); ObjectNode imagePart = content.addObject(); imagePart.put("type", "image_url"); @@ -1466,12 +1476,99 @@ public class AihrSopSeedService { vector[index] += (hash & 1) == 0 ? 1 : -1; } + private Optional rerankRuntime() { + try { + List rows = jdbcTemplate.query(""" + select c.model_name, + coalesce(nullif(c.api_host, ''), nullif(p.api_host, '')) as resolved_api_host, + coalesce(nullif(c.api_key, ''), nullif(p.api_key, '')) as resolved_api_key + from aihr_model_config c + left join aihr_model_provider p + on p.tenant_id = c.tenant_id and p.provider_code = c.provider_code + where c.tenant_id = ? + and c.category = 'rerank' + and c.enabled = 1 + and (p.status is null or p.status = '0') + order by case when c.model_show = 'Y' then 0 else 1 end, c.id asc + limit 1 + """, (rs, rowNum) -> new ChatRuntime( + rs.getString("model_name"), + rs.getString("resolved_api_host"), + rs.getString("resolved_api_key") + ), TENANT_ID); + return rows.stream() + .filter(runtime -> !isBlank(runtime.modelName()) && !isBlank(runtime.baseUrl()) && !isBlank(runtime.apiKey())) + .findFirst(); + } catch (DataAccessException e) { + return Optional.empty(); + } + } + + /** + * RRF 融合后的候选交给 rerank 模型按语义相关性重排(TechSpec 12.3)。 + * 未配置 rerank 模型或调用失败时保持 RRF 顺序,检索不受影响。 + */ + private List rerankHits(String queryText, List hits) { + if (hits.size() < 2) { + return hits; + } + Optional runtime = rerankRuntime(); + if (runtime.isEmpty()) { + return hits; + } + try { + ObjectNode body = objectMapper.createObjectNode(); + body.put("model", runtime.get().modelName()); + body.put("query", queryText); + body.put("top_n", hits.size()); + body.put("return_documents", false); + ArrayNode documents = body.putArray("documents"); + hits.forEach(hit -> documents.add(truncate(hit.content(), 2000))); + + HttpRequest httpRequest = HttpRequest.newBuilder() + .uri(URI.create(normalizeBaseUrl(runtime.get().baseUrl()) + "/rerank")) + .timeout(Duration.ofSeconds(30)) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + runtime.get().apiKey()) + .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body))) + .build(); + HttpResponse response = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(15)) + .build() + .send(httpRequest, HttpResponse.BodyHandlers.ofString()); + if (!ok(response.statusCode())) { + throw new IllegalStateException("rerank HTTP " + response.statusCode()); + } + JsonNode results = objectMapper.readTree(response.body()).path("results"); + if (!results.isArray() || results.isEmpty()) { + return hits; + } + List reranked = new ArrayList<>(); + for (JsonNode item : results) { + int index = item.path("index").asInt(-1); + if (index >= 0 && index < hits.size()) { + reranked.add(hits.get(index)); + } + } + // 模型漏返回的候选按原序补到末尾,保证不丢结果 + for (KnowledgeHit hit : hits) { + if (!reranked.contains(hit)) { + reranked.add(hit); + } + } + return reranked; + } catch (Exception e) { + log.warn("rerank failed, keep rrf order: {}", e.getMessage()); + return hits; + } + } + private SearchResponse dbSearch(String category, String queryText, Integer limit) { try { int limitValue = limitValue(limit); List keywordHits = likeHits(category, queryText, limitValue); List textHits = rrf(keywordHits, fulltextHits(category, queryText, limitValue), limitValue); - List hits = rrf(textHits, vectorHits(category, queryText, limitValue), limitValue); + List hits = rerankHits(queryText, rrf(textHits, vectorHits(category, queryText, limitValue), limitValue)); if (hits.isEmpty()) { return null; } @@ -2032,7 +2129,7 @@ public class AihrSopSeedService { return Optional.empty(); } try { - return Optional.of(normalizeExtractedText(callVisionOcr(runtime.get(), bytes, mimeType))); + return Optional.of(normalizeExtractedText(callVisionOcr(runtime.get(), bytes, mimeType, FRAME_PROMPT))); } catch (Exception e) { log.warn("video frame ocr failed for {}: {}", fileName, e.getMessage()); return Optional.empty(); diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index e708d189..e7fe83c4 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -51,7 +51,7 @@ SOP 文档上传第三片已经落最小后端边界: | 缺失向量补跑 | `POST /api/knowledge/doc/vectorize-missing` | 扫描缺少 `embedding_json` 的片段,复用同一向量化与 Qdrant 入库链路,适合模型配置变更后或历史资料补齐向量 | | 向量库状态与重建 | `GET /api/knowledge/doc/vector-index-status`、`POST /api/knowledge/doc/rebuild-vector-index` | 模型配置页展示当前 vector 模型维度、Qdrant collection 维度、点数和片段向量数;维度不一致时可一键清空旧 embedding、删除 collection 并按当前模型重建 | | Qdrant 向量索引 | 同一上传接口内机会性执行 | embedding 写入 MySQL 后尽力 upsert 到 Qdrant;同名文档替换会尽力删除旧 points;Qdrant 不可用不影响上传和 MySQL 检索;外部 embedding 成功但 collection 维度不一致时不静默降级为 `local-hash-v1`,通过状态接口和重建入口处理 | -| 混合检索 | `POST /api/knowledge/search` | 先跑中文关键词 `LIKE` 打分和 MySQL Fulltext,再生成 query embedding 走 Qdrant,最后按 RRF 融合并回 MySQL hydrate 片段;Qdrant 或外部向量接口不可用时保留关键词/全文检索 | +| 混合检索 | `POST /api/knowledge/search` | 先跑中文关键词 `LIKE` 打分和 MySQL Fulltext,再生成 query embedding 走 Qdrant,RRF 融合后交给 `category='rerank'` 模型(如硅基流动 bge-reranker-v2-m3)按语义相关性重排;rerank 未配置或失败保持 RRF 顺序,Qdrant 或外部向量接口不可用时保留关键词/全文检索 | | 解析状态聚合 | `GET /api/knowledge/processing/overview` | 聚合 `aihr_knowledge_attach.status`、fragment 数、embedding 数、`sys_oss.ext1.fileSize`,生成资料处理页指标、分类、任务、链路和事件列表 | | 服务端目录导入 | `POST /api/knowledge/doc/import-local` | JSON `{ directory, category, limit }`;`directory` 只能是 `AIHR_IMPORT_ROOT` / `aihr.import.root` 下的相对目录,默认根目录为 `./.data/import`;逐文件复用上传解析链路,同步执行,保留给小批量/调试 | | 服务端导入任务 | `POST /api/knowledge/doc/import-local-task`、`GET /api/knowledge/doc/import-tasks`、`POST /api/knowledge/doc/import-tasks/{id}/cancel` | 启动后台目录导入并返回任务;任务写入 `aihr_knowledge_import_task`,页面轮询查看总数、成功数、失败数、当前文件和进度;运行中任务可取消;重试当前按同目录重新启动一轮 | diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 5801eb1a..06e1429c 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -70,6 +70,15 @@ 净新增工作量:① session 时长字段+聚合;② 审核通过→积分的贡献计分服务逻辑;③ profile 接口拼装四指标。其余为 M6 既有 P1 表和页面。 +### B7. 视觉向量检索(Doubao-embedding-vision,二期) + +需求(2026-07-04 技术评审结论):纯视觉语义检索——"找演示保安站姿的视频"、图片素材以图搜图等"没说出口、也没写在画面上"的内容,当前文本向量链路(转写/OCR/caption → bge-m3)覆盖不到。 + +- **选型已定**:火山引擎 `Doubao-embedding-vision`(Seed1.6-Embedding,文本/图片/视频 → 统一向量空间,中文原生,0.7 元/百万 token)。优于 CLIP 类双塔方案:免融合排序调参、托管 API 免运维。 +- 工程量(不只是换模型名):入库管线支持"视觉片段"(帧/图直接向量化)、Qdrant 图文混合存储、检索结果展示帧图/跳转视频时间点、引用约束适配(视觉命中服务"找资料"场景,不进 RAG 生成)。 +- 前置:火山引擎开户 + G3/G6 供应商合规评估;图片/视频资料成规模、以图搜图成为真实高频需求。 +- 已做的替代缓解(2026-07-04):视频关键帧 caption(vision prompt 带画面描述)+ rerank(bge-reranker-v2-m3)已上线,覆盖大部分检索需求;B7 只补最后一段纯视觉语义。 + ## Future Enhancements ### Model Classification Version Governance