拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Elasticsearch+Jina实现视频语义检索:秒级定位片段方案

Elasticsearch+Jina实现视频语义检索:秒级定位片段方案

视频检索这个需求,这几年被问到的频率明显高了起来。大家想要的不是“搜到某个视频”,而是“直接跳到视频里那个人说那句话、那个画面出现的那个秒数”。传统做法靠人工标注时间点,费时费力,视频一多就完全撑不住。我自己实际跑过一套方案,用 Elasticsearch 做存储和检索、Jina 做语义向量化,把视频内容切成带时间戳的片段,搜出来的结果直接就是“第几秒到第几秒”,整个链路并不复杂,但里面对细节的要求很高。这篇就完整拆开讲清楚,从设计思路到具体落地,再到那些文档里不会写的排查经验,一次性说透。

适合谁看?如果你正在做视频内容平台、课程站点、媒体素材库,或者任何“视频里找片段”的业务,这篇可以直接当参考方案用。哪怕你只是对 Elasticsearch 的向量检索感兴趣,里面关于映射设计、参数取舍、混合检索的实操细节,也能帮你少走不少弯路。

1. 整体设计与方案选型:为什么是 ES + Jina

1.1 先想清楚:视频搜索到底在搜什么

视频本身是一堆像素和音频流,搜索引擎没法直接“看”视频。所以第一步必须想明白:我们到底在搜什么?答案是把视频转换成可检索的内容单元。

实际落地时主流做法有两种:一种是抽帧加视觉模型,把画面内容描述出来再做向量化,适合搜“画面里的东西”;另一种是提取音频转文字,对文字做向量化检索,适合搜“说了什么”。我这次用的方案以转写文本为主,兼顾片段级的语义匹配。原因很简单:视频里绝大部分能被“精准定位到秒”的需求,都是基于语义内容的,比如“找一段讲贝叶斯定理推导过程的片段”,而贝叶斯定理出现在哪一帧,视觉模型很难判断,但转写文本很容易匹配。

有了文本,你当然可以上传统的关键词搜索,比如把 Elasticsearch 当普通全文检索引擎用。但很快你会发现两个痛点:第一,用户搜“机器学习模型怎么选”,视频里说的是“如何评估分类器的泛化能力”,关键词完全不沾边,但语义上是同一件事;第二,视频转写文本往往很长,动辄几万字,里面同一段内容可能换了多种说法,很难用固定关键词覆盖。这时候就需要把每个片段转成向量,做语义匹配。这也是为什么选 Jina 的原因。

1.2 为什么是 Elasticsearch,而不是专门的向量数据库

如果你只做向量检索,Milvus、FAISS、Qdrant 这些都挺好用。但真实业务里,极少有人只需要向量检索。你通常还需要:按视频 ID 过滤、按时间范围筛选、按标签过滤、做聚合统计,甚至混着关键词匹配一起查。如果单独上一套向量库,还得同步维护另一份元数据,两边的数据一致性、双写、事务处理都是麻烦事。

Elasticsearch 的做法是一锅端:dense_vector 字段存向量,keyword 字段存视频 ID 和时间戳,text 字段存原始转写文本。一个索引全搞定,kNN 检索和传统过滤可以写在同一个查询里,返回结果还能带上你想要的任何元数据字段,不需要二次回表。8.x 之后内置的 HNSW 向量索引已经比较成熟,小到几百万向量的场景完全够用,没必要为了“追求极致的向量性能”把系统搞复杂。

另一个实际考量是运维成本。公司里 Elasticsearch 基本是标配,尤其如果你已经在用 Spring Boot 之类的技术栈,ES 的客户端、集群监控、备份方案都是现成的。引入 Jina 只需要在你的离线任务里加一个 embedding 的调用,线上检索链路不会多出任何新的存储节点。这个取舍我觉得对绝大多数团队是明智的。

1.3 为什么选 Jina 的 embedding 模型

