首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >给微信加一套多模态搜索:Elasticsearch 9.5 `semantic` 字段上手

给微信加一套多模态搜索:Elasticsearch 9.5 `semantic` 字段上手

原创
作者头像
点火三周
发布2026-08-25 19:51:19
发布2026-08-25 19:51:19
990
举报

不做 OCR,不做语音转写,用一个字段声明让图片、视频、音频都能用自然语言搜。


先看效果


这篇文章能给你什么

看完能自己跑起来一套东西:指着一堆哈希命名的媒体文件,用中文描述就能搜出来。

  • 环境:Elasticsearch 9.5+(Cloud Hosted / Serverless / 开了 Cloud Connected Mode 的自管集群)
  • 代码量:核心不到 40 行 Python,只用标准库 + ffmpeg
  • 难度:会写 HTTP 请求就够了,不需要懂向量检索

📸 截图位 1:搜索「夜景灯光」的结果网格 —— 缩略图全是夜景视频


一、为什么微信的数据搜不到

macOS 上微信的本地目录长这样:

代码语言:basic
复制
~/Library/Containers/com.tencent.xinWeChat/Data/Documents/
  xwechat_files/wxid_xxxxx/
    ├── temp/           # 截图、应用缓存图片
    ├── msg/video/      # 视频,按月份分目录
    ├── msg/file/       # 音频、文档
    └── msg/attach/     # .dat 加密容器

三个问题叠在一起:

  1. 文件名是哈希。 24fa235d9976ad90567cabbf81f8881b.mp4b635b531-0f34-4414.png,原始文件名早就丢了。
  2. 文件没有语义标签。 视频里是什么画面、截图里写了什么,文件系统一无所知。
  3. 微信自带搜索只认聊天文本。 你记不住原话,它就帮不上。

顺带说一个坏消息:msg/attach/ 下的 .dat 是微信私有加密容器(文件头 07 08 56 32),聊天数据库本身也是加密的。这两块我们不碰,所以这套方案拿不到聊天上下文 —— 只能定位到文件。

Finder 里 msg/video/2026-05/ 的哈希文件名列表


二、原理:一个字段,三种模态,同一个向量空间

传统做法是拼三条流水线:图片走 OCR,语音走 ASR,视频抽帧走图像模型,最后把产出的文本丢进全文检索。这套方案里这三条都没有。

Elasticsearch 9.5 新增了一个 semantic 字段类型。你在 mapping 里声明它、绑定一个多模态推理端点,剩下的事它自己干:写入文档时自动把媒体编码成向量,查询时自动把查询编码成向量。

代码语言:markdown
复制
微信本地文件 ──压缩/裁剪──▶ PUT _doc ──▶ semantic 字段自动向量化
                                              │
图片 ─┐                                       ▼
视频 ─┼──▶ 同一个模型 ──▶ 同一个 1024 维空间 ◀── 查询也编码进这里
音频 ─┘                                       │
                                              ▼
                                        最近邻 = 搜索结果

关键在最后一步:三种模态被同一个模型编码进同一个空间。所以文字能命中视频,拖一张图也能召回视频 —— 跨模态检索不需要额外代码,它是这个设计的自然结果。

架构动画图(三簇向量汇入同一空间)

semanticsemantic_text 别搞混

semantic_text

semantic

task type

text_embedding / sparse_embedding

embedding

吃什么

只吃文本

文本 + 图片 / 视频 / 音频 / PDF

默认端点

没有,必须指定 inference_id

两者是并列关系,不是替代。做多模态用 semantic


三、动手:5 步

第 1 步:确认推理端点

ES 9.5 内置了 .jina-embeddings-v5-omni-small,不用自己创建:

代码语言:http
复制
GET _inference/embedding/.jina-embeddings-v5-omni-small

第 2 步:建索引

核心就是把一个字段声明成 semantic

代码语言:http
复制
PUT wechat-multimodal
{
  "mappings": {
    "properties": {
      "embedding": {
        "type": "semantic",
        "inference_id": ".jina-embeddings-v5-omni-small"
      },
      "file_path":  { "type": "keyword" },
      "file_name":  { "type": "keyword" },
      "media_type": { "type": "keyword" },
      "indexed_at": { "type": "date" }
    }
  }
}

建完回读一次 mapping,ES 会把模型参数补进去 —— 这是确认端点真挂上了最快的办法:

代码语言:json
复制
"model_settings": {
  "task_type": "embedding",
  "dimensions": 1024,
  "similarity": "cosine",
  "element_type": "float"
}

