多模态聊天记录检索:从 IM 消息到跨模态语义召回的工程实现

多模态聊天记录检索:从 IM 消息到跨模态语义召回的工程实现

引言

即时通讯系统中的“聊天记录检索”,最早通常是一个文本检索问题:用户输入关键词,系统从消息表中查找包含该关键词的历史消息。对于只包含文字的早期聊天场景,这种方案足够直接,也便于解释和调试。

但在真实 IM 系统中,聊天记录早已不只是文本。一次完整的沟通可能包含文字、截图、照片、短视频、文件名、图片说明、视频备注以及时间、会话、发送人等元数据。用户想找的内容也未必能用一个明确关键词表达,例如:

  • “上次发过的那张白板照片”
  • “群里有人发过的合同截图”
  • “那个录屏里展示登录失败的画面”
  • “和订单异常相关的聊天内容”

这类需求已经超出传统关键词检索的能力边界。关键词检索依赖字面匹配,而用户的真实意图往往是语义化、跨模态的。所谓“多模态聊天记录检索”,就是将文本、图片、视频等不同形式的消息统一纳入检索系统,使用户可以用文本或图片等方式找回语义相关的历史消息。

本文拆解一个面向 IM 系统的多模态聊天记录检索实现。该模块基于 Spring Boot、PostgreSQL pgvector、Qwen3-VL Embedding 与 Qwen3-VL Rerank,完成了文本、图片、视频消息的向量化存储、相似召回与二阶段重排。需要强调的是,本文只基于当前代码实现进行分析,不夸大其能力边界:当前视频处理采用“抽取首帧并按图片 embedding”的方式,并不是完整的视频时序理解。

什么是多模态聊天记录

“多模态”中的“模态”,可以理解为信息的表现形式。文本是一种模态,图片是一种模态,音频、视频也都是不同模态。多模态系统的目标,是让机器能够处理和关联这些不同形式的信息。

在聊天系统中,常见消息可以分为几类:

  1. 文本消息:普通聊天内容、命令、说明、备注。
  2. 图片消息:截图、照片、表情包、设计稿、白板照片。
  3. 视频消息:录屏、短视频、会议片段。
  4. 消息元数据:发送人、接收人、群组、会话 ID、消息时间、文件名、消息类型。

传统数据库更擅长处理结构化字段,例如 conversation_idmessage_timefrom_id。全文检索系统更擅长处理文本内容。但图片和视频无法直接用 SQL 的 LIKE 或全文索引进行语义匹配。因此,多模态检索通常需要引入“嵌入向量”。

嵌入向量,英文通常称为 embedding,是模型将文本、图像等输入映射为一组浮点数后的结果。语义相近的输入,在向量空间中的距离往往更近。当前项目中,消息被转换为 512 维向量,并存储在 PostgreSQL 的 vector(512) 字段中。

另一个容易混淆的概念是“模态转译”。它指的是为了工程处理,将一种模态转成另一种可被系统消费的形式。例如当前代码对视频消息的处理,并不是直接理解完整视频,而是使用 ffmpeg 抽取首帧,将首帧作为图像送入 embedding 模型。这属于典型的工程折中:实现简单、成本较低,但对视频中间内容和音频信息的理解有限。

当前模块的总体架构

从代码结构看,当前模块是一个独立的 Spring Boot AI 服务,而不是直接写入 IM 主业务模块。它主要围绕以下几个核心类展开:

  • MessageVectorService:消息向量化、文件处理、检索和重排的核心编排服务。
  • MessageVectorRepository:基于 JDBC 和 pgvector 的向量写入与召回。
  • Qwen3VlEmbeddingModel:封装文本和图片 embedding 调用。
  • Qwen3VlRerankModel:封装文本查询和图片查询的候选重排。
  • MessageSearchController:提供文本搜索和以图搜图接口。
  • schema.sql:定义消息索引表、向量字段和 HNSW 索引。