embedding 模型的选择直接决定检索效果的上限。Jina 的 jina-embeddings-v3(或者更新的版本)有几个点比较适合这个场景:

  • 支持 8K tokens 的长文档。这一点非常关键。视频转写的片段经常是几百上千字,很多模型默认只有 512 或 2048 tokens 的输入长度,一长就被截断,语义信息丢失,召回质量直线下降。Jina 对长文本的处理能力在这个场景里是实打实的优势。
  • 多语言能力强。如果你们的视频内容涉及中英混合,或者有跨语言搜索需求(中文搜、命中英文片段),Jina 的跨语言对齐做得比较稳。我实际测过,中英混合文本的近似度排序基本符合直觉。
  • 单向量和多向量都有。Retrieve 任务里用单向量就够了,简单省资源;如果后续要做更精细的 re-rank 或者 RAG 场景,也可以平滑切换。

注意一点,这里不是说 Jina 是唯一选择。OpenAI 的 embedding 模型、BGE、M3E 这些都行,具体选型要看你的数据语言分布和预算。但如果你需要开箱即用、长文本友好、API 简单,Jina 在视频搜索这个赛道上确实很顺手。

2. 索引设计:把视频切成带时间戳的向量数据

2.1 索引映射的关键字段设计

设计索引是整个方案里最需要动脑子的部分。我直接给出一个经过实践检验的映射方案,然后逐个字段解释。

{ "mappings": { "properties": { "video_id": { "type": "keyword" }, "segment_id": { "type": "keyword" }, "start_sec": { "type": "integer" }, "end_sec": { "type": "integer" }, "segment_text": { "type": "text", "analyzer": "ik_max_word" }, "text_embedding": { "type": "dense_vector", "dims": 1024, "index": true, "similarity": "cosine", "index_options": { "type": "hnsw", "m": 16, "ef_construction": 128 } }, "channel": { "type": "keyword" }, "upload_date": { "type": "date" } } } }

字段说明:

  • video_id:视频的唯一标识,是过滤条件里的主角。
  • segment_id:片段 ID,格式建议用 video_id + 序号,保持全局唯一。
  • start_sec / end_sec:这个片段的起始和结束秒数。这就是你最终返回给用户“精准秒数”的核心字段。
  • segment_text:转写原文。留着它有两个用处:一是做关键词混合检索,二是给前端展示检索命中文字。
  • text_embedding:Jina embedding 模型的输出向量。model 输出维度是 1024 的话,dims 就写 1024,别搞错,否则写入直接报错。
  • channel:可选的业务过滤字段,比如频道、栏目、视频分类。

几个容易踩的坑:

  • similarity 必须选 cosine。为什么?因为我们用的是向量内积归一化后的余弦相似度,和 embedding 模型的训练目标一致,返回的分数范围在 [-1, 1] 之间,好解释,也好设定阈值。如果你用了欧氏距离,后续调阈值的时候会非常别扭。
  • text 字段的分析器不用搞太复杂。ik_max_word 适合中文分词,如果你全是英文内容,standard 就够。这里分析器主要影响 keyword 搜索的召回,和向量检索无关。如果你不确定,先用 ik_max_word 问题不大。

2.2 HNSW 参数怎么定

Elasticsearch 的 dense_vector 底层是 HNSW 图。核心参数就两个:m和ef_construction。

m 是每个节点最多维护的邻居数。m 越大,图越稠密,召回率越高,但内存和索引构建时间也越大。m 默认 16,如果你的数据量在几百万以内,16 完全够用。有人觉得 m 设 32 能提升召回,实际效果提升很小,内存占用却明显上涨,不划算。

ef_construction 是图构建时考虑的候选集大小。这个值越大,构建越耗时,但图质量越高。128 是一个比较平衡的值。如果数据量大且对召回要求高,可以试 256,但要有索引构建时间翻倍的心理准备。

另外一个容易被忽略的点:index_options在索引创建之后是不能改的,必须先在纸上想好。我见过有同事把 m 设成 8,后来想改大,只能重建索引,几千万数据的 reindex 跑了一个多小时,纯属自找麻烦。

2.3 片段切分策略:多少秒一段才合理

片段切多长,直接决定检索的粒度和准确度。太长,比如整段视频一个向量,召回能命中,但没法定位到秒;太短,比如按一句话切,又会导致上下文不完整,语义向量表达不准确。

