feat(aihr): keyframe caption and rerank in retrieval pipeline
视频关键帧 vision prompt 从纯 OCR 扩展为「文字+一句画面描述」,纯视觉 信息(动作/场景演示)进入文本向量可检索;检索 RRF 融合后接 category=rerank 模型(bge-reranker-v2-m3)语义重排,未配置或失败保持 RRF 顺序(TechSpec 12.3 最后一环)。BACKLOG 记入 B7 视觉向量检索 (Doubao-embedding-vision,二期)。端到端验证:重传视频片段带画面描述; 检索「首次反馈时限」视频转写片段重排后命中第一。
This commit is contained in:
+100
-3
@@ -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<ChatRuntime> rerankRuntime() {
|
||||
try {
|
||||
List<ChatRuntime> 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<KnowledgeHit> rerankHits(String queryText, List<KnowledgeHit> hits) {
|
||||
if (hits.size() < 2) {
|
||||
return hits;
|
||||
}
|
||||
Optional<ChatRuntime> 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<String> 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<KnowledgeHit> 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<KnowledgeHit> keywordHits = likeHits(category, queryText, limitValue);
|
||||
List<KnowledgeHit> textHits = rrf(keywordHits, fulltextHits(category, queryText, limitValue), limitValue);
|
||||
List<KnowledgeHit> hits = rrf(textHits, vectorHits(category, queryText, limitValue), limitValue);
|
||||
List<KnowledgeHit> 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();
|
||||
|
||||
@@ -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`,页面轮询查看总数、成功数、失败数、当前文件和进度;运行中任务可取消;重试当前按同目录重新启动一轮 |
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user