整体链路可以概括为:

  1. 根据消息类型构造 embedding 输入。
  2. 调用多模态 embedding 模型生成向量。
  3. 将消息元数据和向量写入 PostgreSQL。
  4. 查询时先生成 query embedding。
  5. 使用 pgvector 进行 TopK 相似召回。
  6. 对候选结果调用 rerank 模型进行二阶段排序。
  7. 返回包含 vectorScorererankScorehighConfidenceMatch 等字段的搜索结果。

数据建模:用一张索引表统一承载多种消息

多模态检索的第一步,是为不同类型的消息建立统一索引。当前项目的 schema.sql 定义如下:

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE IF NOT EXISTS im_ai_message_index (
    message_key BIGINT PRIMARY KEY,
    app_id INT NOT NULL,
    conversation_id VARCHAR(128) NOT NULL,
    message_kind VARCHAR(16) NOT NULL,
    from_id VARCHAR(64),
    to_id VARCHAR(64),
    group_id VARCHAR(64),
    message_time BIGINT NOT NULL,
    image_url VARCHAR(512),
    file_name VARCHAR(255),
    text_content TEXT,
    caption TEXT,
    embedding vector(512)
);

这个表有几个值得关注的设计点。

第一,message_key 作为主键,说明每条 IM 消息在 AI 索引中对应一条记录。这种设计便于根据原始消息 ID 做幂等处理和后续更新。

第二,message_kind 用于区分 TEXTIMAGEVIDEO 等消息类型。不同类型消息的 embedding 来源不同,但最终都写入同一个 embedding 字段。

第三,text_contentcaptionfile_name 等字段保留了可解释的文本信息。这些字段不仅方便前端展示,也会在 rerank 阶段被组装成候选文档。

第四,embedding vector(512) 表示当前模型返回的向量维度是 512。代码中也会校验向量维度,避免不同模型或配置不一致导致检索异常。

表结构还创建了两个索引:

CREATE INDEX IF NOT EXISTS idx_im_ai_message_index_embedding_hnsw
    ON im_ai_message_index USING hnsw (embedding vector_cosine_ops);

CREATE INDEX IF NOT EXISTS idx_im_ai_message_index_app_conversation_time
    ON im_ai_message_index (app_id, conversation_id, message_time DESC);

第一个是 HNSW 向量索引,用于加速近似最近邻检索。HNSW 是工业界常用的 ANN(Approximate Nearest Neighbor,近似最近邻)索引结构,适合在高维向量空间中快速找相似项。

第二个是业务过滤索引。聊天记录检索通常不能只看语义,还必须限定应用、会话和时间范围。否则,用户在某个会话中搜索时可能召回其他会话的无关消息。

写入链路:文本、图片、视频如何生成向量

文本消息:结构化文本再 embedding

文本消息写入时,MessageVectorService 会先构造 embedding 输入:

private MessageIndex buildTextOrMetadataMessage(MessageIndexRequest request) {
    if (request.getMessageKind() != MessageKind.TEXT) {
        throw new BusinessException("批量文本接口仅支持TEXT消息");
    }
    String embeddingSource = buildTextEmbeddingSource(request);
    float[] embedding = embeddingModel.embedText(embeddingSource);
    return MessageIndex.builder()
        .messageKey(request.getMessageKey())
        .appId(request.getAppId())
        .conversationId(request.getConversationId())
        .messageKind(request.getMessageKind().name())
        .fromId(request.getFromId())
        .toId(request.getToId())
        .groupId(request.getGroupId())
        .messageTime(request.getMessageTime())
        .fileName(request.getFileName())
        .textContent(request.getTextContent())
        .caption(request.getCaption())
        .embedding(embedding)
        .build();
}

这里没有直接把 textContent 原样送给模型,而是调用 buildTextEmbeddingSource。该方法内部会构造结构化文档:

private String buildStructuredDocument(String typeLabel, List<String> values) {
    Set<String> lines = new LinkedHashSet<>();
    lines.add("类型: " + typeLabel);
    for (String value : values) {
        addNormalizedLine(lines, value);
    }
    if (lines.size() == 1) {
        lines.add("内容较少");
    }
    return String.join("\n", lines);
}