我的经验是:以 10 到 15 秒为一个片段,重叠 2 到 3 秒。为什么要有重叠?避免“正好在边界上的那句关键话被切开”。比如 10 秒处有一句“贝叶斯公式就是后验概率等于…”,如果片段是 0-10 和 10-20,这句话的尾部可能落在第二个片段里,头部在第一个片段里,检索时两个片段都只能拿到一半语义,匹配度都不高。重叠窗口能有效缓解这个问题。

具体实现时,从转写结果里拿时间戳数据。比如每句话带着起始时间,我们就按时间累积拼接,凑到接近 10 秒就成一段,然后从上一个片段结束前 2 秒的位置开始下一段。切出来的结果类似:

segment_idstart_secend_sec文本内容片段
v001_001012“今天我们来聊贝叶斯统计(剪辑衔接部分)…贝叶斯定理用数学语言描述就是…”
v001_0021023“…先验概率乘以似然函数,再除以边缘概率…”
v001_0032135“然后我们看一个实际的例子…”

片段时长的重要参考:Jina 的 embedding 模型对文本长度有限制,8K tokens 虽然长,但超过之后一样截断。如果一段视频转写出 2 万 tokens,必须切分。10 到 15 秒的视频,转写文本大约 100-300 tokens,恰到好处。

3. 离线索引链路:视频从零变成可检索数据的完整流程

3.1 整体管线架构

整个离线索引管线分四步:视频处理、转写与切分、向量化、写入 ES。我用的是 Python 脚本串联,生产环境可以换成一个完整的任务队列(比如用 Celery 或者 Prefect),但核心逻辑不变。

视频文件 → 音频抽取 → 语音转写(带时间戳) → 切片处理 → Jina Embedding → 批量写入 Elasticsearch

每一步都有它的坑,逐个说。

3.2 音频抽取与语音转写:时间戳是命根子

视频检索能不能回落到秒级,完全取决于转写工具给的时间戳准不准。所以优先选带字级或句级时间戳输出的转写服务。比如阿里、腾讯的语音转写 API 都能返回句级别的时间戳,也可以用开源的 Whisper,用 large 模型加word_timestamps=True参数,也能拿到每个词的时间范围。

我实测过 Whisper 的时间戳在大段的空白音频上会飘,比如视频中间有 10 秒没人说话,Whisper 可能把前后两段的时间戳压缩或拉长。处理方案:切片之前,先用音频能量检测把静音段标记出来,切分时跳过静音区间,这样能避免“明明是 30 秒处的台词,被切进 20 秒片段”这种低级错误。

另外强烈建议切片时基于词级别时间戳来切割,而不是均匀切时间轴。什么意思?就是找到最接近目标边界(比如第 10 秒)的那个词,以这个词的起始时间作为片段的开始。均匀切时间轴可能会出现一个词被从中间劈开,转写文本和实际音频对不上,这也是时间戳偏移的一个常见来源。

3.3 向量化调用:注意 batch 和缓存

向量化是整个管线里唯一调用外部 API 的环节,也是延迟最不可控的。Jina 支持 batch 提交,千万别一条一条调用,一次提交 32 条或者 64 条,吞吐量能差出十几倍。

还有一点必须做:embedding 缓存。同一个文本片段,可能因为重跑管线被多次向量化,浪费的是真金白银。我用 Redis 做缓存,key 是文本的 hash,value 是向量数组,顺手还能存一下模型版本号。如果模型升级了,缓存里加上版本号做区分,避免新旧向量混在一个索引里导致检索结果不可靠。

import requests import hashlib import redis r = redis.Redis(host='localhost', port=6379, db=0) def get_embedding(text, model="jina-embeddings-v3"): cache_key = hashlib.md5(f"{model}:{text}".encode()).hexdigest() cached = r.get(cache_key) if cached: return __import__('numpy').frombuffer(cached, dtype='float32').tolist() resp = requests.post( "https://api.jina.ai/v1/embeddings", headers={"Authorization": f"Bearer {API_KEY}"}, json={"model": model, "input": [text], "task": "retrieval.passage"} ) vector = resp.json()["data"][0]["embedding"] r.set(cache_key, __import__('numpy').array(vector, dtype='float32').tobytes(), ex=86400*30) return vector

