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:
2026-07-04 01:06:09 +08:00
parent fe1eef3905
commit df20627fbb
3 changed files with 110 additions and 4 deletions
@@ -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();