结构化文本的意义在于给模型更多上下文。比如同样是“登录失败”,如果类型标注为“文本消息”,它和“图片消息中的 caption”在候选重排时可以被区别对待。

图片消息:保存文件并生成图像向量

图片消息通过文件上传接口进入系统。核心处理逻辑如下:

private MessageIndex buildImageMessage(MessageIndexRequest request, MultipartFile file) {
    FileStorageService.StoredFile storedFile = fileStorageService.saveImage(file);
    try {
        String imageDataUri = fileStorageService.toImageDataUri(storedFile.path());
        float[] embedding = embeddingModel.embedImage(imageDataUri);
        return MessageIndex.builder()
            .messageKey(request.getMessageKey())
            .appId(request.getAppId())
            .conversationId(request.getConversationId())
            .messageKind(request.getMessageKind().name())
            .fromId(request.getFromId())
            .toId(request.getToId())
            .groupId(request.getGroupId())
            .messageTime(request.getMessageTime())
            .imageUrl(storedFile.accessUrl())
            .fileName(storedFile.fileName())
            .textContent(buildImageDocumentText(request, storedFile.fileName()))
            .caption(normalizeText(request.getCaption()))
            .embedding(embedding)
            .build();
    }
    catch (Exception ex) {
        fileStorageService.deleteQuietly(storedFile.path());
        throw ex;
    }
}

这里有两条信息被保留下来。

一条是图片本身的视觉向量,即 embedImage(imageDataUri) 的结果。它用于相似图片召回或跨模态检索。

另一条是图片相关文本,例如 caption、原始文本内容和文件名。代码会把这些字段组装进 textContent,供后续展示和 rerank 使用。

如果 embedding 过程中发生异常,代码会删除已保存文件,避免出现“文件已落盘但索引失败”的脏数据。这是一个重要的工程细节。

视频消息:抽首帧作为近似表示

视频消息处理更复杂。当前实现没有直接调用视频理解模型,而是将视频转成一张代表性图片:

private MessageIndex buildVideoMessage(MessageIndexRequest request, MultipartFile file) {
    Path tempVideoPath = null;
    Path firstFramePath = null;
    try {
        tempVideoPath = Files.createTempFile("video-index-", getVideoSuffix(file.getOriginalFilename()));
        file.transferTo(tempVideoPath);
        firstFramePath = extractFirstFrame(tempVideoPath);
        String imageDataUri = fileStorageService.toImageDataUri(firstFramePath);
        float[] embedding = embeddingModel.embedImage(imageDataUri);
        String fileName = resolveVideoFileName(request, file);
        return MessageIndex.builder()
            .messageKey(request.getMessageKey())
            .appId(request.getAppId())
            .conversationId(request.getConversationId())
            .messageKind(request.getMessageKind().name())
            .fromId(request.getFromId())
            .toId(request.getToId())
            .groupId(request.getGroupId())
            .messageTime(request.getMessageTime())
            .fileName(fileName)
            .textContent(buildVideoDocumentText(request, fileName))
            .caption(normalizeText(request.getCaption()))
            .embedding(embedding)
            .build();
    }
    finally {
        fileStorageService.deleteQuietly(firstFramePath);
        fileStorageService.deleteQuietly(tempVideoPath);
    }
}

首帧提取依赖 ffmpeg:

ProcessBuilder processBuilder = new ProcessBuilder(
    appProperties.getVideo().getFfmpegCommand(),
    "-y",
    "-i",
    videoPath.toString(),
    "-frames:v",
    "1",
    framePath.toString()
);

这种处理方式适合视频封面和首帧具有代表性的场景。例如录屏一开始就显示错误页面,或者短视频首帧就是用户要找的画面。但它也有明显局限:如果关键信息出现在视频中段、末尾或音频中,首帧向量无法捕捉这些信息。

更完整的视频检索通常会引入关键帧抽取、多帧 embedding、ASR 语音转写、OCR 字幕识别或视频 caption。但这些能力会显著增加计算成本和系统复杂度。