第 3 步:写入

三种模态同一个形状,只有 type 在变

代码语言:python
复制
# 图片
{"embedding": {"type": "image", "value": f"data:image/jpeg;base64,{b64}"}}
# 视频
{"embedding": {"type": "video", "value": f"data:video/mp4;base64,{b64}"}}
# 音频
{"embedding": {"type": "audio", "value": f"data:audio/mpeg;base64,{b64}"}}

直接 PUT 进去就行,没有 pipeline,不用手动调推理接口。

第 4 步:查询

文字查询就是一条普通的 match —— 这是整套方案里最舒服的一点,前端代码完全不用感知向量的存在:

代码语言:http
复制
GET wechat-multimodal/_search
{
  "query": { "match": { "embedding": "夜景 灯光" } },
  "_source": { "excludes": ["embedding"] }
}

媒体查询用 knn,和写入侧结构对称:

代码语言:http
复制
GET wechat-multimodal/_search
{
  "query": {
    "knn": {
      "field": "embedding",
      "query_vector_builder": {
        "embedding": {
          "input": { "type": "image",
                     "value": "data:image/jpeg;base64,..." }
        }
      },
      "num_candidates": 100,
      "k": 20
    }
  },
  "_source": { "excludes": ["embedding"] }
}

type 换成 video / audio 就是以视频搜、以音频搜。

⚠️ "_source": {"excludes": ["embedding"]} 不是可选。semantic 字段会把你写入的 base64 原样留在 _source 里,不排除的话每次搜索响应会带上几十 MB 数据。

第 5 步:完整可跑的脚本

把上面串起来,这是一个能直接跑的最小版本:

代码语言:python
复制
#!/usr/bin/env python3
"""微信本地媒体 → ES 9.5 semantic 字段。依赖:ffmpeg、Pillow"""
import base64, json, subprocess, tempfile, urllib.request, urllib.error
from datetime import datetime, timezone
from pathlib import Path

ES     = "https://your-cluster.es.cloud.es.io"
APIKEY = "your-api-key"
INDEX  = "wechat-multimodal"
IID    = ".jina-embeddings-v5-omni-small"

MAX_BYTES = 900 * 1024   # 上限 1MB,留余量
MAX_SECS  = 110          # 上限 120s,留余量 —— 见下文"坑 2"

WECHAT = Path.home() / ("Library/Containers/com.tencent.xinWeChat/Data/Documents"
                        "/xwechat_files/wxid_xxxxx")


