不做 OCR,不做语音转写,用一个字段声明让图片、视频、音频都能用自然语言搜。
看完能自己跑起来一套东西:指着一堆哈希命名的媒体文件,用中文描述就能搜出来。
📸 截图位 1:搜索「夜景灯光」的结果网格 —— 缩略图全是夜景视频
macOS 上微信的本地目录长这样:
~/Library/Containers/com.tencent.xinWeChat/Data/Documents/
xwechat_files/wxid_xxxxx/
├── temp/ # 截图、应用缓存图片
├── msg/video/ # 视频,按月份分目录
├── msg/file/ # 音频、文档
└── msg/attach/ # .dat 加密容器三个问题叠在一起:
24fa235d9976ad90567cabbf81f8881b.mp4、b635b531-0f34-4414.png,原始文件名早就丢了。顺带说一个坏消息:msg/attach/ 下的 .dat 是微信私有加密容器(文件头 07 08 56 32),聊天数据库本身也是加密的。这两块我们不碰,所以这套方案拿不到聊天上下文 —— 只能定位到文件。

Finder 里
msg/video/2026-05/的哈希文件名列表
传统做法是拼三条流水线:图片走 OCR,语音走 ASR,视频抽帧走图像模型,最后把产出的文本丢进全文检索。这套方案里这三条都没有。
Elasticsearch 9.5 新增了一个 semantic 字段类型。你在 mapping 里声明它、绑定一个多模态推理端点,剩下的事它自己干:写入文档时自动把媒体编码成向量,查询时自动把查询编码成向量。
微信本地文件 ──压缩/裁剪──▶ PUT _doc ──▶ semantic 字段自动向量化
│
图片 ─┐ ▼
视频 ─┼──▶ 同一个模型 ──▶ 同一个 1024 维空间 ◀── 查询也编码进这里
音频 ─┘ │
▼
最近邻 = 搜索结果关键在最后一步:三种模态被同一个模型编码进同一个空间。所以文字能命中视频,拖一张图也能召回视频 —— 跨模态检索不需要额外代码,它是这个设计的自然结果。

架构动画图(三簇向量汇入同一空间)
semantic 和 semantic_text 别搞混
|
| |
|---|---|---|
task type |
|
|
吃什么 | 只吃文本 | 文本 + 图片 / 视频 / 音频 / PDF |
默认端点 | 有 | 没有,必须指定 |
两者是并列关系,不是替代。做多模态用 semantic。
ES 9.5 内置了 .jina-embeddings-v5-omni-small,不用自己创建:
GET _inference/embedding/.jina-embeddings-v5-omni-small核心就是把一个字段声明成 semantic:
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 会把模型参数补进去 —— 这是确认端点真挂上了最快的办法:
"model_settings": {
"task_type": "embedding",
"dimensions": 1024,
"similarity": "cosine",
"element_type": "float"
}三种模态同一个形状,只有 type 在变:
# 图片
{"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,不用手动调推理接口。
文字查询就是一条普通的 match —— 这是整套方案里最舒服的一点,前端代码完全不用感知向量的存在:
GET wechat-multimodal/_search
{
"query": { "match": { "embedding": "夜景 灯光" } },
"_source": { "excludes": ["embedding"] }
}媒体查询用 knn,和写入侧结构对称:
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 数据。
把上面串起来,这是一个能直接跑的最小版本:
#!/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()搜索侧更短:
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})到这里就能搜了。
这几个我都实地踩过,按顺序踩到的概率从高到低。
indices.inference.max_binary_input_size 默认 1MB,超了直接 400。
文档说可以调到 20MB,但实际很可能调不了:Serverless 固定 1MB,Cloud Hosted 上这个设置需要 operator 权限(我试过,被拒),只有完全自管的集群能改。所以老老实实压缩。
视频和音频都有一个 120 秒的时长上限:
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 的名义值。
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 × 最高分)。
_inference API 不吃结构化媒体想拿单个文件的向量做调试?这样是不行的:
POST _inference/embedding/.jina-embeddings-v5-omni-small
{ "input": [{"type": "video", "value": "data:video/mp4;base64,..."}] }{ "error": { "reason": "unknown field [type]" }, "status": 400 }_inference 只吃字符串。而如果你把 data URI 当字符串传,它会被当成文本编码 —— 不报错,但产出一个毫无意义的向量。这是最坑的一类失败:静默的错误结果。
视频和音频的向量只能通过两条路拿到:semantic 字段的写入路径,或 knn 的 query_vector_builder。想读回向量,用这个:
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+ 新建的索引上。
跑通之后值得做的三件事:
Chunking does not apply to non-text input)。模型从视频里最多均匀抽 32 帧池化成一个向量,一段 15 分钟的视频等于每 29 秒才取一帧。正解是自己切片 —— semantic 字段接受数组,每个元素各生成一个 embedding,由最佳匹配的那一片给文档打分。ffmpeg 抽一帧存本地缓存,约 25KB/张。没有这个,搜索结果就是一片一模一样的占位图。_source 里的 base64(99.3%),真向量只占 0.6%。mapping 层 _source.excludes 能把这块降到 0.2%,代价是失去 reindex 能力。这三件事的实测数据、以及"压缩便宜截断贵"这类反直觉结论,都在深度篇里:article-v2.md。
semantic字段把多模态检索的工程门槛几乎抹平了 —— 一个字段声明,三种模态,同一个向量空间。 剩下的功夫全在数据上:压缩策略、时长上限、分片、语料质量。而这部分没有字段类型能替你做。
参考
文中所有限制与行为基于 2026 年 8 月实测。Tech Preview 功能可能变化,请以官方文档为准。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。