Embedding 模型适配:统一向量空间的入口

Qwen3VlEmbeddingModel 封装了文本和图片 embedding 调用。核心方法如下:

public float[] embedText(String text) {
    if (!StringUtils.hasText(text)) {
        throw new BusinessException("文本内容不能为空");
    }
    return executeEmbedding(buildPayload(new ContentItem("text", text)));
}

public float[] embedImage(String imageUri) {
    if (!StringUtils.hasText(imageUri)) {
        throw new BusinessException("图片地址不能为空");
    }
    return executeEmbedding(buildPayload(new ContentItem("image", imageUri)));
}

payload 中包含模型名称、输入内容和参数:

private EmbeddingPayload buildPayload(ContentItem item) {
    AppProperties.Qwen.Embedding properties = appProperties.getQwen().getEmbedding();
    EmbeddingParameters parameters = new EmbeddingParameters(
        properties.getDimension(),
        properties.isEnableFusion(),
        properties.getOutputType(),
        properties.getInstruct(),
        properties.getFps()
    );
    return new EmbeddingPayload(
        properties.getModelName(),
        new EmbeddingInput(List.of(item)),
        parameters
    );
}

这说明系统把文本和图像统一交给同一个多模态 embedding 适配器处理。对检索系统来说,重要的不是原始输入是文本还是图片,而是最终都能得到同一维度、同一空间下的向量表示。

代码中还显式校验了模型返回向量维度:

int expectedDimension = appProperties.getQwen().getEmbedding().getDimension();
if (embedding.length != expectedDimension) {
    throw new BusinessException("模型返回向量维度不是" + expectedDimension);
}

这是生产系统中很必要的防御。模型切换、配置调整或 API 返回异常,都可能导致维度不一致。如果不在写入前校验,后续插入 pgvector 或检索时会出现更隐蔽的问题。

向量写入:JDBC 与 pgvector 的结合

向量写入由 MessageVectorRepository.batchInsert 完成:

String sql = """
    INSERT INTO im_ai_message_index (
        message_key, app_id, conversation_id, message_kind, from_id, to_id, group_id,
        message_time, image_url, file_name, text_content, caption, embedding
    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    """;

写入 embedding 时,代码使用 pgvector Java 类型:

ps.getConnection()
    .unwrap(org.postgresql.PGConnection.class)
    .addDataType("vector", PGvector.class);
ps.setObject(13, new PGvector(messageIndex.getEmbedding()));

这说明当前项目没有直接依赖 ORM 来处理向量字段,而是使用 JDBC 显式写入。对于向量检索场景,这种方式较为直接,可控性较强。尤其是在需要精确控制 SQL、索引和距离运算符时,JDBC 或 SQL builder 往往比抽象层更清晰。

同样,写入前也会校验 embedding:

private void validateEmbedding(float[] embedding) {
    int expectedDimension = appProperties.getQwen().getEmbedding().getDimension();
    if (embedding == null || embedding.length != expectedDimension) {
        throw new IllegalArgumentException("向量维度必须为" + expectedDimension);
    }
}

这和模型适配层的校验形成了双重保障。

查询链路:从 Query 到候选召回

文本检索入口在 MessageSearchController 中:

@GetMapping("/search")
public List<MessageSearchResponse> search(@Valid @ModelAttribute MessageSearchRequest request) {
    return messageVectorService.searchByText(request);
}

图片检索入口则支持上传图片进行以图搜图:

@PostMapping(value = "/search-by-image", consumes = "multipart/form-data")
public List<MessageSearchResponse> searchByImage(
        @RequestParam("file") MultipartFile file,
        @RequestParam(value = "targetKind", required = false) MessageKind targetKind,
        @RequestParam(value = "topK", required = false) Integer topK,
        @RequestParam(value = "appId", required = false) Integer appId,
        @RequestParam(value = "conversationId", required = false) String conversationId,
        @RequestParam(value = "startTime", required = false) Long startTime,
        @RequestParam(value = "endTime", required = false) Long endTime) {
    return messageVectorService.searchByImage(file, targetKind, topK, appId, conversationId, startTime, endTime);
}