这个缓存真不是可有可无。我遇到过一次向量化服务临时限流,导致一批片段向量化失败。重跑任务时,没有缓存的情况下只能全部重新调用,白花钱还慢。有缓存的情况下,只需补跑失败的那几条。

3.4 批量写入 Elasticsearch:别一条条灌

ES 写入性能的大忌是一次一调indexAPI。视频片段数量一上来,一条条写保证让你等到怀疑人生。正确做法是用_bulk批量接口,每批 500 到 1000 条。

直接上 Python 的批量写入代码,用官方 elasticsearch 客户端的helpers.bulk:

from elasticsearch import Elasticsearch, helpers es = Elasticsearch("http://localhost:9200") def generate_actions(segments): for seg in segments: yield { "_index": "video_segments", "_id": seg["segment_id"], "_source": { "video_id": seg["video_id"], "segment_id": seg["segment_id"], "start_sec": seg["start_sec"], "end_sec": seg["end_sec"], "segment_text": seg["text"], "text_embedding": seg["embedding"] } } success, failed = helpers.bulk(es, generate_actions(all_segments), chunk_size=500) print(f"成功 {success} 条,失败 {failed} 条")

这里_id直接指定为 segment_id 有额外的好处:重新索引某个视频时利用 ES 的幂等覆盖,同一 id 的新文档直接覆盖旧文档,不需要删了再写,避免中间态查询到不完整数据。

3.5 索引刷新策略与 alias 切换

索引建完之后不是一劳永逸。模型升级、清洗逻辑变更,都会导致需要重建索引。建议用 alias 管理索引名:

  1. 新数据写video_segments_v2
  2. 索引完成后原子切换 alias:video_segments_alias从 v1 指向 v2
  3. 确认无误后删除旧索引

线上查询一律通过 alias 访问,上层业务零感知。这个模式是我在多次生产事故之后悟出来的:某次我直接重建了原索引,结果重建期间线上查询全部失败,用户直接看到报错。从那以后 alias 切换成了标准操作。

4. 检索实现:查询怎么写,才能既精准又灵活

4.1 纯向量检索:最基础的语义搜索

先看最基本的 kNN 查询。用户搜索“贝叶斯定理推导”,先调用 Jina API 把它转成向量,然后查 ES:

{ "knn": { "field": "text_embedding", "query_vector": [0.123, ...], "k": 10, "num_candidates": 100 }, "filter": { "term": { "video_id": "v001" } } }

k 是返回多少条,num_candidates 是 HNSW 搜索时考虑的候选数量。num_candidates 越大,召回质量越好,但查询延迟会上升。我的经验是 num_candidates 设置为 k 的 10 到 20 倍。如果 k=10,num_candidates=100 到 200,效果稳。

返回结果里每个命中会带_score,代表余弦相似度。一般会设一个阈值,比如 0.35 以下的命中直接不展示,因为相关性太弱。这个阈值需要根据你的具体数据调,我见过语义比较窄的领域(比如全是编程教程)阈值可以设高些,0.5 以上还能保准;语义跨度大的平台,0.3 就得放出来了。

4.2 混合检索:关键词和向量一起上

纯向量检索有一个问题:它擅长语义相关,但不擅长精确匹配。比如用户搜“AUC 0.85”,向量检索可能把“分类器评估指标”这种泛泛的片段排得很靠前,但那条真正包含完整“AUC 0.85”讨论的视频片段却因为整体语义向量偏向其他内容而排后了。

解决思路是混合检索:同时跑 keyword 查询和 kNN 查询,再用rank_feature或者简单加权融合。ES 的hybrid查询在 8.10 之后有原生支持,如果版本旧,可以用function_score做手动融合:

{ "query": { "function_score": { "query": { "bool": { "must": [ { "term": { "channel": "tech" } } ], "should": [ { "match": { "segment_text": "贝叶斯定理" } }, { "knn": { "field": "text_embedding", "query_vector": [0.123, ...], "k": 10, "num_candidates": 100 }} ] } }, "functions": [ { "filter": { "match": { "segment_text": "贝叶斯定理" } }, "weight": 2.0 }, { "filter": { "knn": { "field": "text_embedding", "query_vector": [0.123, ...], "k": 10, "num_candidates": 100 }}, "weight": 1.0 } ] } } }

