一个真实的失败案例
上个月,团队接手一个 case:给一份 200 页的行业研究报告做问答系统。数据源里的文字被切成 2000 多个 chunk,用 BGE-Large 向量化,pgvector 存好,Rerank 模型也接上了。测试第一轮就翻车。
用户问:“2024 年 Q3 的市场份额分布?”——检索命中的是一段泛泛的总结文字,真正答案在同一页的一张饼图里。用户问:“架构图里的缓存层放在什么位置?”——命中的是"缓存"两个字出现的技术术语列表,架构图本身完全没被索引。
问题不出在检索算法,也不出在 embedding 质量。问题是:一个多模态文档,被当作纯文字文档处理,图表里的信息从索引阶段就消失了。
这是纯文字 RAG 在图文混排文档上的通病。行业研报、财报、技术白皮书、产品手册——这些"高价值密度"文档里,30% 到 60% 的信息藏在图表、示意图、截图、公式图片里。传统 pipeline 用 OCR 抓一点文字回来,但饼图的比例、架构图的拓扑、示意图的逻辑关系,OCR 抓不到。
今天这篇聊多模态 RAG 的三种索引策略、跨模态检索的实现,以及怎么用融合排序把它们拼在一起。文末有一份可直接跑的 Python demo,不依赖任何 API key。
三种索引策略的原理
先把选项摆开。
策略 A:OCR + 文字索引。最传统。对每张图跑 OCR,抓出文字后当作普通 chunk 索引。优点是零改造,pipeline 复用;缺点是无 OCR 内容(图表、示意图、无字照片)直接丢失,图片里的语义结构也保留不了。
策略 B:Caption + 文字索引。对每张图用 VLM(Vision Language Model)生成描述性 caption,再把 caption 当作文本索引。等于让模型先"看图说话",然后按文字去检索。这条路的好处是索引层完全复用现有向量库,工程改动小;风险是 caption 质量直接决定检索上限,模型没说的信息就永远检不到。
策略 C:视觉嵌入 + Late Interaction。直接把图像编码进向量空间,跳过文字这一步。代表工作是 ColPali——把 PDF 页面当作图像输入到视觉语言模型的编码器,输出 patch 级别的向量序列(不是把整张图压成一个向量)。查询侧的文本也输出 token 级向量。检索时用 ColBERT 的 Late Interaction 思想:每个 query token 和图像所有 patch 算相似度取最大,再对所有 query token 求和。
三种策略的对比:
| 维度 | 策略 A(OCR) | 策略 B(Caption) | 策略 C(视觉嵌入) |
|---|---|---|---|
| 索引成本 | 低 | 中(每张图跑一次 VLM) | 高(需要专门模型) |
| 检索质量 | 差(丢信息) | 中(依赖 caption) | 好(端到端) |
| 存储开销 | 小 | 小 | 大(多向量) |
| 部署复杂度 | 低 | 低 | 中高 |
| 适合场景 | 图片少的文档 | 中小规模、图片语义可描述 | 大规模、图表密集 |
实际工程里,我一般把 B 和 C 组合起来用,然后靠融合排序去调。下面看代码。
Caption 策略实现
策略 B 的核心是:对每张图生成一段能被文本 embedding 抓住的描述。真实系统里这段调用 Qwen-VL 或 DeepSeek-VL2,demo 里我用可预测的 mock 函数替代,保证不联网也能跑。
from dataclasses import dataclass, field
from typing import List, Dict, Any, Tuple, Optional
import hashlib, math, random
---------- 数据结构 ----------
@dataclass
class TextBlock:
block_id: str
text: str
page: int
@dataclass
class ImageBlock:
block_id: str
image_ref: str
page: int
kind: str = “figure” # figure / chart / table / photo
@dataclass
class MultimodalDocument:
doc_id: str
title: str
text_blocks: List[TextBlock] = field(default_factory=list)
image_blocks: List[ImageBlock] = field(default_factory=list)
meta: Dict[str, Any] = field(default_factory=dict)
---------- Mock embedding & caption ----------
def _stable_vec(seed_str: str, dim: int = 128) -> List[float]:
“”“基于内容哈希生成稳定单位向量,避免每次运行结果漂移。”“”
seed_int = int(hashlib.sha256(seed_str.encode()).hexdigest(), 16) % (2**32)
rng = random.Random(seed_int)
v = [rng.gauss(0.0, 1.0) for _ in range(dim)]
n = math.sqrt(sum(x*x for x in v)) or 1.0
return [x/n for x in v]
def cosine(a: List[float], b: List[float]) -> float:
return sum(x*y for x, y in zip(a, b)) # 已归一化
def mock_caption(image: ImageBlock) -> str:
“”“真实系统这里调用 VLM;demo 用规则模拟。”“”
hints = {
“chart”: “柱状图展示了各季度收入趋势”,
“table”: “表格列出了模型参数与推理成本”,
“figure”: “架构示意图描述了检索与生成流程”,
“photo”: “现场照片记录了服务器机房”,
}
return f"[{image.kind}] {hints.get(image.kind, ‘未知图像’)},位于第{image.page}页"
def mock_text_embed(text: str, dim: int = 128) -> List[float]:
return _stable_vec(f"TXT::{text}", dim)
---------- Caption 索引器 ----------
class CaptionBasedIndexer:
def __init__(self):
self.entries: List[Tuple[str, str, List[float], ImageBlock]] = []
def add\_document(self, doc: MultimodalDocument) -> None: for img in doc.image\_blocks: cap = mock\_caption(img) vec = mock\_text\_embed(cap) self.entries.append((doc.doc\_id, cap, vec, img)) def search(self, query: str, top\_k: int = 5) -> List[Dict[str, Any]]: qv = mock\_text\_embed(query) scored = [{ "doc\_id": d, "block\_id": img.block\_id, "caption": cap, "score": cosine(qv, v), "source": "caption", } for d, cap, v, img in self.entries] scored.sort(key=lambda x: x["score"], reverse=True) return scored[:top\_k]生产环境里mock_caption会被替换成真实 VLM 调用。这里有个经验:caption 的 prompt 要明确写出你希望被检索到的关键信息类型——比如"请描述图表的横纵轴、主要趋势、极值点"——而不是让模型自由发挥。VLM 默认会写文学化的描述,那种描述对检索是负资产。
视觉嵌入策略实现
策略 C 的关键区别在两点:一是图像被拆成 patch,每个 patch 有独立向量;二是检索用 Late Interaction,不是单向量点积。
def mock_visual_embed(image: ImageBlock, dim: int = 128) -> List[List[float]]:
“”“ColPali思路:一张图输出 N 个 patch 向量。”“”
n_patches = 8
return [_stable_vec(f"IMG::{image.image_ref}::p{i}", dim) for i in range(n_patches)]
def mock_visual_query_embed(query: str, dim: int = 128) -> List[List[float]]:
“”“查询侧同样输出 token 级向量。”“”
tokens = list(query)[:6] or [query]
return [_stable_vec(f"QTOK::{query}::{t}::{i}", dim)
for i, t in enumerate(tokens)]
class VisualEmbeddingIndexer:
def __init__(self):
self.entries: List[Tuple[str, List[List[float]], ImageBlock]] = []
def add\_document(self, doc: MultimodalDocument) -> None: for img in doc.image\_blocks: self.entries.append((doc.doc\_id, mock\_visual\_embed(img), img)) @staticmethod def late\_interaction(q\_tokens: List[List[float]], patches: List[List[float]]) -> float: """ColBERT / ColPali:每个 query token 对所有 patch 取 max sim,再平均。""" if not q\_tokens or not patches: return 0.0 total = 0.0 for qt in q\_tokens: total += max(cosine(qt, p) for p in patches) return total / len(q\_tokens) def search(self, query: str, top\_k: int = 5) -> List[Dict[str, Any]]: q\_tokens = mock\_visual\_query\_embed(query) scored = [{ "doc\_id": d, "block\_id": img.block\_id, "kind": img.kind, "score": self.late\_interaction(q\_tokens, patches), "source": "visual", } for d, patches, img in self.entries] scored.sort(key=lambda x: x["score"], reverse=True) return scored[:top\_k]Late Interaction 的直觉是:query “各季度收入趋势” 里的"季度"“收入”"趋势"这几个 token,会分别在图像的不同 patch 上找到最相关的区域(左侧图例、柱状图区域、横轴时间标签),每个 token 独立打分再融合。相比单向量点积,它保留了细粒度的对齐信息。代价是索引存储从 O(N) 变成 O(N × patch数),检索时的计算量也线性增加。
生产环境用 ColPali 时,索引侧一般会用 PLAID 之类的压缩方案,把 float32 向量量化成 int8 或 int4,把存储成本压回一个可接受的量级。
统一检索接口 + 图搜图
上层需要一个统一入口,屏蔽策略差异,同时支持文搜图和图搜图两个方向:
class MultimodalRetriever:
def __init__(self, cap: CaptionBasedIndexer,
vis: VisualEmbeddingIndexer):
self.cap = cap
self.vis = vis
def add\_document(self, doc: MultimodalDocument) -> None: self.cap.add\_document(doc) self.vis.add\_document(doc) def retrieve(self, text\_query: Optional[str] = None, image\_query: Optional[ImageBlock] = None, top\_k: int = 5) -> Dict[str, List[Dict[str, Any]]]: results: Dict[str, List[Dict[str, Any]]] = {} if text\_query: results["caption"] = self.cap.search(text\_query, top\_k) results["visual"] = self.vis.search(text\_query, top\_k) if image\_query: # 图搜图:把查询图当作一组 patch 向量,与库内 patch Late Interaction q\_patches = mock\_visual\_embed(image\_query) scored = [{ "doc\_id": d, "block\_id": img.block\_id, "kind": img.kind, "score": VisualEmbeddingIndexer.late\_interaction(q\_patches, p), "source": "image2image", } for d, p, img in self.vis.entries] scored.sort(key=lambda x: x["score"], reverse=True) results["image2image"] = scored[:top\_k] return results图搜图的场景比想象中常见:技术支持工单里用户传截图问"这个报错怎么解决",或者设计评审里"找一下和这张图类似的历史设计"。共用一套 patch embedding 就能同时支持这两个方向,不需要另建索引。
融合排序:RRF 算法实现
多路召回之后必须融合。分数直接相加行不通——不同来源的分数分布差异极大(caption 走文本 cosine 是 0-1,visual 的 Late Interaction 平均是 0-1 但均值明显不同),加权和很难调。
Reciprocal Rank Fusion(Cormack, 2009)绕开了这个问题:只看排名,不看分数。一个 item 在某路结果里排第 r 位,贡献分是1 / (k + r),k 是平滑常数,论文推荐 60。多路加起来就是最终分。
class FusionRanker:
def __init__(self, k: int = 60):
self.k = k
def fuse(self, ranked\_lists: Dict[str, List[Dict[str, Any]]], top\_k: int = 5) -> List[Dict[str, Any]]: scores: Dict[str, float] = {} detail: Dict[str, Dict[str, Any]] = {} for source, lst in ranked\_lists.items(): for rank, item in enumerate(lst): key = f"{item['doc\_id']}::{item['block\_id']}" scores[key] = scores.get(key, 0.0) + 1.0 / (self.k + rank + 1) if key not in detail: detail[key] = {\*\*item, "rrf\_sources": [source]} else: detail[key]["rrf\_sources"].append(source) fused = [{\*\*detail[k\_], "rrf\_score": s} for k\_, s in scores.items()] fused.sort(key=lambda x: x["rrf\_score"], reverse=True) return fused[:top\_k]RRF 的工程价值在于:加一路召回不用重新调权重。你想接入 BM25?加进来就是。想再加一路 Rerank?加进来就是。参数只有一个 k,而且不敏感,60 基本一劳永逸。
完整 demo 流程
把上面全部串起来:
def build_demo_corpus() -> List[MultimodalDocument]:
return [
MultimodalDocument(
doc_id=“D1”, title=“Q3财报解读”,
text_blocks=[TextBlock(“D1-t1”, “本季度整体营收环比上升。”, 1)],
image_blocks=[
ImageBlock(“D1-i1”, “d1_p1_chart.png”, 1, “chart”),
ImageBlock(“D1-i2”, “d1_p2_table.png”, 2, “table”),
]),
MultimodalDocument(
doc_id=“D2”, title=“推理系统架构”,
text_blocks=[TextBlock(“D2-t1”, “系统采用分层缓存策略。”, 3)],
image_blocks=[
ImageBlock(“D2-i1”, “d2_p1_arch.png”, 1, “figure”),
ImageBlock(“D2-i2”, “d2_p4_photo.png”, 4, “photo”),
]),
]
def main():
docs = build_demo_corpus()
retriever = MultimodalRetriever(CaptionBasedIndexer(), VisualEmbeddingIndexer())
for d in docs:
retriever.add_document(d)
query = "各季度收入趋势" print(f"[Query] {query}") results = retriever.retrieve(text\_query=query, top\_k=3) for source, lst in results.items(): print(f"-- {source} --") for r in lst: print(f" {r['doc\_id']}/{r['block\_id']} score={r['score']:.4f}") fused = FusionRanker(k=60).fuse(results, top\_k=3) print("== RRF 融合 ==") for r in fused: print(f" {r['doc\_id']}/{r['block\_id']} " f"rrf={r['rrf\_score']:.4f} sources={r['rrf\_sources']}") # 图搜图 demo:拿 D1 的图去搜相似图 query\_image = docs[0].image\_blocks[0] print("== 图搜图 ==") for r in retriever.retrieve(image\_query=query\_image, top\_k=3)["image2image"]: print(f" {r['doc\_id']}/{r['block\_id']} score={r['score']:.4f}")if __name__ == “__main__”:
main()
这段代码保存为mm_rag_demo.py后python3 mm_rag_demo.py直接可跑。会看到 Caption 路和 Visual 路各自返回 top-3,然后 RRF 融合出一个综合列表,最后图搜图返回相似图。因为 mock embedding 是内容哈希驱动的伪随机,具体排名对 demo 不重要,重点是整个 pipeline 的骨架能跑通。
真实系统里替换三个 mock 函数:
mock_caption→ 调 Qwen-VL 或 DeepSeek-VL2
mock_text_embed→ 调 BGE / OpenAI text-embedding-3-large
mock_visual_embed/
mock_visual_query_embed→ 调 ColPali 或类似模型
其余 pipeline 代码零改动。
选型决策指南
三种策略不是二选一,是组合问题。我的决策路径:
文档以纯文字为主(图片 < 10%):只上策略 A,OCR 到能抓的都抓上。没必要为少数图片引入复杂 pipeline。
中小规模知识库(< 10 万张图),图片语义可描述:上策略 B。VLM 生成 caption 的成本可以接受,索引侧完全复用现有向量库。技术手册、产品说明这类场景最合适。
大规模知识库(> 10 万张图)或图表极密集(学术论文、财报):上策略 C,或 B + C 融合。策略 C 的初期投入大(模型部署、存储成本),但检索质量和维护成本长期占优。
精度要求极高、图文语义关联紧密(医学影像、工程图):B + C 融合,再叠一层专门微调的 Rerank。这类场景通常有充足预算,也有能力做领域 Fine-tune。
图搜图场景强需求(客服工单、UI 设计库):强制上策略 C,B 完全覆盖不了。
一个反直觉的经验:不要过早上策略 C。ColPali 类模型的存储成本容易失控——一张 A4 页面切成 1024 个 patch,每 patch 128 维 float32 就是 512KB。10 万页就是 50GB,还没算副本和索引开销。除非策略 B 已经明显吃力,否则从 B 起步、监控 recall 指标、真正不够用了再加 C。
最后
我在一线科技企业深耕十二载,见证过太多因技术更迭而跃迁的案例。那些率先拥抱 AI 的同事,早已在效率与薪资上形成代际优势,我意识到有很多经验和知识值得分享给大家,也可以通过我们的能力和经验解答大家在大模型的学习中的很多困惑。
我整理出这套 AI 大模型突围资料包:
- ✅AI大模型学习路线图
- ✅Agent行业报告
- ✅100集大模型视频教程
- ✅大模型书籍PDF
- ✅DeepSeek教程
- ✅AI产品经理入门资料
完整的大模型学习和面试资料已经上传带到CSDN的官方了,有需要的朋友可以扫描下方二维码免费领取【保证100%免费】👇👇
为什么说现在普通人就业/升职加薪的首选是AI大模型?
人工智能技术的爆发式增长,正以不可逆转之势重塑就业市场版图。从DeepSeek等国产大模型引发的科技圈热议,到全国两会关于AI产业发展的政策聚焦,再到招聘会上排起的长队,AI的热度已从技术领域渗透到就业市场的每一个角落。
智联招聘的最新数据给出了最直观的印证:2025年2月,AI领域求职人数同比增幅突破200%,远超其他行业平均水平;整个人工智能行业的求职增速达到33.4%,位居各行业榜首,其中人工智能工程师岗位的求职热度更是飙升69.6%。
AI产业的快速扩张,也让人才供需矛盾愈发突出。麦肯锡报告明确预测,到2030年中国AI专业人才需求将达600万人,人才缺口可能高达400万人,这一缺口不仅存在于核心技术领域,更蔓延至产业应用的各个环节。
资料包有什么?
①从入门到精通的全套视频教程⑤⑥
包含提示词工程、RAG、Agent等技术点
② AI大模型学习路线图(还有视频解说)
全过程AI大模型学习路线
③学习电子书籍和技术文档
市面上的大模型书籍确实太多了,这些是我精选出来的
④各大厂大模型面试题目详解
⑤ 这些资料真的有用吗?
这份资料由我和鲁为民博士共同整理,鲁为民博士先后获得了北京清华大学学士和美国加州理工学院博士学位,在包括IEEE Transactions等学术期刊和诸多国际会议上发表了超过50篇学术论文、取得了多项美国和中国发明专利,同时还斩获了吴文俊人工智能科学技术奖。目前我正在和鲁博士共同进行人工智能的研究。
所有的视频教程由智泊AI老师录制,且资料与智泊AI共享,相互补充。这份学习大礼包应该算是现在最全面的大模型学习资料了。
资料内容涵盖了从入门到进阶的各类视频教程和实战项目,无论你是小白还是有些技术基础的,这份资料都绝对能帮助你提升薪资待遇,转行大模型岗位。
智泊AI始终秉持着“让每个人平等享受到优质教育资源”的育人理念,通过动态追踪大模型开发、数据标注伦理等前沿技术趋势,构建起"前沿课程+智能实训+精准就业"的高效培养体系。
课堂上不光教理论,还带着学员做了十多个真实项目。学员要亲自上手搞数据清洗、模型调优这些硬核操作,把课本知识变成真本事!
如果说你是以下人群中的其中一类,都可以来智泊AI学习人工智能,找到高薪工作,一次小小的“投资”换来的是终身受益!
应届毕业生:无工作经验但想要系统学习AI大模型技术,期待通过实战项目掌握核心技术。
零基础转型:非技术背景但关注AI应用场景,计划通过低代码工具实现“AI+行业”跨界。
业务赋能 突破瓶颈:传统开发者(Java/前端等)学习Transformer架构与LangChain框架,向AI全栈工程师转型。
👉获取方式:
😝有需要的小伙伴,可以保存图片到wx扫描二v码免费领取【保证100%免费】🆓**