文本查询的核心逻辑如下:

public List<MessageSearchResponse> searchByText(MessageSearchRequest request) {
    String searchQuery = buildSearchQueryText(request.getQuery());
    int finalTopK = resolveFinalTopK(request.getTopK());
    float[] queryEmbedding = embeddingModel.embedText(searchQuery);
    List<MessageSearchResponse> candidates = searchCandidates(queryEmbedding, request, null, false);
    return rerankTextResults(searchQuery, candidates, finalTopK);
}

图片查询的核心逻辑如下:

public List<MessageSearchResponse> searchByImage(
        MultipartFile imageFile,
        MessageKind targetKind,
        Integer topK,
        Integer appId,
        String conversationId,
        Long startTime,
        Long endTime) {
    String imageDataUri = fileStorageService.toImageDataUri(imageFile);
    float[] queryEmbedding = embeddingModel.embedImage(imageDataUri);
    MessageSearchRequest request = new MessageSearchRequest();
    request.setQuery("image-search");
    request.setTopK(resolveRequestTopK(topK));
    request.setAppId(appId);
    request.setConversationId(conversationId);
    request.setStartTime(startTime);
    request.setEndTime(endTime);
    int finalTopK = resolveFinalTopK(request.getTopK());
    String target = targetKind == null ? null : targetKind.name();
    List<MessageSearchResponse> candidates = searchCandidates(queryEmbedding, request, target, true);
    return rerankImageResults(imageDataUri, candidates, finalTopK);
}

这两段代码展示了统一检索范式:不论 query 是文本还是图片,都会先生成 query embedding,然后进入同一个向量召回逻辑。

pgvector 召回:语义相似度和业务过滤并行

真正执行向量召回的是 MessageVectorRepository.search

SELECT message_key, app_id, conversation_id, message_kind, from_id, to_id, group_id,
       message_time, image_url, file_name, text_content, caption,
       1 - (embedding <=> CAST(:embedding AS vector)) AS score
FROM im_ai_message_index
WHERE embedding IS NOT NULL

这里的 <=> 是 pgvector 的余弦距离运算符。代码将 1 - distance 作为分数,命名为 score,映射到返回结果中的 vectorScore

随后会拼接业务过滤条件:

if (request.getAppId() != null) {
    sql.append(" AND app_id = :appId");
}
if (StringUtils.hasText(request.getConversationId())) {
    sql.append(" AND conversation_id = :conversationId");
}
if (request.getStartTime() != null) {
    sql.append(" AND message_time >= :startTime");
}
if (request.getEndTime() != null) {
    sql.append(" AND message_time <= :endTime");
}
if (StringUtils.hasText(targetKind)) {
    sql.append(" AND message_kind = :targetKind");
}

最后按向量距离排序:

sql.append(" ORDER BY embedding <=> CAST(:embedding AS vector) ASC LIMIT :topK");

这体现了聊天记录检索与普通向量检索的区别。普通向量检索可能只关心“语义上最相似”,而 IM 检索必须满足用户所在业务上下文。例如用户在某个群聊中搜索,就不应默认召回其他群聊中的消息。

二阶段重排:为什么不能只依赖向量召回

向量召回适合从大量消息中快速筛出候选集,但它并不总是能给出最合理的最终排序。原因包括:

  • embedding 会压缩原始信息,细节可能丢失。
  • 文本、图片、视频之间的相似度分布不完全一致。
  • 用户 query 可能很短,语义不充分。
  • 候选消息的 caption、文件名、类型等结构化信息也会影响相关性。

因此当前代码引入了 rerank 阶段:

