1. 多模态搜索为什么需要 JINA + Elasticsearch
先说结论:传统 RAG 只能搜文本,图片里的信息在检索阶段就“瞎了”。我最早做知识库检索增强时,用户上传了大量产品手册截图、设计稿标注图、合同扫描件,但检索时只能靠 OCR 抽出来的文字,画面上的颜色、布局、logo 形态这些信息全部丢失。后来换成了基于向量检索的方案,才意识到模态转换本身就会造成信息损耗,与其在预处理阶段拼命补 OCR 和规则,不如直接在嵌入层做多模态对齐。
JINA 在中间扮演的角色是“统一编码器”。它旗下有专门做文本嵌入的 jina-embeddings-v2 系列,也有做图文对齐的 jina-clip 系列,还有做精排的 jina-reranker 系列。把这些模型串起来,配合 Elasticsearch 的向量存储和混合检索能力,就能搭出比较完整的“多模态搜、混合排、增强生成”链路。
这套组合解决的核心问题有三个:
- 图文统一表征:图片和文字被映射到同一个向量空间里,于是“用一张图搜相似图”和“用一段文字搜相关图片”可以共用同一套索引。
- 混合检索相关性:向量检索擅长语义相似,但关键词匹配在代码片段、型号规格、专有名词上依然有不可替代的优势,两者得融合起来。
- RAG 输入质量:检索回来的图文片段经过重排序后,才送进大模型做生成,避免“检索看似相关、实际答非所问”的尴尬。
适合谁来参考这套方案?如果手里已经有 Elasticsearch,正打算给知识库或搜索系统加图片、视频帧、PDF 版面等非纯文本内容的检索能力,或者在做“以图搜图”“图文问答”这类交互功能,这篇文章可以直接作为落地参考。如果完全没接触过 ES 和向量搜索,下面的原理部分也尽量用大白话解释,按步骤操作即可跑通。
2. 整体方案选型:为什么是 JINA 全家桶,而不是各搞各的
2.1 多模态嵌入选型背后的道理
多模态搜索的第一步,是把不同类型的数据塞进同一个向量空间。这看起来简单,其实有几个坎:
第一,截止时间不一致。有些模型只能吃英文,有些模型对中文场景支持差,有些模型对长文档萎靡不振。我之前试过拿纯英文的 CLIP 模型处理中文海报,结果语义完全对不上,“促销活动”能给你编码成“promotion activity”的英文语义,但中文语境里的“大促”“满减”就根本没学到。JINA 在这方面相对省心,它有专门的中文文本嵌入模型,也开源了图文对齐模型,不需要自己折腾两套编码体系再去做对齐。
第二,输入长度限制。很多视觉模型文本端只能吃 77 个 token,这意味着查询词稍微长一点、或者文档里的文字描述稍微完整一点,就会被截断。JINA 文本嵌入系列支持最长 8192 token 的输入,对商品详情页、长条款这类文本友好很多。图片端虽然也会做分块处理,但至少文本端不会成为短板。
第三,训练目标和场景匹配。通用视觉模型更多是给分类、检测用的,特征向量对“语义对比”并不敏感。JINA 的图文模型是专门为检索场景训练的正负样本对比学习,这一点和搜索场景天然匹配。选它,本质上是在选“检索优化过”的编码器,而不是“分类优化过”的编码器。
2.2 Elasticsearch 在检索链路里承担的职责
ES 在这里不只是“存向量的数据库”,它同时承担了四件事:
- 向量存储和 ANN 检索:dense_vector 字段支持 HNSW 算法,能在一亿级数据下做毫秒级近邻搜索。
- 传统关键词检索:BM25 全文检索对短词、专有名词、型号代码的命中精度非常高,而且支持分词器配置。
- 混合检索融合:ES 8.8 之后自带的 rank_feature 和 RRF(Reciprocal Rank Fusion)机制,能把两路甚至多路检索结果合并成一条有序列表。
- 返回排序后的文档内容:检索结果携带原文和元数据,直接交给下游 LLM 生成答案。
选择 ES 而不是单独部署向量数据库,最大好处是不需要在系统里多维护一套存储。知识库原本就在 ES 里,现在只是多建一个索引、多管一个字段,查询链路统一走 ES 的 API,运维心智负担小得多。
2.3 检索、重排、生成的完整链路
整个 RAG 流程按顺序是:
- 文档入库:文本走文本嵌入,图片走图文嵌入,生成向量后连原文、元数据一起写入 ES。
- 查询改写:用户输入可能是文字,也可能是图片,统一编码成查询向量。
- 混合检索:ES 同时执行 BM25 关键词检索和 kNN 向量检索,再用 RRF 把二者排名融合。
- 精排重排:把融合后的 Top K 结果交给 jina-reranker 做一次深度相关性打分,这个模型能同时吃多模态输入。
- 增强生成:把重排后的高相关片段拼进 Prompt,发送给 LLM 生成回答。
这条链路里,ES 负责“快召回”,JINA 负责“准嵌入和准精排”,LLM 负责“组织语言”。每一步都有明确分工,不会把互相矛盾的逻辑搅在一起。
3. 材料准备与环境搭建
动手前先把环境和依赖理清楚。我在生产环境推荐的做法是分两层:离线编码和在线检索分离。
3.1 依赖清单
- Python 3.9+,我用的 3.11。
- Elasticsearch 8.10+ 或更高版本,本地开发可以用官方 Docker 镜像,生产环境至少 2 节点起步。
- JINA 相关能力通过两种方式接入:一种是调用云服务 API(适合快速验证),另一种是基于其公开模型权重本地部署(适合生产环境数据隔离要求高的情况)。两种方式向量维度相同,可无缝切换。
- 大模型生成部分,本地可以用开源模型部署,也可以接托管 API,核心是让最终链路能跑通。
安装 Python 依赖:
pip install elasticsearch sentence-transformers pillow requests这里说明一下为什么用 sentence-transformers 而不是直接调 API。开发阶段反复调 API,费用和限流都是问题。先用它加载 JINA 模型做本地验证,确认向量维度和语义效果没问题后再切换 API 或集群部署,调试效率高一个量级。
3.2 Elasticsearch 索引映射设计
索引设计是整个方案的骨架,映射字段直接决定后续能做什么检索。我建议把索引分为两类字段:
- 裸数据字段:保存原始文本或图片 URL,用于结果展示和 LLM 生成。
- 检索字段:保存向量和用于关键词检索的文本,以及供过滤用的元数据(如标签、时间戳、类型)。
映射示例如下:
mappings = { "properties": { "content_id": {"type": "keyword"}, "content_type": {"type": "keyword"}, "text_content": {"type": "text", "analyzer": "ik_max_word"}, "image_path": {"type": "keyword"}, "text_vector": { "type": "dense_vector", "dims": 768, "index": True, "similarity": "cosine", "index_options": {"type": "hnsw", "m": 16, "ef_construction": 128} }, "image_vector": { "type": "dense_vector", "dims": 768, "index": True, "similarity": "cosine", "index_options": {"type": "hnsw", "m": 16, "ef_construction": 128} }, "modality": {"type": "keyword"}, "timestamp": {"type": "date"} } }两个细节值得注意:
第一,文本和图片向量各占一个字段,这样查询时可以根据输入模态灵活选择比对字段。比如用户上传图片,kNN 就用 image_vector 字段;用户输入文字,kNN 就用 text_vector 字段。当然也可以把两种向量拼成一个(比如拼接成 1536 维),但那样体积和检索耗时会增加,且不利于单独调试。
第二,HNSW 的 m 和 ef_construction 参数。m=16 是召回率和内存的平衡点,ef_construction 控制在 128 足够绝大多数场景。如果数据量特别大(千万级以上),m 可以降到 12,ef_construction 提到 256,具体要靠压测决定。
3.3 数据准备:文本和图片是如何统一成向量的
以商品知识库为例,我准备了三种类型的数据:
- 纯文本商品描述,比如“这款保温杯采用 316 不锈钢内胆,保温时间达 12 小时”。
- 商品主图,JPG/PNG 格式。
- 图文对,比如一张海报加一段活动文案。
入库时,文本描述直接编码成 text_vector;商品主图编码成 image_vector;图文对则可以两个字段都写,让同一条记录同时支持两种查询。
实际编码过程中,最大的坑是图片预处理的尺寸。我最初直接拿原图喂给模型,结果发现模型内部会把图片缩放,但有的原图带有 EXIF 旋转信息,编码后语义会发生偏移。后来固定成先转 RGB、再 Resize 到模型输入尺寸、最后归一化,一套标准流程,问题就消失了。
4. 核心代码实现:从入库到检索的完整实操
4.1 文本和图片统一编码
先定义统一的编码工具类,同时处理文本和图片:
from sentence_transformers import SentenceTransformer from PIL import Image import numpy as np class JinaEncoder: def __init__(self): # 文本嵌入模型,中文场景强推 self.text_model = SentenceTransformer("jinaai/jina-embeddings-v2-base-zh") # 图文统一模型 self.clip_model = SentenceTransformer("jinaai/jina-clip-v1-base") # 这两个模型向量维度均为 768 def encode_text(self, documents): # 文本向量走带指令前缀的通道,提升检索效果 texts = [f"search_query: {doc}" for doc in documents] vecs = self.text_model.encode(texts, normalize_embeddings=True) return vecs def encode_image(self, image_paths): vecs = [] for img_path in image_paths: img = Image.open(img_path).convert("RGB") # 模型内部有预处理,这里确保格式统一即可 vec = self.clip_model.encode(img, normalize_embeddings=True) vecs.append(vec) return np.array(vecs)这里为什么文本模型和图文模型各用一次?因为 jina-clip 的文本编码器虽然能和图片对齐,但纯文本语义检索的精度不如专门的文本嵌入模型。实际测试中,用文本模型编码长描述再检索,命中率更高;用图文模型编码短查询和图片比对,跨模态检索效果更好。两种模型并存,互相补位。
4.2 批量写入 Elasticsearch
向量生成后写入 ES。为了优雅处理大批量数据,我封装了一个批量写入函数:
from elasticsearch import Elasticsearch, helpers class VectorIndexer: def __init__(self, es_client, index_name, encoder): self.es = es_client self.index_name = index_name self.encoder = encoder def bulk_insert(self, items): # items 是字典列表,每个元素包含 text、image_path、metadata 等字段 for batch_start in range(0, len(items), 100): batch = items[batch_start:batch_start+100] text_vecs = None image_vecs = None text_docs = [None] * len(batch) image_docs = [None] * len(batch) for i, item in enumerate(batch): if item.get("text"): text_docs[i] = item["text"] if item.get("image_path"): image_docs[i] = item["image_path"] if any(text_docs): text_vecs = self.encoder.encode_text([d for d in text_docs if d]) if any(image_docs): image_vecs = self.encoder.encode_image([d for d in image_docs if d]) actions = [] text_idx = 0 image_idx = 0 for i, item in enumerate(batch): doc = { "content_id": item["id"], "content_type": item["type"], "text_content": item.get("text", ""), "image_path": item.get("image_path", ""), "modality": item.get("modality", "text"), "timestamp": item.get("timestamp", None) } if item.get("text"): doc["text_vector"] = text_vecs[text_idx].tolist() text_idx += 1 if item.get("image_path"): doc["image_vector"] = image_vecs[image_idx].tolist() image_idx += 1 actions.append({ "_op_type": "index", "_index": self.index_name, "_source": doc }) helpers.bulk(self.es, actions)批量大小选 100,是 ES 单次 bulk 性能和内存消耗的平衡点。如果批次太大,客户端内存容易爆;太小,网络往返次数多,写入吞吐上不去。
另外一个容易被忽略的点:写入前要确保索引 mapping 已经建好。如果首次写入时字段类型不对,ES 会自动映射成别的类型,之后想改 mapping 只能重建索引,非常麻烦。项目里我习惯先创建索引再写数据,宁可多一步初始化,也不要让自动映射留坑。
4.3 混合检索:BM25 与 kNN 的融合策略
检索阶段是整个方案的灵魂。单用向量检索时,遇到“ABC-12345”这种型号号码基本完蛋,向量空间里它和“ABC-12344”距离很近,但 BM25 可以精确命中字符序列。反过来,用户写“保温效果好的杯子”,BM25 只能按字面匹配,向量却能理解语义。所以必须两路并行。
ES 8.8+ 支持在一个 search 请求里同时跑 kNN 和 Query,然后让 ES 用 RRF 机制合并结果:
def hybrid_search(self, query_text=None, query_image_path=None, top_k=10): knn_query = None # 根据输入模态生成对应向量 if query_text: text_vec = self.encoder.encode_text([query_text])[0].tolist() knn_query = {"field": "text_vector", "query_vector": text_vec, "k": top_k * 2, "num_candidates": 100} elif query_image_path: image_vec = self.encoder.encode_image([query_image_path])[0].tolist() knn_query = {"field": "image_vector", "query_vector": image_vec, "k": top_k * 2, "num_candidates": 100} # 同时执行关键词检索 bm25_query = {"match": {"text_content": query_text}} if query_text else {"match_all": {}} body = { "knn": knn_query, "query": bm25_query, "rank": {"rrf": {"rank_constant": 60, "window_size": top_k * 2}}, "size": top_k } resp = self.es.search(index=self.index_name, body=body) return resp["hits"]["hits"]RRF 的参数值得解释一下。rank_constant 默认 60,代表每条结果对最终分数的贡献;它越大,两路结果的排名越平滑,不会出现某一路排名靠前的文档霸榜。调小到 20 左右,可以让“某一路的第一名”更突出。实际使用中,我不建议频繁调它,先默认跑,观察结果的多样性再微调。
num_candidates 设成 100,意思是 kNN 阶段先粗召回 100 个候选项,再从中精排出 Top 20 进入 RRF。设太小会降低召回率,设太大查询变慢。100 对百万级数据是稳妥的选择。
4.4 重排序:让相关性判断再精细一圈
混合检索给出的融合排名,本质上是“两路粗排信号的妥协结果”,并不意味着每个结果都真的和查询强相关。这时候 jina-reranker 就该上场了。
from sentence_transformers import CrossEncoder class Reranker: def __init__(self): self.model = CrossEncoder("jinaai/jina-reranker-v1-base-en") # 注意:如果数据是中文为主,建议换成 jina-reranker-v1-base-zh 或带 zh 的版本 def rerank(self, query, candidates, top_k=5): # candidates 是 ES 返回的文档列表 pairs = [] for doc in candidates: content = doc["_source"].get("text_content") or doc["_source"].get("image_path") or "" pairs.append([query, content]) scores = self.model.predict(pairs) scored_docs = list(zip(candidates, scores)) scored_docs.sort(key=lambda x: x[1], reverse=True) return scored_docs[:top_k]重排序模型本质上是一个 CrossEncoder,它会把查询和文档拼在一起做深度编码,所以能捕捉到语义交互信号,效果远比向量余弦相似度精细。代价是计算量大,只能对 Top 20 ~ 50 的结果做,所以重排序必须排在粗召回之后。
这里有一个我踩过的坑:如果索引里同时有文本和图片,重排序时把图片路径传给文本 CrossEncoder,得分会非常离谱。正确做法是图文对场景换用支持多模态输入的 JINA 重排序模型,或者至少把图片对应的 OCR 文本和文字描述拼进 content 再送进文本重排序。如果纯图片且无文本,就直接跳过重排序,以 kNN 结果为准。
4.5 RAG 生成:把检索结果传给 LLM
重排序后的高相关结果,最终拼进 Prompt:
def build_rag_prompt(query, docs): context_parts = [] for rank, (doc, score) in enumerate(docs, 1): source = doc["_source"] content = source.get("text_content") if not content and source.get("image_path"): content = f"[图片]: {source['image_path']}" context_parts.append(f"[{rank}] {content}") context = "\n".join(context_parts) return f"""基于以下检索内容回答问题。如果内容不相关,请明确说明“未检索到足够信息”。 检索内容: {context} 问题:{query} 回答:"""Prompt 里我故意加了一句“如果内容不相关,请明确说明”,这是避免 LLM 硬编答案的有效手段。RAG 出错最大的来源,不是检索不到,而是检索到了低相关内容,LLM 还煞有介事地编造。加了这句话之后,LLM 会更严格地审视上下文,实测幻觉率明显下降。
生成的调用这里不做展示,各家模型 API 细节不同,核心是把上面的 Prompt 作为一个用户消息传进去,温度参数设置在 0.2 以下,避免创造性发挥偏离检索事实。
5. 常见问题与排查实录
5.1 向量维度不一致导致写入报错
JINA 系列模型维度有 512、768、1024 等多种,如果文本模型和图文模型混用却在同一个 dense_vector 字段里写入,ES 会直接抛 mapping 异常。
排查方法:先看模型输出的 shape,再确认索引 mapping 里的 dims 是否匹配。我出现过一次多模态模型"jina-clip-v1"和文本模型"jina-embeddings-v2-base-zh"其实都是 768 维、但另一个英文模型是 512 维的情况,混着用才暴露问题。建议所有字段统一用一个主模型的维度,其他模型输出做完投影或拼接再入库。
5.2 混合检索结果里关键词匹配完全消失
ES 8.8 之前的版本不支持 kNN 和 Query 在同一个请求里直接 RRF。如果升级版本不方便,就分开跑两路:
- 跑一次 kNN 查询,拿到 Top 50 文档 id 列表。
- 跑一次 BM25 查询,拿到 Top 50 文档 id 列表。
- 自己在应用层用 RRF 公式合并。
RRF 合并公式很简单:
score(d) = sum( 1 / (k + rank_i(d)) )k 就是上面的 rank_constant,默认 60。rank_i 是该文档在第 i 路结果里的名次。应用层实现大约二十行代码,不需要依赖 ES 版本。
5.3 中文检索效果不如英文
多模态检索场景里,中文文本的向量检索效果差有时不是模型问题,而是分词和查询长度的问题。BM25 这条路上,ES 如果用了标准分词器,中文会按单字切开,BM25 命中率会非常难看。
建议给 text_content 字段配置中文分词器(比如 ik_max_word 或 smartcn),并在写入时保证文本不做截断。向量检索这边,JINA 中文模型本身效果不错,但查询词太短(少于 4 个字)时容易语义漂移,可以先用查询改写模型扩写一下,或者把查询和查询所属的类目 Tag 拼起来再编码。
5.4 图片检索的结果不稳定
图片检索最典型的毛病是“同一张图检索出来的结果每次不一样”。原因通常是图片没有固定预处理。我建了个标准流程:读取图片 → 转 RGB → Resize 到 224x224(或模型要求的尺寸)→ 转 Tensor → 归一化。这样无论原图是 4K 大图还是手机截图,编码输入都一样,结果自然稳定。
另一个坑是图片字段只在部分文档里有值、而查询时用 image_vector 字段做 kNN,没有该字段的文档会自动被排除。如果希望没有图片的文档也能被搜到,需要在写入时给没有图片的文档补一个“空图向量”(全 0 或随机向量),但这会引入噪声。实践中我更推荐维护两个索引:图片索引