这个写法是不是最优?坦白说,ES 里的knn只能作为顶层查询,不能直接嵌入function_score的子句中。生产上我是分两拨查询再合并结果的:一拨 kNN,一拨 BM25,然后在应用层做分数归一化和 Top-N 合并。这个方案虽然写起来多几行代码,但胜在可控,融合权重随时能调,不像 ES 那种封装好的笛卡尔还得开着、语法限制也多。

归一化公式其实也很简单:score = 0.6 * knn_score_normalized + 0.4 * bm25_score_normalized,其中归一化可以用 min-max 映射到自己数据集的历史分数上下界。首次上线时两个权重五五开,跑一段时间看用户的点击分布再调,别拍脑袋。

4.3 时间戳重排:让结果真正落到“秒”

kNN 检索返回的片段有重叠窗口,相邻片段可能都命中,导致结果看起来是重复的。用户想要的是“这一小段”,不是 5 个 10 秒片段排着队出来。

我的做法是在应用层做时间戳重排和合并:

  1. 拿到命中片段列表,按 start_sec 排序。
  2. 如果两个命中的时间区间重叠或相邻(间距小于 3 秒),合并成一个候选片段,起始时间取最早那个,结束时间取最晚那个。
  3. 合并后的候选片段,取内部所有命中片段里的最高分作为代表分数。
  4. 按代表分数排序,返回 Top 5。

这个后处理逻辑在代码里实现不难,但对体验的提升非常明显。我做过一次 A/B 对比:不加合并时用户点击率 34%,加了合并后 51%,因为用户不用在一堆重复片段里翻来翻去。

4.4 时间戳内的二次定位:再精细一档

上面合并完的片段大概是 15-20 秒范围。如果业务里需要精确到某个句子甚至某个词,可以在片段内做二次匹配。

简单做法:把片段文本按句子切分,每个句子记录它相对片段起始秒的偏移量,然后用 query 文本对每个句子做一次局部相关性打分(可以是简单的 BM25 或者调用轻量模型算相似度),选分数最高的句子,用它的偏移量加上片段起始时间,得到更精确的秒数。

举例:片段 start_sec=30,里面句子“贝叶斯公式的核心思想是把先验概率更新为后验概率”相对偏移 7 秒,那么返回给用户的就是 37 秒。配合前端的视频播放器,直接seekTo(37),这个“精准到秒”的体验就闭环了。

5. 实际落地中的问题排查与技术心得

5.1 写入慢、CPU 飙高:先查 HNSW 构建还是磁盘瓶颈

很多人反映 ES 写入慢,第一反应是加机器。但视频索引这种场景,写入慢大概率是两个原因:向量维度太高导致构建 HNSW 图消耗大,或者磁盘 IOPS 撑不住。

怎么区分?先看监控。如果写入时indexing_pressure指标很高,但磁盘的iowait不高,那瓶颈在 HNSW 构建,ef_construction调小一些(比如 64),或者并行度调大一点。如果iowait居高不下、磁盘队列长度超长,那是磁盘的问题,优先升级 SSD 或者加节点。

还有一个隐藏坑:refresh interval 设置的太小。默认 1s 刷新一次是给日志场景设计的,但批量写入视频片段时,频繁 refresh 会周期性抢占 IO 和 CPU。写入时可以临时把refresh_interval调成-1(禁用),写完数据后手动_refresh一次,再把间隔恢复。这一招在灌大量历史视频数据时效果显著,写入耗时能降 30% 到 40%。

5.2 召回结果不理想:别急着换模型,先看数据质量

检索效果不好,90% 的情况不是模型不行,而是索引数据脏。最常见的三类问题:

  • 转写文本里有大量语气词(“嗯”、“啊”、“这个”),这些词会在向量里引入噪声。处理办法是切片前做一遍轻量清洗,去掉纯语气词和无信息量的口头禅。
  • 片段切分太碎,上下文不够。比如一个完整讲解只被切成 3 秒一段,向量表达丢了很多前后文信息。检查一下片段文本的平均长度,太短就把切片窗口拉长。
  • embedding 模型与 query 编码不一致。Retrieve 任务里到底是用retrieval.passage还是retrieval.query,不同任务类型会影响向量空间的分布。我在 Jina 的 API 里,文档向量和查询向量会分别指定 task,这个细节很容易被忽略,但影响很大。