private List<MessageSearchResponse> rerankTextResults(
        String query,
        List<MessageSearchResponse> candidates,
        int finalTopK) {
    if (CollectionUtils.isEmpty(candidates)) {
        return candidates;
    }
    try {
        List<Qwen3VlRerankModel.RerankCandidate<MessageSearchResponse>> rerankCandidates =
            buildRerankCandidates(candidates);
        List<Qwen3VlRerankModel.RerankResult<MessageSearchResponse>> reranked =
            rerankModel.rerankText(query, rerankCandidates, finalTopK);
        return mapRerankResults(reranked, candidates, finalTopK);
    }
    catch (Exception ex) {
        log.warn("文本检索重排失败,回退向量召回结果: {}", ex.getMessage());
        return applyFallbackDecision(sortByVectorScore(candidates), finalTopK);
    }
}

图片查询也有类似逻辑:

private List<MessageSearchResponse> rerankImageResults(
        String imageDataUri,
        List<MessageSearchResponse> candidates,
        int finalTopK) {
    try {
        List<Qwen3VlRerankModel.RerankCandidate<MessageSearchResponse>> rerankCandidates =
            buildRerankCandidates(candidates);
        List<Qwen3VlRerankModel.RerankResult<MessageSearchResponse>> reranked =
            rerankModel.rerankImage(imageDataUri, rerankCandidates, finalTopK);
        return mapRerankResults(reranked, candidates, finalTopK);
    }
    catch (Exception ex) {
        log.warn("图片检索重排失败,回退向量召回结果: {}", ex.getMessage());
        return applyFallbackDecision(sortByVectorScore(candidates), finalTopK);
    }
}

这里的 fallback 很重要。模型服务可能超时、限流或返回异常。如果 rerank 失败,系统不会直接检索失败,而是回退到向量召回结果。这种降级策略能提升系统可用性。

候选文档构造:将多模态消息转成可重排文本

当前 rerank 模型接收的候选 document 是文本形式,因此代码会把候选消息转换为结构化文本:

private String buildRerankDocument(MessageSearchResponse candidate) {
    MessageKind messageKind = parseMessageKind(candidate.getMessageKind());
    return switch (messageKind) {
        case TEXT -> buildTextDocument(candidate);
        case IMAGE -> buildImageDocument(candidate);
        case VIDEO -> buildVideoDocument(candidate);
    };
}

不同消息类型使用不同类型标签:

private String buildTextDocument(MessageSearchResponse candidate) {
    return buildStructuredDocument(
        "文本消息",
        List.of(candidate.getTextContent(), candidate.getCaption(), candidate.getFileName())
    );
}

private String buildImageDocument(MessageSearchResponse candidate) {
    return buildStructuredDocument(
        "图片消息",
        List.of(candidate.getCaption(), candidate.getTextContent(), candidate.getFileName())
    );
}

private String buildVideoDocument(MessageSearchResponse candidate) {
    return buildStructuredDocument(
        "视频消息",
        List.of(candidate.getCaption(), candidate.getTextContent(), candidate.getFileName())
    );
}

这是一种实用但有限的设计。它让 rerank 阶段能够利用消息类型、caption、文件名等信息,但候选图片本身并没有再次作为图片输入给 rerank。换句话说,当前系统的多模态能力主要体现在 embedding 召回阶段,rerank 阶段更偏向对候选元数据的相关性判断。

这种方案的优点是成本较低、实现清晰、接口稳定。缺点是如果图片没有 caption 或文件名,rerank 对图片内容的判断能力会受限。

TopK 策略与候选扩展

为了让 rerank 有足够候选,系统会区分“候选 TopK”和“最终 TopK”:

private int resolveCandidateTopK(Integer requestedTopK, boolean multimodalSearch) {
    int candidateTopK = appProperties.getQwen().getRerank().getCandidateTopK();
    int effectiveCandidateTopK = multimodalSearch ? Math.max(candidateTopK, 20) : candidateTopK;
    return Math.max(resolveFinalTopK(requestedTopK), effectiveCandidateTopK);
}

这段代码体现了二阶段检索的常见思想:第一阶段召回要适当放宽,第二阶段再精排。如果第一阶段只召回最终需要的 3 条,rerank 几乎没有发挥空间。尤其在多模态检索中,相似度分布可能更不稳定,适当扩大候选集是合理的。