def es(method, path, body=None, timeout=120):
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(
        f"{ES}{path}", data=data, method=method,
        headers={"Authorization": f"ApiKey {APIKEY}",
                 "Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return json.load(r)


def duration(p):
    out = subprocess.run(["ffprobe", "-v", "error", "-show_entries",
                          "format=duration", "-of", "csv=p=0", str(p)],
                         capture_output=True, text=True).stdout.strip()
    return float(out or 0)


def shrink_image(p):
    from PIL import Image
    import io
    img = Image.open(p).convert("RGB")
    q, scale = 85, 1.0
    while True:
        buf = io.BytesIO()
        im = img.resize((int(img.width*scale), int(img.height*scale)),
                        Image.Resampling.LANCZOS) if scale < 1 else img
        im.save(buf, "JPEG", quality=q)
        if buf.tell() <= MAX_BYTES:
            return buf.getvalue()
        if q > 40: q -= 15
        else: scale *= 0.75


def shrink_av(p, kind):
    """视频/音频:先裁时长,再压体积。时长必须回读校验。"""
    suffix = ".mp4" if kind == "video" else ".mp3"
    ladder = ([(320, 40), (240, 45), (192, 48)] if kind == "video"
              else [(0, 32), (0, 24)])
    for a, b in ladder:
        out = tempfile.mktemp(suffix=suffix)
        cmd = ["ffmpeg", "-y", "-i", str(p), "-t", str(MAX_SECS)]
        cmd += (["-vf", f"scale='min({a},iw)':-2", "-crf", str(b),
                 "-preset", "fast", "-an"] if kind == "video"
                else ["-ar", "16000", "-ac", "1", "-b:a", f"{b}k"])
        subprocess.run(cmd + [out], capture_output=True, check=True)
        data, secs = Path(out).read_bytes(), duration(out)
        Path(out).unlink(missing_ok=True)
        if len(data) <= MAX_BYTES and secs < 120:
            return data
    return None


MIME = {"image": "image/jpeg", "video": "video/mp4", "audio": "audio/mpeg"}


def index_file(p: Path, kind: str):
    raw = p.read_bytes()
    if kind == "image":
        data = raw if len(raw) <= MAX_BYTES else shrink_image(p)
    else:
        data = (raw if len(raw) <= MAX_BYTES and duration(p) < 120
                else shrink_av(p, kind))
    if data is None:
        return f"跳过 {p.name}(压不到限制以内)"

    b64 = base64.b64encode(data).decode()
    doc = {
        "embedding":  {"type": kind,
                       "value": f"data:{MIME[kind]};base64,{b64}"},
        "file_path":  str(p),
        "file_name":  p.name,
        "media_type": kind,
        "indexed_at": datetime.now(timezone.utc).isoformat(),
    }
    try:
        es("PUT", f"/{INDEX}/_doc/{p.stem[:64]}", doc)
        return f"OK  {p.name}"
    except urllib.error.HTTPError as e:
        # 关键:必须读 body,否则只能看到 "HTTP Error 400"
        try:
            reason = json.loads(e.read())["error"]["root_cause"][0]["reason"]
        except Exception:
            reason = str(e)
        return f"ERR {p.name}: {reason}"


def main():
    jobs = []
    for f in sorted((WECHAT / "temp").rglob("*")):
        if f.suffix.lower() in (".jpg", ".jpeg", ".png"):
            jobs.append((f, "image"))
    for f in sorted((WECHAT / "msg/video").rglob("*.mp4")):
        jobs.append((f, "video"))
    for f in sorted((WECHAT / "msg/file").rglob("*")):
        if f.suffix.lower() in (".mp3", ".m4a", ".wav", ".aac"):
            jobs.append((f, "audio"))

    print(f"共 {len(jobs)} 个文件")
    for i, (f, kind) in enumerate(jobs, 1):
        print(f"[{i}/{len(jobs)}] {index_file(f, kind)}")


if __name__ == "__main__":
    main()

搜索侧更短:

代码语言:python
复制
def search_text(q, size=20):
    return es("POST", f"/{INDEX}/_search", {
        "query": {"match": {"embedding": q}},
        "_source": {"excludes": ["embedding"]},
        "size": size})


def search_media(kind, data_uri, size=20):
    return es("POST", f"/{INDEX}/_search", {
        "query": {"knn": {
            "field": "embedding",
            "query_vector_builder": {
                "embedding": {"input": {"type": kind, "value": data_uri}}},
            "num_candidates": size * 5, "k": size}},
        "_source": {"excludes": ["embedding"]},
        "size": size})

到这里就能搜了。


四、新手一定会踩的 4 个坑

这几个我都实地踩过,按顺序踩到的概率从高到低。

坑 1:1MB 二进制上限

indices.inference.max_binary_input_size 默认 1MB,超了直接 400。

文档说可以调到 20MB,但实际很可能调不了:Serverless 固定 1MB,Cloud Hosted 上这个设置需要 operator 权限(我试过,被拒),只有完全自管的集群能改。所以老老实实压缩。

坑 2:120 秒硬上限(文档里没写)

视频和音频都有一个 120 秒的时长上限:

代码语言:bash
复制
Video duration 300.0s exceeds 120s cap. Trim the clip and retry.

两个要命的细节:

① 判定的是容器时长元数据,不是帧数或体积。 一个 925 秒的视频,你抽成 92 帧、压到 155KB,照样被拒 —— 因为容器还写着 925 秒。降帧率绕不过去,必须真裁时间轴。

② 别裁到正好 120 秒。 ffmpeg -t 120 实际会输出 120.1 秒(按包边界对齐),120.1 > 120,全部拒绝。我 19 段音频里 11 段就是这么没的。

所以上面脚本里写的是 MAX_SECS = 110,并且裁完用 ffprobe 回读校验,不相信 -t 的名义值。

坑 3:分数不是相似度,别拿 60% 当阈值

cosine 的打分公式是 score = (1 + cosine) / 2,所以:

score

真实 cosine

含义

0.50

0.00

完全无关

0.60

0.20

弱相关

0.70

0.40

中等相关

跨模态检索的分数天生挤在 0.55–0.75 这个窄带。我一开始在界面上按"相似度 ≥ 60%"分组,结果发现图片的分数天花板只有 0.604 —— 阈值恰好把一整个模态全判成了低相关。

建议:别用绝对阈值,也别把 score 当百分比给用户看。 改成排名,或者用相对阈值(score > 0.94 × 最高分)。

坑 4:_inference API 不吃结构化媒体

想拿单个文件的向量做调试?这样是不行的:

代码语言:json
复制
POST _inference/embedding/.jina-embeddings-v5-omni-small
{ "input": [{"type": "video", "value": "data:video/mp4;base64,..."}] }
代码语言:json
复制
{ "error": { "reason": "unknown field [type]" }, "status": 400 }

_inference 只吃字符串。而如果你把 data URI 当字符串传,它会被当成文本编码 —— 不报错,但产出一个毫无意义的向量。这是最坑的一类失败:静默的错误结果。

视频和音频的向量只能通过两条路拿到:semantic 字段的写入路径,或 knnquery_vector_builder。想读回向量,用这个:

代码语言:http
复制
GET wechat-multimodal/_search
{ "query": {"match_all": {}}, "_source": {"exclude_vectors": false} }

(这个参数默认是 true,所以向量默认看不到。用 fields: ["_inference_fields"] 取会直接报错,别被误导成"读不出来"。)


五、必须说清的边界

写在最后,但重要性排第一。

① 这不是纯本地方案。 推理是 Elastic Cloud 上的托管端点做的,媒体文件会以 base64 发到云端。它省掉了自己部署模型的全部成本,代价是数据离开了本机。要全本地,得自己跑模型。

② 拿不到聊天上下文。 加密数据库我们没碰,所以只能定位到文件和它所在的月份目录,不知道是谁在哪个会话里发的。

temp/ 会被微信清理。 我索引的 127 张图,原文件后来全没了 —— 只能靠 ES _source 里留的 base64 副本显示。如果你打算做 _source 排除优化,注意这两件事互斥。

④ 语料质量决定一切。 我那 127 张"图片"里,47% 是 macOS 截图、37% 应用缓存、16% 微信缓存,真实照片 0 张。所以搜「海边风景」返回的全是视频 —— 这不是模型有偏,是索引里真的没有海边的照片。分析召回质量之前,先搞清楚索引里装的是什么。

semantic 字段是 Tech Preview,官方明确说不建议上生产。已知限制里几条会影响架构选型:不支持 aggregations、ES|QL 查不了、不能放在 nested 字段里、只能用在 9.5+ 新建的索引上。


六、下一步

跑通之后值得做的三件事:

  1. 长视频分片。 ES 不会自动切非文本输入(官方明确写了 Chunking does not apply to non-text input)。模型从视频里最多均匀抽 32 帧池化成一个向量,一段 15 分钟的视频等于每 29 秒才取一帧。正解是自己切片 —— semantic 字段接受数组,每个元素各生成一个 embedding,由最佳匹配的那一片给文档打分。
  2. 视频缩略图。 ffmpeg 抽一帧存本地缓存,约 25KB/张。没有这个,搜索结果就是一片一模一样的占位图。
  3. 存储瘦身。 我的索引 140MB 里有 139MB 是 _source 里的 base64(99.3%),真向量只占 0.6%。mapping 层 _source.excludes 能把这块降到 0.2%,代价是失去 reindex 能力。

这三件事的实测数据、以及"压缩便宜截断贵"这类反直觉结论,都在深度篇里:article-v2.md


一句话总结

semantic 字段把多模态检索的工程门槛几乎抹平了 —— 一个字段声明,三种模态,同一个向量空间。 剩下的功夫全在数据上:压缩策略、时长上限、分片、语料质量。而这部分没有字段类型能替你做。


参考

文中所有限制与行为基于 2026 年 8 月实测。Tech Preview 功能可能变化,请以官方文档为准。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 先看效果
  • 这篇文章能给你什么
  • 一、为什么微信的数据搜不到
  • 二、原理:一个字段,三种模态,同一个向量空间
    • semantic 和 semantic_text 别搞混
  • 三、动手:5 步
    • 第 1 步:确认推理端点
    • 第 2 步:建索引
    • 第 3 步:写入
    • 第 4 步:查询
    • 第 5 步:完整可跑的脚本
  • 四、新手一定会踩的 4 个坑
    • 坑 1:1MB 二进制上限
    • 坑 2:120 秒硬上限(文档里没写)
    • 坑 3:分数不是相似度,别拿 60% 当阈值
    • 坑 4:_inference API 不吃结构化媒体
  • 五、必须说清的边界
  • 六、下一步
  • 一句话总结
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档