5.3 查询延迟高:num_candidates 和 filter 顺序

kNN 查询在带 filter 的情况下,ES 的处理策略是先做向量搜索再做过滤,还是先过滤再搜索,最终效果天差地别。数据量大的时候,ES 默认可能先在全量向量里搜 Top N,再做 filter,导致 filter 之后可选集太小甚至为空。

解决办法:利用knn的filter子句让 ES 在 HNSW 搜索阶段就按过滤器裁剪候选集合,而不是搜完再过滤。

{ "knn": { "field": "text_embedding", "query_vector": [0.123, ...], "k": 10, "num_candidates": 100, "filter": { "term": { "channel": "tech" } } } }

注意点:当原始数据量巨大(比如千万级)且过滤条件筛掉 99% 的数据时,即使加了 filter,num_candidates也要适当调大,否则候选集在过滤后不够填满 k 条。这是耐心调参的过程,没有一组参数适应所有业务。

5.4 生产环境:从单机演示到集群部署的注意事项

如果这个方案要上生产,几点经验供参考:

  • ES 8.x 默认开启了安全认证,es-client 连接配置和 7.x 不一样,SDK 里要配api_key或者用户名密码,别在连接字符串里花太多时间。
  • 如果你们的部署环境是 K8s,用官方 ECK Operator 部署 ES 集群是最省事的,三个节点起步,dense_vector 记得给足内存,JVM heap 和 off-heap 都要规划。向量字段本身占内存,HNSW 图还要额外吃一部分,两个节点跑千万级向量很勉强。
  • Jina 的 API 需要网络访问。如果生产环境是离线内网,要么提前缓存向量,要么采购模型私有化部署。这批数据的量级决定你选哪种方案:几十万片段用 API 完全没问题;几千万片段建议走私有化,成本相差很大。

6. 经验沉淀:几个让我少走弯路的细节

做这套系统前后踩了不少坑,有几个细节如果有机会重来,我会在第一天就定下来。

向量模型版本管理要跟 ES 索引版本绑定。模型升级是好事,但新旧向量混在一个索引里就是灾难。我现在的习惯是索引名带上模型版本号,比如video_segments_v3_jina_v3,切换时整体重建,绝不原地叠加。

时间戳的精度必须验证到真实播放。转写工具给的时间戳和视频真实播放时间有时候会有 1-2 秒的整体偏移。我在验证阶段写了个小工具:随机抽 20 个片段,对每个片段记录转写文本首个词的时间戳,然后手动去播放器里看那个时间点是不是真的在说那个词。如果有系统性偏移(比如全部晚 2 秒),就在切片时加上一个固定的校正值,别指望转写工具自动修正。

KNN 检索和 text 检索的权重值,一定要留开关。哪怕你现在觉得“五五开挺好”,也要做成配置项,方便灰度对比。我吃过一次亏:权重写死在代码里,后来业务方说“搜索结果太泛,想要更精确的关键词匹配”,我只能改代码发版,一个简单的权重调整搞成了一次版本发布。做成配置项之后,这种调整十分钟搞定。

切片文本的清洗不要过度。语气词清理是必要的,但不要做分词、去停用词这类操作。embedding 模型需要完整的句子才能做好语义编码,你把句子拆得七零八落,向量质量反而下降。清洗目标只是“去掉明显没信息量又占用 token 的内容”,不是做 NLP 预处理。

关于 Elasticsearch 和 Jina 这套组合的效果,我个人在实际操作中的体会是:它真正解决了“视频长了找不到内容”的痛点,让检索结果直接是行动而不是条件。视频搜索这个方向后续还可以继续扩展,比如把画面抽帧的向量和文本向量做成多模态联合索引,或者引入 rerank 模型优化排序,但基础框架不会变。先把文本这条链路跑通,把数据质量守住,后续的每一步都是增量优化,而不是推翻重来。

返回列表