不过,候选集越大,rerank 成本越高,响应时间也可能增加。因此 candidateTopK 应根据业务延迟目标、模型费用和检索质量进行平衡。

置信度判断:不只是排序,还要判断“是否足够确定”

当前代码不只返回排序结果,还对 Top1 做高置信判断:

private boolean isHighConfidenceTop1(MessageSearchResponse top1, Double scoreGap) {
    if (top1 == null || top1.getRerankScore() == null || scoreGap == null) {
        return false;
    }
    AppProperties.Qwen.Rerank rerankProperties = appProperties.getQwen().getRerank();
    return top1.getRerankScore() >= rerankProperties.getHighConfidenceThreshold()
        && scoreGap >= rerankProperties.getMinScoreGap();
}

scoreGap 是第一名和第二名 rerank 分数的差:

private Double calculateScoreGap(List<MessageSearchResponse> results) {
    if (CollectionUtils.isEmpty(results) || results.get(0).getRerankScore() == null) {
        return null;
    }
    if (results.size() == 1) {
        return results.get(0).getRerankScore();
    }
    Double secondScore = results.get(1).getRerankScore();
    if (secondScore == null) {
        return null;
    }
    return results.get(0).getRerankScore() - secondScore;
}

这种设计适合“是否自动采纳 Top1”的场景。例如搜索结果可能用于智能助手自动引用某条消息,或者用于内容审核系统自动定位证据。此时只知道排序还不够,还需要知道系统对第一名是否足够有把握。

不过,阈值不能凭经验随意设置。合理做法是基于真实查询样本、人工标注结果和线上反馈进行校准。否则,高置信标记可能产生误导。

现有技术方案对比

方案一:关键词检索

关键词检索可以通过数据库 LIKE、全文索引、Elasticsearch、OpenSearch 等实现。它的优势是成本低、结果可解释、对精确词匹配效果好。例如搜索订单号、手机号、错误码时,关键词检索往往比语义检索更可靠。

但它的局限也很明显。用户搜索“报销凭证”时,历史消息中可能写的是“发票截图”;用户搜索“登录异常”时,群里可能只发了一张错误页面图片。关键词检索无法自然处理这些语义差异和跨模态内容。

方案二:文本向量检索

文本向量检索会将聊天文本转换为 embedding,再使用向量数据库或向量索引召回相似内容。它能解决同义词、近义表达、模糊查询等问题。

如果系统只有文本消息,这是一条性价比较高的路线。但在 IM 场景中,大量关键信息存在于图片和视频中。仅做文本 embedding 时,图片和视频必须依赖 caption、OCR 或人工描述才能进入检索系统。

方案三:多模态统一向量检索

多模态统一向量检索将文本和图片映射到同一向量空间,从而支持用文本搜图片、用图片搜图片,甚至用图片找相关文本。当前项目接近这种方案:文本通过 embedText,图片和视频首帧通过 embedImage,最终都写入同一个向量字段。

这种方案的优势是统一、灵活,适合聊天记录这种数据形态复杂的场景。缺点是依赖多模态模型能力,且调试难度高于关键词检索。

方案四:混合检索与多路召回

更成熟的工业系统通常不会只依赖一种召回方式,而是组合关键词检索、向量检索、OCR 文本检索、业务规则召回和用户行为特征,再统一 rerank。

例如:

  • 搜索订单号时,关键词召回优先。
  • 搜索“白板照片”时,图像向量召回更有效。
  • 搜索“某人上周发的截图”时,发送人和时间过滤非常关键。
  • 搜索视频内容时,关键帧、字幕、ASR 转写都可能参与召回。

当前项目已经具备“向量召回 + rerank”的基础,但还没有实现多路召回融合。后续可以在此基础上扩展关键词、OCR、ASR 等链路。

实际应用中的关键权衡

检索质量与响应延迟

embedding、pgvector 召回和 rerank 都会增加耗时。尤其是 rerank,需要对候选集再次调用模型。候选越多,质量可能越好,但延迟和成本也越高。

