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();
|
||||
|
||||
Reference in New Issue
Block a user