因此实际系统中通常会设置:

  • 最大候选数量
  • 模型调用超时
  • 重试次数
  • fallback 逻辑
  • 慢查询日志和指标监控

当前代码中的超时、重试和 fallback,是向生产系统靠近的重要信号。

多模态能力与可解释性

关键词检索结果容易解释,因为命中了某个词。向量检索结果则更难解释,尤其是图片和文本跨模态匹配时,用户可能会问“为什么这张图被召回”。

保留 captionfileNamemessageKindvectorScorererankScore 等字段,可以部分增强可解释性。但如果要进一步提升用户信任,还可以在结果中展示匹配依据,例如 OCR 命中的文字、caption 摘要或模型生成的解释。

视频理解的深度与成本

当前视频方案抽取首帧,适合快速落地。它的成本远低于完整视频分析,但召回质量依赖首帧代表性。

更复杂的视频检索可以逐步演进:

  1. 抽取首帧。
  2. 抽取多帧。
  3. 抽取关键帧。
  4. 对关键帧做 OCR 和 caption。
  5. 对音频做 ASR。
  6. 将多帧、多文本、多音频结果融合为一个或多个索引项。

每一步都会提升覆盖范围,也会增加存储、计算和工程复杂度。

权限与隐私

聊天记录属于高敏感数据。多模态检索系统不能只考虑召回质量,还必须考虑权限边界。例如:

  • 用户只能搜索自己有权限访问的会话。
  • 群聊消息需要遵守群成员权限。
  • 图片、视频可能包含隐私信息。
  • embedding 本身也可能被视为派生敏感数据。

当前代码已经支持 appIdconversationId、时间范围等过滤,但真正的权限控制通常还需要和 IM 主系统的用户、群组、组织关系打通。

未来方向

第一,加入 OCR。大量图片消息其实是截图,里面包含文字。对图片做 OCR 后,可以把识别出的文字作为额外字段参与关键词检索、向量检索和 rerank。

第二,增强视频索引。首帧方案可以升级为关键帧采样,并将多帧结果聚合。对于会议录屏、操作演示等视频,还可以结合 ASR 语音转写。

第三,引入混合检索。关键词检索和向量检索不是替代关系,而是互补关系。订单号、错误码、人名等精确查询更适合关键词;模糊意图和跨模态查询更适合向量检索。

第四,做反馈闭环。用户点击、复制、打开、忽略某条搜索结果,都是有价值的反馈信号。长期看,检索系统需要基于真实行为优化排序。

第五,增强可观测性。多模态检索涉及模型调用、向量数据库、文件处理、视频处理等多个环节。生产环境应监控 embedding 耗时、rerank 耗时、召回数量、异常率、fallback 比例、TopK 点击率等指标。

总结

这套多模态聊天记录检索实现,是一个比较务实的工程方案。它没有试图一次性解决所有多模态理解问题,而是围绕 IM 消息检索的核心需求,建立了清晰的两阶段链路:

  1. 写入阶段,将文本、图片、视频首帧转换为统一向量。
  2. 存储阶段,使用 PostgreSQL pgvector 保存 512 维 embedding。
  3. 查询阶段,根据文本或图片 query 生成向量。
  4. 召回阶段,通过 HNSW 索引和余弦距离找相似消息。
  5. 重排阶段,使用 rerank 模型结合结构化候选文本重新排序。
  6. 决策阶段,返回分数、分差和高置信标记。

从架构上看,它把 IM 系统中原本分散的文本、图片、视频消息统一纳入语义检索框架,为聊天记录智能检索提供了可扩展基础。

从工程边界看,它仍然有进一步演进空间,例如 OCR、ASR、多帧视频理解、混合检索、权限增强和反馈闭环。但正因为边界清晰,这套实现更适合作为多模态聊天记录检索的第一阶段落地方案:先用统一向量空间解决“能搜到”的问题,再通过重排、过滤和多路召回逐步解决“搜得准、搜得稳、搜得可解释”的问题。

posted @ 2026-05-29 23:35  if5561  阅读(30)  评论(0)    收藏  举报