在上一节《图文混排 PDF 检索》中,我们系统性地解决了 PDF 内部的多模态问题——布局语义丢失、表格结构熵增、图文信息割裂。通过 MultiModalVectorStoreIndexLlamaParseMinerU 等技术,我们已经能够让 LLM “看懂"复杂文档内部的图表和表格。

  但这只是多模态检索的”文档内检索“场景。今天,我们要进入一个更具挑战性的领域:跨模态检索——即用文字描述搜索图片(文搜图),或用一张图片搜索相似图片(图搜图)。这在企业场景中极为常见:电商的"以图搜货”、设计素材库的风格匹配、企业资料库的图表定位、安防监控的视频检索……这些需求的本质,是让 AI 能够跨越"文本"与"图像"两种模态的鸿沟,在统一的语义空间中进行比较。

  结合目前的技术,本节课将重点探讨:CLIP、VLM Captioning、Qwen3-VL 三种主流技术路径的原理与边界,以及为什么 Qwen3-VL + Reranker 正在取代 ColPali 成为新的行业标准。根据 MMEB-v2 和 ViDoRe v3 基准测试数据,Qwen3-VL 体系在存储成本仅为 ColPali 的 1/30 的前提下,视觉文档检索精度达到了 86.3 分,实现了性能与成本的双重突破。

一、 从文档内检索到跨模态检索 链接到标题

  在上一节课中,我们处理的场景是"给定一个 PDF,检索其中的图文信息"。这本质上是一个文档内问题——图片和文字在同一个文档中,它们的关联是"物理上的相邻"。但跨模态检索面临的挑战完全不同:图片和查询文本没有物理关联,我们需要让 AI 理解它们的语义相似性

1.1 跨模态检索的核心挑战 链接到标题

  与文档内检索相比,跨模态检索面临的本质性技术挑战:

文档内检索 vs 跨模态检索的本质差异

维度文档内检索(上节课)跨模态检索(本节课)
关联方式物理相邻(同一页/同一段)语义相似(无物理关联)
模态关系图文同源,有上下文图文异源,无上下文
核心难点解析提取向量空间对齐
典型场景“这张图表展示了什么?”“找一张展示销售趋势的图”
技术方案LlamaParse、MinerUCLIP、Qwen3-VL

  从表格可以看出,跨模态检索的核心难点从"解析提取“转变为”向量空间对齐"——我们需要让文本和图像在同一个数学空间中具有可比性。这正是 CLIP、Qwen3-VL 等多模态嵌入模型要解决的问题。

1.2 传统解决方案的局限性 链接到标题

  面对这一困境,企业通常会尝试以下三种传统方案。但遗憾的是,它们都存在致命缺陷:

传统图片检索方案的局限性对比

方案工作原理核心问题
文件名搜索基于文件名关键词匹配文件名通常无语义(如 IMG_20240315.jpg
OCR 提取文字识别图中文字建立索引丢失视觉信息(趋势、布局、颜色)
人工打标签人工为每张图片添加描述成本高、不可持续、覆盖率低

  以 OCR 方案为例,假设我们有一张展示"2024 年 Q3 销售额从 800 万下降到 650 万"的柱状图。OCR 可能只能提取出"Q3"、“800”、“650"这些离散数字,却无法理解"下降趋势"这一核心语义。更糟糕的是,如果图表使用的是可视化趋势线而非数字标注,OCR 将完全失效。

 这种"只见树木,不见森林"的处理方式,正是传统方案的根本局限:它们试图将视觉信息"翻译"为文本,但翻译过程中必然丢失信息

1.3 多模态 RAG 的核心思想:统一向量空间 链接到标题

  既然"翻译"会丢失信息,那能否让 AI 直接"看懂"图片?这正是多模态 RAG 的核心思想:让文本和图像在同一个语义空间中进行比较。

  这个思想可以用一个简单的类比来理解:想象文本和图像是两种不同的"语言”,传统方法试图把图像"翻译"成文本再搜索(必然丢失信息),而多模态 RAG 则是让 AI 同时精通两种语言,能够直接理解它们的含义并进行比较。

  从技术角度看,这个"统一语义空间"的实现依赖于多模态嵌入模型。这类模型能够:

  1. 将一段文本(如"一只白色的狗坐在窗台上")编码为一个固定维度的向量

  2. 将一张图片(如一张狗的照片)也编码为同样维度的向量

  3. 两个向量在同一个空间中,可以直接计算余弦相似度

  当文本向量与图片向量在这个空间中"距离很近"时,就说明它们语义相关。这就是文搜图(Text-to-Image Retrieval)的数学基础。同理,图搜图(Image-to-Image Retrieval)则是用图片向量去搜索其他相似的图片向量。

  至此,我们已经理解了多模态 RAG 的核心价值:打破传统 RAG 的"视觉围墙",让系统能够真正"看懂"图片并进行语义检索。我们也了解了其数学基础——通过多模态嵌入模型将文本和图像映射到统一向量空间。

  但一个关键问题随之而来:实现这个"统一向量空间"有哪些技术路径?它们各有什么优劣? 接下来我们将深入剖析三种主流方案:CLIP 双编码器、VLM 描述生成(Captioning)以及 国内千问模型 Qwen3-VL。

检索实战:从CLIP模型到智能体 链接到标题

  本节课,我们将通过完整的代码实战,逐步构建从最简单的CLIP文搜图MVP最小执行单元,到支持Milvus持久化、VLM描述增强、混合检索融合,最终到Qwen3-VL黄金架构和Agentic RAG智能体的完整演进路径。每一步都是可运行的代码,每一个技术点都对应前面理论中的关键概念。

  按照"由浅入深、层层递进"的原则,我们将分四个批次展开:基础(CLIP + Milvus)进阶(VLM Captioning)高级(Qwen3-VL黄金架构)智能体(Agentic RAG)。本批次聚焦基础篇,带你快速跑通第一个多模态检索系统。

一、 环境与依赖准备 链接到标题

  在动手之前,我们需要准备好运行环境。多模态检索涉及图像处理、向量嵌入、向量数据库等多个组件,每个组件都有特定的依赖包。

# 查看 Python 版本
!python --version

1.1 核心依赖包安装 链接到标题

  以下是本次实战需要的核心依赖包及其作用:

核心依赖包功能说明

依赖包作用安装命令
llama-indexLlamaIndex核心框架pip install llama-index
llama-index-embeddings-clipCLIP嵌入模型支持pip install llama-index-embeddings-clip
CLIPOpenAI CLIP原始库pip install git+https://github.com/openai/CLIP.git
pymilvusMilvus向量数据库Python客户端pip install pymilvus
llama-index-vector-stores-milvusMilvus与LlamaIndex集成pip install llama-index-vector-stores-milvus
pillow图像处理库pip install pillow

  执行依赖检查代码,确保所有包都已正确安装:

import os
from pathlib import Path
from dotenv import load_dotenv

# 加载环境变量,通常用于配置 API 密钥和存储路径
load_dotenv(override=True)

def check_dependencies():
    """
    检查运行多模态检索所需的必要 Python 依赖库是否已安装。
    
    返回:
        bool: 如果所有依赖都已安装返回 True,否则返回 False。
    """
    # 定义需要检查的库:(模块名, pip安装名)
    required_packages = [
        ("llama_index.core", "llama-index"),
        ("llama_index.embeddings.clip", "llama-index-embeddings-clip"),
        ("pymilvus", "pymilvus"),
    ]
    
    missing = []
    # 遍历列表,尝试导入每个模块以验证其是否存在
    for module_name, package_name in required_packages:
        try:
            __import__(module_name)
            print(f"✅ {package_name} 已安装")
        except ImportError:
            missing.append(package_name)
            print(f"❌ {package_name} 未安装")
    
    # 检查 OpenAI 的 CLIP 库,该库通常需要从 GitHub 源码安装
    try:
        import clip
        print("✅ CLIP 已安装")
    except ImportError:
        print("❌ CLIP 未安装")
        print("   请运行: pip install git+https://github.com/openai/CLIP.git")
        missing.append("CLIP")
    
    # 如果有任何缺失的包,提示用户
    if missing:
        print(f"\n⚠️ 请安装缺失的依赖")
        return False
    
    print("\n🎉 所有依赖检查通过!")
    return True

# 执行依赖项检查函数
check_dependencies()

  打印版本信息(重要):为了避免未来版本冲突,我们记录当前安装的包版本:

def print_package_versions():
    """打印核心依赖包版本信息"""
    import importlib.metadata
    
    packages = [
        "llama-index",
        "llama-index-embeddings-clip",
        "pymilvus",
        "llama-index-vector-stores-milvus",
        "pillow"
    ]
    
    print("\n📦 核心依赖包版本信息:")
    print("-" * 50)
    
    for package in packages:
        try:
            version = importlib.metadata.version(package)
            print(f"  {package}: {version}")
        except importlib.metadata.PackageNotFoundError:
            print(f"  {package}: 未安装")
    
    # 特殊处理 CLIP
    try:
        import clip
        # CLIP 没有标准版本号,打印安装状态
        print(f"  CLIP (OpenAI): 已安装")
    except ImportError:
        print(f"  CLIP (OpenAI): 未安装")
    
    print("-" * 50)

# 打印版本信息
print_package_versions()
import os
from dotenv import load_dotenv

# 加载环境变量(.env 文件中需要配置 OPENAI_API_KEY
load_dotenv(override=True)

# 验证 API Key
api_key = os.getenv("OPENAI_API_KEY")
base_url = os.getenv("OPENAI_BASE_URL")
if api_key:
    print(f"✅ OPENAI_API_KEY 已配置: {api_key[:20]}...")
else:
    raise ValueError("❌ 请设置 OPENAI_API_KEY 环境变量")
from llama_index.core import Settings
from llama_index.llms.openai import OpenAI
from llama_index.embeddings.openai import OpenAIEmbedding

# 配置 LLM(大语言模型)
Settings.llm = OpenAI(
    model="gpt-4o",           # 使用 GPT-4o 模型
    temperature=0,             # 降低随机性,输出更稳定
    api_key=api_key,
    api_base=base_url          # 可选:使用代理 API
)

# 简单文本测试
response = Settings.llm.complete("你好,请用一句话介绍你自己。")
print("LLM 响应:")
print(response.text)

1.2 Milvus向量数据库启动 链接到标题

  Milvus是一个高性能的向量数据库,支持大规模向量检索。我们将使用Docker快速启动Milvus服务。课件里已经保存了一份Milvus的docker-compose.yml文件,可以执行docker-compose up -d启动Milvus服务。

  步骤一:启动Milvus(Docker Compose方式)


# 在存在docker-compose.yml文件相同的目录下,启动Milvus
docker-compose up -d

  步骤二:验证Milvus连接

def check_milvus_connection():
    """检查 Milvus 连接是否正常"""
    try:
        from pymilvus import connections, utility
        
        MILVUS_HOST = "localhost"
        MILVUS_PORT = 19530
        
        connections.connect(
            alias="default",
            host=MILVUS_HOST,
            port=MILVUS_PORT
        )
        
        # 测试连接
        version = utility.get_server_version()
        print(f"✅ Milvus 连接成功 (版本: {version})")
        
        connections.disconnect("default")
        return True
        
    except Exception as e:
        print(f"❌ Milvus 连接失败: {e}")
        print(f"   请确保 Milvus 正在运行: docker-compose up -d")
        return False

# 执行检查
check_milvus_connection()

二、 CLIP模型初始化与向量空间理解 链接到标题

2.1 CLIP 双编码器——速度与成本的极致 链接到标题

  CLIP(Contrastive Language-Image Pre-training)是 OpenAI 在 2021 年发布的里程碑式模型。它采用双编码器架构:一个文本编码器(Text Encoder)和一个图像编码器(Image Encoder),通过对比学习将图文映射到同一个 512 维的向量空间。

工作原理

文本 → [Text Encoder] → 文本向量(512维)┐
                                           ├→ 余弦相似度计算
图片 → [Image Encoder] → 图片向量(512维)┘

2.1 ClipEmbedding初始化 链接到标题

  CLIP 的核心优势在于其极致的效率:单张图片的编码时间仅需约 10 毫秒,且模型完全开源免费。这使得它成为大规模图像库快速索引的首选方案。在 LlamaIndex 中,我们可以通过 ClipEmbedding 类快速接入:

from llama_index.embeddings.clip import ClipEmbedding

# 初始化 CLIP 嵌入模型
embed_model = ClipEmbedding()
print("✅ ClipEmbedding 初始化完成")

  代码说明:这一行代码背后,LlamaIndex会自动下载CLIP模型权重(约400MB),并加载到内存中。首次运行会较慢,后续会使用缓存。

2.2 文本与图像向量对比实验 链接到标题

  为了直观理解"统一向量空间",我们对比文本向量和图像向量的维度:

# 文本向量编码
text_vec = embed_model.get_text_embedding("一只橘色的猫")
print(f"文本向量维度: {len(text_vec)}")
print(f"文本向量前5维: {text_vec[:5]}")

# 图像向量编码(假设有一张猫的图片)
img_vec = embed_model.get_image_embedding("/Users/mac/大模型资料/多模态RAG/七大场景llama-Index进阶实战/测试文档&图片/test_images/Image_cvvlc4cvvlc4cvvl.png")
print(f"图像向量维度: {len(img_vec)}")
print(f"文本向量前5维: {img_vec[:5]}")

  关键发现:文本和图像的向量维度完全相同(都是512维),这正是CLIP"统一向量空间"的体现。当我们计算文本向量与图像向量的余弦相似度时,就能衡量它们的语义关联程度。

  上述代码展示了 CLIP 的核心机制:get_text_embedding()get_image_embedding() 输出的向量维度相同,可以直接计算相似度。这正是"统一向量空间"的具体实现。

CLIP 的边界与局限

  • 无法识别图中文字:CLIP 将整张图压缩为单一向量,丢失了细粒度的文字信息

  • 细粒度特征捕捉弱:难以区分"2023 年销售图表"与"2024 年销售图表"

  • 中文理解较弱:原版 CLIP 主要在英文数据上训练(可使用 Chinese-CLIP 改善)

适用场景:海量自然图像检索(电商商品图、摄影素材库),对细节精度要求不高,但对速度和成本极度敏感。

2.3 MultiModalVectorStoreIndex构建 链接到标题

  现在我们有了嵌入模型,下一步是构建多模态索引。LlamaIndex的MultiModalVectorStoreIndex专门为图片设计,可以自动处理图片文档。

2.3.1 加载图片文档 链接到标题

  首先使用SimpleDirectoryReader加载图片目录:

from llama_index.core import SimpleDirectoryReader
from pathlib import Path

# 配置图片目录
BASE_DIR = Path.cwd()
image_dir = BASE_DIR / "images"  # 请替换为你的图片目录路径

# 加载图片文档
documents = SimpleDirectoryReader(
    input_dir=str(image_dir),
    required_exts=[".png", ".jpg", ".jpeg", ".gif", ".webp"]
).load_data()

print(f"📷 加载了 {len(documents)} 个图片文档")

  结果分析documents是一个列表,每个元素代表一张图片。LlamaIndex会自动记录图片的文件路径、元数据等信息。

2.3.2 构建多模态索引(内存版) 链接到标题

  最简单的MVP:使用内存存储构建索引(后续会升级为Milvus持久化):

from llama_index.core.indices import MultiModalVectorStoreIndex

# 构建多模态索引
index = MultiModalVectorStoreIndex.from_documents(
    documents,
    image_embed_model=embed_model
)
print("✅ MultiModalVectorStoreIndex 构建完成")

  代码背后的逻辑

  1. image_embed_model=embed_model:告诉索引使用CLIP对图片进行嵌入,而非将图片路径当作文本。

  2. MultiModalVectorStoreIndex会自动调用embed_model.get_image_embedding()处理每张图片。

  3. 所有图片向量默认存储在内存中(后续步骤会改为Milvus)。

2.4 文搜图实战 链接到标题

  索引构建完成后,我们可以用文本查询来检索相关图片。这正是"文搜图"(Text-to-Image Retrieval)的核心功能。

# 加载打印图片的方法
from utils_image_display import display_retrieval_results,display_local_image

2.4.1 text_to_image_retrieve基础用法 链接到标题

  使用text_to_image_retrieve()方法执行文搜图:

# 创建检索器
retriever = index.as_retriever(
    similarity_top_k=3,
    image_similarity_top_k=3
)

# 执行文搜图检索
query = "架构图"
results = retriever.text_to_image_retrieve(query)

print(f"\n🔍 查询: \"{query}\"")
print("-" * 50)
for i, result in enumerate(results, 1):
    file_path = result.node.metadata.get("file_path", "未知")
    score = result.score if hasattr(result, 'score') else 0.0
    print(f"  [{i}] {Path(file_path).name}: {score:.4f}")

# 3. 显示结果
display_retrieval_results(results, title="文搜图: 架构图", max_display=6)

  结果分析:分数在0.24左右是CLIP的正常表现。关键不是绝对分数,而是排序是否符合预期。

2.4.2 中英文查询效果对比 链接到标题

  由于CLIP主要在英文数据上训练,我们对比中英文查询效果:

test_queries = [
    ("架构图", "architecture diagram"),
    ("流程图", "flowchart"),
]

for cn_query, en_query in test_queries:
    print(f"\n中文查询: \"{cn_query}\" | 英文查询: \"{en_query}\"")
    print("-" * 40)
    
    cn_results = retriever.text_to_image_retrieve(cn_query)
    en_results = retriever.text_to_image_retrieve(en_query)
    
    if cn_results:
        cn_top_score = cn_results[0].score if hasattr(cn_results[0], 'score') else 0.0
        print(f"  中文Top1分数: {cn_top_score:.4f}")
    
    if en_results:
        en_top_score = en_results[0].score if hasattr(en_results[0], 'score') else 0.0
        print(f"  英文Top1分数: {en_top_score:.4f}")
        print(f"  提升幅度: {(en_top_score - cn_top_score):.4f}")

  典型发现:英文查询分数通常比中文高0.03-0.05,但仍然不够高(约0.26-0.28)。这验证了理论课中的论断:CLIP的中文能力有限,且单靠Embedding的绝对分数无法满足精排需求,因此需要两阶段架构Reranker。

2.5 图搜图实战 链接到标题

  除了"文搜图",我们还可以用一张图片去搜索相似的图片。这在"以图搜货"、“找相似设计稿"等场景中极为实用。

2.5.1 image_to_image_retrieve用法 链接到标题

  使用image_to_image_retrieve()方法执行图搜图:

from pathlib import Path

# 配置图片目录
BASE_DIR = Path.cwd()
image_dir = BASE_DIR / "images"  # 请替换为你的图片目录路径

# 选择一张查询图片
image_files = list(image_dir.glob("*.png")) + list(image_dir.glob("*.jpg"))
if image_files:
    query_image = image_files[0]
    
    print(f"\n🔍 图搜图查询: {query_image.name}")
    print("-" * 50)
    
    # 执行图搜图检索
    retriever_img = index.as_retriever(image_similarity_top_k=4)
    results = retriever_img.image_to_image_retrieve(str(query_image))
    
    for i, result in enumerate(results, 1):
        file_path = result.node.metadata.get("file_path", "未知")
        score = result.score if hasattr(result, 'score') else 0.0
        print(f"  [{i}] {Path(file_path).name}: {score:.4f}")

    # 3. 显示结果
    display_retrieval_results(results, title="图搜图", max_display=6)

2.5.2 排除查询图片本身 链接到标题

  在图搜图场景中,检索结果的第一名往往是查询图片本身(相似度接近1.0)。实际应用中我们需要排除它:

# 优化后的图搜图:过滤查询图片本身
query_image_path = Path(query_image)
results = retriever_img.image_to_image_retrieve(str(query_image))

filtered_results = []
for result in results:
    file_path = Path(result.node.metadata.get("file_path", ""))
    
    # 跳过查询图片本身
    if file_path.resolve() == query_image_path.resolve():
        print(f"  [跳过] {file_path.name} (查询图片本身, 分数: {result.score:.4f})")
        continue
    
    filtered_results.append(result)
    print(f"  [{len(filtered_results)}] {file_path.name}: {result.score:.4f}")

# 3. 显示结果
display_retrieval_results(filtered_results, title="图搜图", max_display=6)

  工程技巧:通过比较绝对路径(resolve())来准确识别同一张图片,避免因相对路径不同而误判。

2.6 Milvus向量库持久化 链接到标题

  前面使用的内存索引在程序重启后会丢失。生产环境中,我们需要将向量持久化到Milvus数据库。

2.6.1 配置Milvus向量存储 链接到标题

  使用MilvusVectorStore替代内存存储:

from llama_index.core import StorageContext
from llama_index.vector_stores.milvus import MilvusVectorStore

# 配置 Milvus 向量存储,链接地址,可换成生产环境地址
MILVUS_URI = "http://localhost:19530"

# 文本集合名称
TEXT_COLLECTION = "multimodal_text_collection"

# 图片集合名称
IMAGE_COLLECTION = "multimodal_image_collection"

# 文本向量存储
text_store = MilvusVectorStore(
    uri=MILVUS_URI,
    collection_name=TEXT_COLLECTION,
    dim=512,  # CLIP 文本向量维度
    overwrite=True,  # 重新创建集合
)

# 图像向量存储
image_store = MilvusVectorStore(
    uri=MILVUS_URI,
    collection_name=IMAGE_COLLECTION,
    dim=512,  # CLIP 图像向量维度
    overwrite=True,
)

# 创建存储上下文
storage_context = StorageContext.from_defaults(
    vector_store=text_store,  # 这里没有用到文本数据,只作为占位符
    image_store=image_store
)
print("✅ Milvus 向量存储配置完成")

  配置说明

  • uri: Milvus服务地址

  • collection_name: 集合名称(类似数据库表名)

  • dim=512: CLIP向量维度

  • overwrite=True: 每次运行时重新创建集合(开发阶段方便调试)

2.6.2 构建持久化索引 链接到标题

  将storage_context传入索引构建:

# 构建持久化索引
index_milvus = MultiModalVectorStoreIndex.from_documents(
    documents,
    storage_context=storage_context,
    embed_model=embed_model,
    image_embed_model=embed_model,
)
print("✅ MultiModalVectorStoreIndex 已持久化到 Milvus")

# 测试持久化后的检索
retriever_milvus = index_milvus.as_retriever(image_similarity_top_k=3)

# 修正:Standard CLIP 模型对中文支持较弱,改用英文 Prompt 以修复相关性问题
query_text = "architecture diagram"
results = retriever_milvus.text_to_image_retrieve(query_text)

print(f"\n Milvus持久化索引检索结果 (Query: {query_text}):")
for i, result in enumerate(results, 1):
    file_path = result.node.metadata.get("file_path", "未知")
    score = result.score if hasattr(result, 'score') else 0.0
    print(f"  [{i}] {Path(file_path).name}: {score:.4f}")

# 显示结果
display_retrieval_results(results, title=f"Retrieval: {query_text}", max_display=6)

  验证方法:重启程序后,可以直接从Milvus加载已有索引,无需重新构建:

# 加载已有索引(程序重启后)
from llama_index.core.indices import MultiModalVectorStoreIndex

# 注意:这里直接使用上面定义好的 text_store 和 image_store
# 如果是完全重启环境,需要重新定义 MilvusVectorStore(..., overwrite=False)
index_reload = MultiModalVectorStoreIndex.from_vector_store(
    vector_store=text_store,
    image_vector_store=image_store,
    image_embed_model=embed_model
)
print("✅ 已成功从 Milvus 加载 MultiModalVectorStoreIndex")

# 创建检索器
retriever_reload = index_reload.as_retriever()

# 执行文搜图检索
reload_results = retriever_reload.text_to_image_retrieve("architecture diagram")

print(f"\n Milvus持久化索引检索结果 (Query: architecture diagram):")
for i, result in enumerate(reload_results, 1):
    file_path = result.node.metadata.get("file_path", "未知")
    score = result.score if hasattr(result, 'score') else 0.0
    print(f"  [{i}] {Path(file_path).name}: {score:.4f}")

# 显示结果
display_retrieval_results(reload_results, title=f"Retrieval: {query_text}", max_display=6)
# 执行图搜图检索
images_results = retriever_reload.image_to_image_retrieve("images/Image_gkvpywgkvpywgkvp.png")

print(f"\n Milvus持久化索引检索结果 (Query: architecture diagram):")
for i, result in enumerate(images_results, 1):
    file_path = result.node.metadata.get("file_path", "未知")
    score = result.score if hasattr(result, 'score') else 0.0
    print(f"  [{i}] {Path(file_path).name}: {score:.4f}")

# 显示结果
display_retrieval_results(images_results, title=f"Retrieval: {query_text}", max_display=6)

  本步验证点:能够在不重新运行from_documents()的情况下执行检索,说明向量已成功持久化。

小结 链接到标题

基础篇核心成果

步骤核心能力对应理论
步骤1环境与依赖准备工程化基础
步骤2CLIP模型初始化统一向量空间
步骤3MultiModalVectorStoreIndexLlamaIndex多模态索引
步骤4text_to_image_retrieve文搜图(召回阶段)
步骤5image_to_image_retrieve图搜图
步骤6Milvus持久化向量数据库存储

  关键认知回顾

  1. CLIP分数低是正常的:余弦相似度0.2-0.3已表示有关联,关键看排序

  2. 两阶段架构的必然性:CLIP适合召回(快速筛选),Reranker适合精排(准确排序)

  3. Milvus的价值:支持海量向量持久化与分布式检索

三、 VLM描述生成:为图片"注入"语义 链接到标题

  在前面的CLIP实战中,我们发现了一个核心痛点:CLIP的相似度分数普遍偏低(0.2-0.3),且对中文支持有限。虽然排序基本准确,但如果查询包含精确关键词(如"包含’微服务架构’的图片”),CLIP很可能检索不到。

  为什么?因为CLIP是端到端的图文对比学习模型,它不理解图片中的文字。一张架构图上写着"微服务架构"四个大字,CLIP也只能从视觉特征(形状、颜色)去匹配,无法做精确的关键词匹配。

  既然 CLIP 的问题是"信息压缩过度",那能否让模型先"理解"图片内容,再用文字描述出来?这正是 VLM Captioning 方案的思路:借助 GPT-4V、Gemini 等视觉语言大模型,为每张图片生成详细的文本描述,然后用传统的文本检索技术进行匹配。并且这样,我们就能同时利用语义检索(向量)和关键词检索(BM25)。

工作原理

  这种方案的优势显而易见:VLM 能够理解图表的语义含义(不仅仅是"有个表格",而是"销售在下滑"),还能提取图中文字(OCR 能力内置)。

VLM Captioning 的边界与局限

  • 成本高昂:每张图片需要调用 VLM API,大规模索引成本可达数千美元

  • 速度极慢:单张图片处理需要 2-5 秒,批量处理效率低下

  • 信息瓶颈与幻觉:VLM 可能遗漏细节或产生错误描述

适用场景:小数据量、高精度需求(法务文档、技术手册),或需要高度可解释性的场景(人工可以审核生成的描述)。

3.1 VLM自适应选择器:成本与效果的平衡 链接到标题

  目前主流的VLM有两大阵营:OpenAI GPT-4o阿里 Qwen-VL-Max。它们的特点对比:

VLM选型对比

维度GPT-4oQwen-VL-Max
中文理解⭐⭐⭐⭐ (优秀)⭐⭐⭐⭐⭐ (顶级,中文专优)
描述质量⭐⭐⭐⭐⭐ (最佳)⭐⭐⭐⭐ (优秀)
成本$0.003/张 (较贵)¥0.01/张 (约$0.0014,便宜50%)
适用场景小规模(<50张),追求极致质量大规模(≥50张),成本优先

  我们实现一个自适应选择器:小规模用GPT-4o(效果最好),大规模用Qwen-VL-Max(性价比高):

class VLMSelector:
    """VLM 自适应选择器 - 根据图片数量智能选择最优VLM"""
    
    SMALL_SCALE_THRESHOLD = 50  # 小规模阈值
    
    @staticmethod
    def select_vlm(
        num_images: int,
        user_preference: str = "auto"
    ):
        """
        根据图片数量自动选择VLM
        
        Returns:
            (vlm_model, reason)  # 选择的VLM和原因说明
        """
        if user_preference != "auto":
            return user_preference, f"用户指定: {user_preference}"
        
        # 小规模(< 50张):GPT-4o(效果最好)
        if num_images < VLMSelector.SMALL_SCALE_THRESHOLD:
            cost_estimate = f"约${num_images * 0.003:.2f}"
            return "gpt-4o", f"小规模({num_images}张) → GPT-4o (最佳效果, 成本{cost_estimate})"
        
        # 大规模(>= 50张):Qwen-VL-Max(性价比)
        else:
            cost_estimate = f"约¥{num_images * 0.01:.2f}"
            return "qwen-vl-max", f"大规模({num_images}张) → Qwen-VL-Max (性价比, 成本{cost_estimate})"

# 使用示例
num_images = 10
vlm_model, reason = VLMSelector.select_vlm(num_images)
print(f"✅ {reason}")

  设计亮点:这个选择器封装了成本与效果的权衡逻辑,未来如果有新的VLM(如Claude 3.5 Sonnet),只需在这里添加新的分支即可。

3.2 批量生成图片描述 链接到标题

  我们使用GPT-4o为图片生成详细描述。核心代码:

from dotenv import load_dotenv
load_dotenv(override=True)

from llama_index.core.llms import ChatMessage, ImageBlock, TextBlock
from llama_index.llms.openai import OpenAI

def generate_image_caption_gpt4o(image_path: str) -> str:
    """使用 GPT-4o 生成图片描述"""
    
    # Prompt模板
    prompt_text = """请详细描述这张图片,包括以下信息:
    1. 图片的主要内容和主题
    2. 图片中的所有可见文字(如有)
    3. 如果是图表,描述数据趋势和关键数值
    4. 颜色、布局和设计风格
    5. 任何其他值得注意的视觉元素

    请用中文回答,描述要详细具体,便于后续检索。"""
    
    # 构造ChatMessage
    message = ChatMessage(
        role="user",
        blocks=[
            ImageBlock(path=image_path),
            TextBlock(text=prompt_text)
        ]
    )
    
    # 使用Settings.llm设置的全局LLM,Gpt-4o模型
    response = Settings.llm.chat([message])
    return response.message.content

# 测试单张图片
image_path = "images/20251216110324_89_50.png"
caption = generate_image_caption_gpt4o(image_path)
print(f"描述: {caption}")
# 显示结果
display_local_image(image_path, title=f"Retrieval: 20251216110324_89_50.png")

  关键观察:VLM生成的描述非常详细,不仅包含视觉信息(颜色、布局),还提取了图片中的文字内容(“微服务架构”、“API网关"等),这正是CLIP做不到的!

3.3 描述缓存机制 链接到标题

  VLM调用是有成本的(GPT-4o约$0.003/张)。我们实现一个缓存机制,避免重复生成:

from pathlib import Path

CAPTION_CACHE_DIR = Path("./caption_cache")
CAPTION_CACHE_DIR.mkdir(exist_ok=True)

def batch_generate_captions(
    image_dir: Path,          # 图片目录路径
    max_images: int = 10,     # 最大处理图片数量
    vlm_model: str = "gpt-4o" # 使用的视觉语言模型名称
):
    """批量为图片生成描述(带缓存)"""
    # 获取图片列表
    image_files = list(image_dir.glob("*.png")) + list(image_dir.glob("*.jpg"))
    # 取最大图片数量
    image_files = image_files[:max_images]
    
    results = []
    
    # 迭代处理选定的图片文件
    for i, image_path in enumerate(image_files, 1):
        print(f"\n[{i}/{len(image_files)}] 处理: {image_path.name}")
        
        # 定义缓存文件路径 (文件名_模型名.txt)
        cache_file = CAPTION_CACHE_DIR / f"{image_path.stem}_{vlm_model}.txt"
        
        # 优先从本地缓存读取,避免重复调用 API 产生费用
        if cache_file.exists():
            print(f"  📂 使用缓存描述")
            caption = cache_file.read_text(encoding="utf-8")
        else:
            print(f"  🚀 调用 {vlm_model} 生成描述...")
            # 核心步骤:调用视觉模型生成描述
            caption = generate_image_caption_gpt4o(str(image_path))
            
            # 生成成功后写入缓存文件供下次使用
            if caption:
                cache_file.write_text(caption, encoding="utf-8")
                print(f"  💾 已缓存到 {cache_file.name}")
        
        # 将有效的描述结果及元数据存入结果列表
        if caption:
            results.append({
                "image_path": str(image_path),
                "image_name": image_path.name,
                "caption": caption
            })
            print(f"  ✅ 描述长度: {len(caption)} 字符")
            print(f"  📝 预览: {caption[:100]}...")
    
    return results

# 执行批量生成
captions = batch_generate_captions(
    image_dir=Path("images"),
    max_images=12,
    vlm_model="gpt-4o"
)

  结果分析:第一次运行会调用VLM API,后续运行直接读取缓存,避免重复付费

3.4 基于描述的向量索引构建 链接到标题

  有了图片描述后,我们需要构建向量索引。与之前的CLIP不同,这次我们用OpenAI Embedding(text-embedding-3-small)对描述文本进行嵌入。

3.4.1 ImageNode与描述文本 链接到标题

  LlamaIndex提供了ImageNode类,同时存储图片路径和文本描述:

from llama_index.core import Settings
from llama_index.embeddings.openai import OpenAIEmbedding

# 显式重置 embed_model 为 OpenAI,防止沿用之前的 CLIP 模型
embed_model = OpenAIEmbedding(model="text-embedding-3-small")

# 设置全局嵌入模型,可以将之前的CLIP模型覆盖掉
Settings.embed_model = embed_model

from llama_index.core.schema import ImageNode
from llama_index.embeddings.openai import OpenAIEmbedding
from llama_index.core import VectorStoreIndex
from typing import List

def build_caption_based_index(captions: List[dict]):
    """基于图片描述构建向量索引"""
    
    # 1. 创建 ImageNode 列表
    nodes = []
    for item in captions:
        node = ImageNode(
            text=item["caption"],  # 描述文本
            image_path=item["image_path"],  # 图片路径
            metadata={
                "file_name": item["image_name"],  # 图片文件名
                "caption_length": len(item["caption"])  # 描述文本长度
            }
        )
        nodes.append(node)
    
    print(f"  创建了 {len(nodes)} 个 ImageNode")
    
    # 2. 配置 OpenAI 嵌入模型
    embed_model = OpenAIEmbedding(model="text-embedding-3-small")
    print(f"  使用 text-embedding-3-small 嵌入模型(1536维)")
    
    # 3. 构建索引(暂用内存存储)
    index = VectorStoreIndex(
        nodes=nodes,
        embed_model=embed_model
    )
    
    print("✅ 基于描述的向量索引构建完成")
    return index ,nodes

# 构建索引
index,nodes = build_caption_based_index(captions)

  关键点

  • ImageNode.text:存储VLM生成的描述(这是被嵌入的内容)

  • ImageNode.image_path:存储图片路径(检索时返回图片)

  • embed_model:使用OpenAI的文本嵌入(1536维),而非CLIP的512维

3.4.2 Milvus数据库持久化 链接到标题

  与批次一类似,我们将索引持久化到Milvus:

from llama_index.core import StorageContext
from llama_index.vector_stores.milvus import MilvusVectorStore

# Milvus 配置
MILVUS_URI = "http://localhost:19530"

# 向量存储集合名称
CAPTION_COLLECTION = "vlm_caption_collection"

# 配置 Milvus 向量存储
vector_store = MilvusVectorStore(
    uri=MILVUS_URI,
    collection_name=CAPTION_COLLECTION,
    dim=1536,  # text-embedding-3-small 维度
    overwrite=True
)

# 创建存储上下文,并将 Milvus 向量存储配置其中
storage_context = StorageContext.from_defaults(
    vector_store=vector_store
)

# 构建持久化索引
index = VectorStoreIndex(
    nodes=nodes,
    storage_context=storage_context,
    embed_model=embed_model
)

print("✅ 向量索引已持久化到 Milvus")

  验证方法:程序重启后,可以从Milvus加载已有索引:

from llama_index.core import VectorStoreIndex

# 加载已有索引
vector_store = MilvusVectorStore(
    uri=MILVUS_URI,
    collection_name=CAPTION_COLLECTION,
    dim=1536,
    overwrite=False  # 不覆盖已有集合
)

index_reload = VectorStoreIndex.from_vector_store(
    vector_store=vector_store,
    embed_model=embed_model
)
print("✅ 从 Milvus 加载已有索引")
# 创建查询引擎
query_engine = index_reload.as_query_engine()

# 查询
response = query_engine.query("我需要了解房屋架构图,给我描述一下,一个房屋的架构图都有什么东西?")

# 显示结果
print(response)

3.5 BM25关键词索引 链接到标题

  向量检索虽然能理解语义,但对精确关键词匹配不敏感。例如,用户查询"包含’微服务’的图片”,向量检索可能返回"分布式架构"(语义相近但关键词不匹配)。

  BM25(Best Matching 25)是一种经典的关键词检索算法,专门用于精确匹配。我们将其与向量检索结合,实现混合检索

3.5.1 BM25Retriever构建 链接到标题

  LlamaIndex提供了BM25Retriever,我们基于描述文本构建BM25索引:

from llama_index.retrievers.bm25 import BM25Retriever
from llama_index.core import Document
from llama_index.core.node_parser import SentenceSplitter

def build_bm25_index(captions: List[dict]):
    """基于图片描述构建BM25索引(关键词检索)"""
    
    print("\n📊 构建BM25索引(关键词检索)...")
    
    # 1. 创建 Document 列表
    documents = []
    for item in captions:
        doc = Document(
            text=item["caption"],
            metadata={
                "image_path": item["image_path"],
                "file_name": item["image_name"]
            }
        )
        documents.append(doc)
    
    print(f"  创建了 {len(documents)} 个 Document")
    
    # 2. 将Documents转换为nodes
    splitter = SentenceSplitter(chunk_size=512)
    nodes = splitter.get_nodes_from_documents(documents)
    
    print(f"  转换为 {len(nodes)} 个 Node")
    
    # 3. 构建 BM25 检索器
    bm25_retriever = BM25Retriever.from_defaults(
        nodes=nodes,
        similarity_top_k=3
    )
    
    print("✅ BM25索引构建完成")
    
    return bm25_retriever

# 构建BM25索引
bm25_retriever = build_bm25_index(captions)

  BM25原理简述:BM25基于TF-IDF思想,给每个词分配权重。查询"包含’微服务’的图片"时,BM25会优先返回描述中包含"微服务"这个词的图片,而不是语义相近但没有这个词的图片。

3.5.2 纯BM25检索测试 链接到标题

  我们先单独测试BM25检索:

# 纯BM25检索
query = "卧室"
results = bm25_retriever.retrieve(query)

print(f"\n🔍 【纯BM25检索】查询: \"{query}\"")
print("-" * 50)

for i, result in enumerate(results[:3], 1):
    file_name = result.node.metadata.get("file_name", "未知")
    caption = result.node.text[:100] + "..."
    print(f"  [{i}] {file_name}")
    print(f"      描述: {caption}")

  关键发现:BM25准确地返回了描述中包含"微服务"关键词的图片!

3.6 QueryFusionRetriever混合检索 链接到标题

  现在我们有了两个检索器:

  • 向量检索器:理解语义,但关键词匹配弱

  • BM25检索器:关键词精确匹配,但语义理解弱

  如何结合两者优势?答案是混合检索 + RRF融合

3.6.1 RRF融合原理 链接到标题

  RRF(Reciprocal Rank Fusion)是一种融合多个检索结果的算法。核心思想:

融合分数 = Σ (1 / (k + rank_i))

其中:
- rank_i:文档在第i个检索器中的排名(1, 2, 3...)
- k:平滑参数(通常为60)

  举例说明

图片向量检索排名BM25检索排名RRF分数计算
A.png131/(60+1) + 1/(60+3) = 0.0164 + 0.0159 = 0.0323
B.png211/(60+2) + 1/(60+1) = 0.0161 + 0.0164 = 0.0325 ✅✅
C.png5101/(60+5) + 1/(60+10) = 0.0154 + 0.0143 = 0.0297

  结论:B.png在两个检索器中表现都不错(排名2和1),因此RRF分数最高,最终排第一。这正是混合检索的价值——综合考虑语义和关键词

3.6.2 实现混合检索 链接到标题

  LlamaIndex的QueryFusionRetriever内置了RRF融合:

from llama_index.core.retrievers import QueryFusionRetriever

def hybrid_search(
    vector_index,
    bm25_retriever,
    query: str,
    top_k: int = 5
):
    """混合检索:向量检索 + BM25检索 + RRF融合"""
    
    print(f"\n🔍 【混合检索】查询: \"{query}\"")
    print("-" * 50)
    
    # 1. 创建向量检索器
    vector_retriever = vector_index.as_retriever(similarity_top_k=10)
    
    # 2. 创建融合检索器(RRF融合)
    print("  路径1: 向量检索(语义理解)")
    print("  路径2: BM25检索(关键词匹配)")
    print("  融合: Reciprocal Rank Fusion (RRF)")
    
    fusion_retriever = QueryFusionRetriever(
        retrievers=[vector_retriever, bm25_retriever],
        similarity_top_k=top_k,
        num_queries=1,  # 不生成query变体
        mode="reciprocal_rerank",  # RRF融合
        use_async=False
    )
    
    # 3. 执行检索
    results = fusion_retriever.retrieve(query)
    
    # 4. 格式化输出
    for i, result in enumerate(results, 1):
        file_name = result.node.metadata.get("file_name", "未知")
        image_path = result.node.metadata.get("image_path", "")
        score = result.score if hasattr(result, 'score') else 0.0
        caption = result.node.text[:100] + "..."
        
        print(f"  [{i}] {file_name} (融合分数: {score:.4f})")
        print(f"      描述: {caption}")
    
    return results

# 对比测试
query = "跟建筑平面图"

# 纯向量检索
vector_retriever = index.as_retriever(similarity_top_k=3)
vector_results = vector_retriever.retrieve(query)
print("\n【纯向量检索】结果:")
for i, r in enumerate(vector_results, 1):
    print(f"  [{i}] {r.node.metadata['file_name']}")

# 显示结果
display_retrieval_results(vector_results, title=f"Retrieval: 纯向量检索", max_display=6)

# 纯BM25检索
bm25_results = bm25_retriever.retrieve(query)
print("\n【纯BM25检索】结果:")
for i, r in enumerate(bm25_results[:3], 1):
    print(f"  [{i}] {r.node.metadata['file_name']}")
    print(f"      描述: {r.node.text[:100]}...")
# 混合检索
hybrid_results = hybrid_search(index, bm25_retriever, query, top_k=3)

  关键发现:混合检索的Top1结果同时包含"微服务"和"流程图"语义,这正是RRF融合的威力——取长补短,相互增强

小结 链接到标题

  在进阶篇中,我们完成了从CLIP到VLM Captioning的跨越:

进阶篇核心成果

步骤核心能力技术亮点
步骤7VLM描述生成自适应选择器(成本优化)、缓存机制
步骤8基于描述的向量索引ImageNode、text-embedding-3-small
步骤9BM25关键词索引精确关键词匹配
步骤10QueryFusionRetriever混合检索RRF融合算法

  关键认知回顾

  1. VLM弥补CLIP短板:通过生成描述,将"视觉信息"转化为"文本信息",支持关键词精确匹配

  2. 混合检索优于单一方法:向量检索(语义)+ BM25(关键词)+ RRF(融合),三者结合更鲁棒

  3. 成本意识:VLM选择器自动平衡成本与效果,缓存机制避免重复付费

四、 Qwen3-VL黄金架构:从召回到精排 链接到标题

  阿里巴巴通义实验室发布了 Qwen3-VL-EmbeddingQwen3-VL-Reranker 系列模型,标志着多模态检索进入了新时代。这套方案的核心创新在于:用单一稠密向量统一表示图像、文本、视频甚至图文混合内容,同时通过专门的重排序模型(Reranker)弥补向量压缩带来的精度损失。

核心架构

┌──────────────────────────────────────────────────────────────┐
│                    Qwen3-VL 统一嵌入空间                      │
│  ┌─────────┐  ┌─────────┐  ┌─────────┐  ┌─────────┐         │
│  │  文本    │  │  图片   │   │  视频   │  │图文混合  │         │
│  └────┬────┘  └────┬────┘  └────┬────┘  └────┬────┘         │
│       └───────────┴───────────┴───────────┘                 │
│                        ↓                                     │
│              统一稠密向量 (4096维,可 MRL 压缩至 512 维)       │
└──────────────────────────────────────────────────────────────┘

为什么 Qwen3-VL 成为新标准?

  Qwen3-VL-Embedding是阿里开源的多模态嵌入模型,核心特点:

  • 统一向量空间:文本和图像都映射到512维向量空间

  • 中文优化:专门针对中文场景优化

  • 高质量:比CLIP更理解图片中的文字和复杂语义

  我们需要回顾一下之前被视为"最佳实践"的 ColPali 方案。ColPali 采用多向量表示(每张图片生成约 1024 个向量),虽然保留了细粒度信息,但带来了严重的存储爆炸问题

Qwen3-VL vs ColPali 存储成本对比(1000 万页文档)

方案向量数量单页存储总存储需求
Qwen3-VL (4096维)1000 万16 KB160 GB
Qwen3-VL (MRL 512维)1000 万2 KB20 GB
ColPali (1024向量 * 128 维/页)102.4 亿512 KB5 TB

  数据说明一切:ColPali 的存储需求是 Qwen3-VL 的 30-250 倍。在企业级场景下,这种存储膨胀几乎无法接受。

Qwen3-VL 的另一大杀手锏:视频检索

  与 CLIP 和 ColPali 都无法处理视频不同,Qwen3-VL 原生支持长达 16 帧的视频输入。这意味着我们可以用"找出一个人跌倒的片段"这样的自然语言查询,直接检索企业视频资产——这是其他方案完全无法实现的能力。

1. 双阶段架构:Embedding + Reranker 链接到标题

  Qwen3-VL 能够在存储成本极低的情况下达到高精度,核心秘密在于其双阶段架构:先用 Qwen3-VL-Embedding 快速召回候选,再用 Qwen3-VL-Reranker 精确重排序。

第一阶段:Embedding 快速召回

  Embedding 模型将查询和所有候选文档都编码为稠密向量,通过 ANN(近似最近邻)算法快速召回 Top-50 候选。这一阶段追求的是高召回率低延迟(通常 <50ms)。

第二阶段:Reranker 精确排序

  Reranker 采用交叉编码器架构,将查询和每个候选文档"拼接"在一起,让 Transformer 的自注意力机制对它们进行深度交互。这种全关注机制能够捕捉到极其细微的图文对应关系——例如,当查询是"表格第三行的数据"时,Embedding 可能只能找到"含有表格的文档",而 Reranker 能精准判断该文档是否包含"第三行"这一细节。

  根据 MMEB-v2 基准测试数据,引入 Reranker 后,Qwen3-VL 在视觉文档任务上的得分从 79.2 飙升至 86.3——这 7 分的提升,正是 Reranker 的价值所在。

2. 企业级黄金架构 链接到标题

  在实际的企业级部署中,我们推荐采用以下"黄金架构"来构建多模态检索系统:

架构分层说明

  1. 用户查询入口:接收文本查询或图片查询

  2. 第一阶段 - 多路召回

    • Qwen3-VL-Embedding:语义召回 Top-30

    • BM25:关键词精确匹配 Top-30

  3. 融合阶段:使用 RRF(Reciprocal Rank Fusion)算法合并去重

  4. 第二阶段 - 精排:Qwen3-VL-Reranker 对 Top-50 精排,输出 Top-5

  5. 生成阶段(可选):将 Top-5 图文送入 LLM 生成最终答案

  这个架构的设计理念是:用低成本手段(Embedding + BM25)保证召回率,用高成本手段(Reranker)保证精确率。这样既能应对大规模数据,又能保证最终结果的质量。

4.2 Qwen3-VL-Embedding模型封装 链接到标题

1. 从ModelScope下载模型 链接到标题

  Qwen3-VL模型较大(约4GB),我们使用ModelScope进行下载:

from modelscope import snapshot_download
from pathlib import Path

MODEL_CACHE_DIR = Path("./models")
MODEL_CACHE_DIR.mkdir(exist_ok=True)

EMBEDDING_MODEL_MS = "qwen/Qwen3-VL-Embedding-2B"

# 下载模型
print(f"\n📥 从 ModelScope 下载: {EMBEDDING_MODEL_MS}")
model_dir = snapshot_download(
    EMBEDDING_MODEL_MS,
    cache_dir=str(MODEL_CACHE_DIR),
    revision='master'
)
print(f"✅ 下载完成: {model_dir}")

  下载说明

  • 首次运行:约需5-10分钟(取决于网速)

  • 后续运行:使用缓存,秒级加载

  • 模型大小:约4GB(包含权重和Processor)

2. Qwen3VLEmbedding类实现 链接到标题

  我们封装一个Qwen3VLEmbedding类,统一处理文本和图像的嵌入:

import torch
import numpy as np
from transformers import AutoModel, AutoProcessor
from qwen_vl_utils import process_vision_info
from PIL import Image

class Qwen3VLEmbedding:
    """Qwen3-VL-Embedding-2B 封装"""
    
    def __init__(self, model_path: str = None, output_dim: int = 512, device: str = "auto"):
        self.output_dim = output_dim
        self.process_vision_info = process_vision_info
        
        print(f"\n🚀 初始化 Qwen3-VL-Embedding (输出维度: {output_dim})")
        
        # 自动选择设备
        if device == "auto":
            if torch.cuda.is_available():
                self.device, dtype = "cuda", torch.bfloat16
            elif torch.backends.mps.is_available():
                self.device, dtype = "mps", torch.float16
            else:
                self.device, dtype = "cpu", torch.float32
        else:
            self.device = device
            dtype = torch.float16 if device != "cpu" else torch.float32
        
        print(f"   设备: {self.device}")
        
        # 加载模型
        if model_path is None:
            model_path = model_dir  # 使用下载的模型路径
        
        self.processor = AutoProcessor.from_pretrained(model_path, trust_remote_code=True)
        self.model = AutoModel.from_pretrained(
            model_path,
            trust_remote_code=True,
            torch_dtype=dtype,
            device_map="auto" if self.device == "cuda" else None
        )
        
        if self.device != "cuda":
            self.model = self.model.to(self.device)
        self.model.eval()
        
        print("✅ Qwen3-VL-Embedding 加载完成")
    
    def _get_embedding_from_model(self, messages: list) -> np.ndarray:
        """从模型获取嵌入向量"""
        # 处理消息
        text = self.processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=False)
        image_inputs, video_inputs = self.process_vision_info(messages)
        
        # 编码
        inputs = self.processor(
            text=[text],
            images=image_inputs,
            videos=video_inputs,
            return_tensors="pt",
            padding=True
        ).to(self.device)
        
        # 推理
        with torch.no_grad():
            outputs = self.model(**inputs, output_hidden_states=True)
            embedding = outputs.hidden_states[-1].mean(dim=1).squeeze()
        
        # 转换为numpy
        embedding = embedding.cpu().numpy().astype(np.float32)
        
        # 截断到指定维度
        if self.output_dim and len(embedding) > self.output_dim:
            embedding = embedding[:self.output_dim]
        
        return embedding
    
    def encode_text(self, text: str) -> np.ndarray:
        """编码文本"""
        messages = [{
            "role": "user",
            "content": [{"type": "text", "text": text}]
        }]
        return self._get_embedding_from_model(messages)
    
    def encode_image(self, image_path: str) -> np.ndarray:
        """编码图像"""
        image = Image.open(image_path).convert("RGB")
        messages = [{
            "role": "user",
            "content": [
                {"type": "image", "image": image},
                {"type": "text", "text": "Describe this image."}
            ]
        }]
        return self._get_embedding_from_model(messages)

# 初始化
embedder = Qwen3VLEmbedding(output_dim=512)

  代码亮点

  1. 自动设备选择:优先使用CUDA,其次MPS(Mac),最后CPU

  2. 统一接口encode_text()encode_image()返回相同维度的向量

  3. 维度截断:支持灵活指定输出维度(默认512)

  测试向量对齐

# 测试文本向量
text_vec = embedder.encode_text("住宅的平面图,显示了房间的布局和尺寸")
print(f"文本向量维度: {len(text_vec)}")  # 512

# 测试图像向量  
img_vec = embedder.encode_image("images/20251216110526_92_50.png")
print(f"图像向量维度: {len(img_vec)}")  # 512

# 计算余弦相似度
from numpy import dot
from numpy.linalg import norm
similarity = dot(text_vec, img_vec) / (norm(text_vec) * norm(img_vec))
print(f"文本-图像相似度: {similarity:.4f}")  # 约0.20-0.35

分数的绝对值不代表绝对相关性

  • 在 Embedding 领域,特别是多模态对齐模型(如 CLIP, Qwen-VL),不同模态(文本与图像)的向量分布通常生活在超球面的不同子区域。

  • 即使是“完全匹配”的图文对,其 Cosine Similarity 往往也只有 0.25 - 0.35 左右。这是业界的常态。

  • 相对排名比绝对数值更重要:只要相关图片的得分明显高于不相关图片(例如 0.23 vs 0.15),检索就是成功的。

CLIP vs Qwen3-VL 的差异

  • CLIP (512维):OpenAI 的 CLIP 原始模型是经过特定可对比学习(Contrastive Learning)训练的,它强行把图文向量拉得很近,所以在某些简单场景下,你可能会看到 0.3 甚至更高的分数。

  • Qwen3-VL (1024/1536维):Qwen 的视觉编码器更深、维度更高,它捕捉的特征更细腻(如对“布局”、“尺寸”等细节的理解),但其 Embedding 空间的分布可能更加稀疏。

4.3 LlamaIndex MultiModalEmbedding适配器 链接到标题

  为了让Qwen3-VL无缝集成到LlamaIndex框架,我们需要实现MultiModalEmbedding适配器。

1. 适配器实现 链接到标题

from llama_index.core.embeddings import MultiModalEmbedding
from typing import List

class Qwen3VLMultiModalEmbedding(MultiModalEmbedding):
    """
    Qwen3-VL 的 LlamaIndex 多模态适配器
    
    该类将自定义的 Qwen3VLEmbedding 封装为 LlamaIndex 标准的 MultiModalEmbedding 接口,
    使得 Qwen3-VL 模型可以无缝集成到 LlamaIndex 的多模态 RAG 工作流中。
    """
    
    # Pydantic 模型配置:允许任意类型(用于存储非 Pydantic 基本类型的 qwen3_embedder 实例)
    model_config = {"arbitrary_types_allowed": True, "extra": "allow"}
    
    def __init__(self, qwen3_embedder: Qwen3VLEmbedding):
        """
        初始化适配器
        
        Args:
            qwen3_embedder: 底层的 Qwen3VLEmbedding 推理实例
        """
        super().__init__(
            embed_batch_size=1,
            model_name="Qwen3-VL-Embedding-2B"
        )
        # 使用 object.__setattr__ 绕过 Pydantic 的属性校验,确保底层实例被正确存储
        object.__setattr__(self, 'qwen3_embedder', qwen3_embedder)
    
    def _get_text_embedding(self, text: str) -> List[float]:
        """
        获取文本的向量表示
        
        Args:
            text: 输入的文本字符串
        Returns:
            List[float]: 文本嵌入向量
        """
        return self.qwen3_embedder.encode_text(text).tolist()
    
    def _get_query_embedding(self, query: str) -> List[float]:
        """
        获取查询文本的向量表示(通常用于检索阶段)
        
        Args:
            query: 输入的查询字符串
        Returns:
            List[float]: 查询嵌入向量
        """
        return self.qwen3_embedder.encode_text(query).tolist()
    
    def _get_image_embedding(self, img_file_path: str) -> List[float]:
        """
        获取图像的向量表示
        
        Args:
            img_file_path: 本地图像文件的路径
        Returns:
            List[float]: 图像嵌入向量
        """
        return self.qwen3_embedder.encode_image(img_file_path).tolist()
    
    # 异步接口实现:目前通过调用同步方法实现
    async def _aget_text_embedding(self, text: str) -> List[float]:
        """异步获取文本嵌入"""
        return self._get_text_embedding(text)
    
    async def _aget_query_embedding(self, query: str) -> List[float]:
        """异步获取查询嵌入"""
        return self._get_query_embedding(query)
    
    async def _aget_image_embedding(self, img_file_path: str) -> List[float]:
        """异步获取图像嵌入"""
        return self._get_image_embedding(img_file_path)

# 实例化适配器并传入底层的推理模型实例
embed_model = Qwen3VLMultiModalEmbedding(embedder)
print(f"✅ 适配器初始化成功: {embed_model.model_name}")

# 设置全局嵌入模型,需要将之前的模型覆盖掉
Settings.embed_model = embed_model
Settings.image_embed_model = embed_model

  适配器作用

  • Qwen3VLEmbedding封装为LlamaIndex标准接口

  • 支持同步和异步调用

  • 可直接用于MultiModalVectorStoreIndex

4.2.2 替换CLIP构建索引 链接到标题

  现在我们用Qwen3-VL替换CLIP来构建索引:

# ========================================================
# 构建 Caption 文本索引(qwen3_vl_text)
# ========================================================

from llama_index.core import VectorStoreIndex, Document,StorageContext
from llama_index.core.node_parser import SentenceSplitter
from llama_index.vector_stores.milvus import MilvusVectorStore


MILVUS_URI = "http://localhost:19530"

# 1. 确保已经生成了 captions(需要先运行 3.3 节生成 Caption)
if 'captions' not in globals():
    print("❌ 请先运行 3.3 节生成图片的 Caption 描述")
else:
    print(f"📝 使用已生成的 {len(captions)} 条 Caption")
    
    # 2. 将 Caption 转换为 Document
    caption_documents = []
    for item in captions:
        doc = Document(
            text=item["caption"],
            metadata={
                "image_path": item["image_path"],
                "file_name": item["image_name"]
            }
        )
        caption_documents.append(doc)
    
    print(f"   创建了 {len(caption_documents)} 个 Caption Document")
    
    # 3. 配置 Milvus
    text_store = MilvusVectorStore(
        uri=MILVUS_URI,
        collection_name="qwen3_vl_text",
        dim=512,  # Qwen3-VL 维度
        overwrite=True
    )
    
    # 4. 构建文本索引(使用 Qwen3-VL embed_model)
    caption_index = VectorStoreIndex.from_documents(
        caption_documents,
        storage_context=StorageContext.from_defaults(vector_store=text_store),
        embed_model=embed_model  # ⭐ 使用 Qwen3-VL 文本编码
    )
    
    print("✅ Caption 文本索引构建完成")
from llama_index.core import SimpleDirectoryReader, StorageContext
from llama_index.core.indices import MultiModalVectorStoreIndex
from llama_index.vector_stores.milvus import MilvusVectorStore

# 1. 加载图片
documents = SimpleDirectoryReader(
    input_dir="images",
    required_exts=[".png", ".jpg", ".jpeg"]
).load_data()
print(f"📷 加载了 {len(documents)} 张图片")

# 2. 配置Milvus(双索引)
MILVUS_URI = "http://localhost:19530"

# 初始化图像向量存储
image_store = MilvusVectorStore(
    uri=MILVUS_URI,
    collection_name="qwen3_vl_image",
    dim=512,
    overwrite=True
)

# 创建存储上下文,同时配置文本和图像向量库
storage_context = StorageContext.from_defaults(
    vector_store=text_store,
    image_store=image_store
)

# 3. 构建索引(使用Qwen3-VL)
index = MultiModalVectorStoreIndex.from_documents(
    documents,
    storage_context=storage_context,
    embed_model=embed_model,
    image_embed_model=embed_model  # ⭐ 使用Qwen3-VL
)
print("✅ Qwen3-VL索引构建完成")
# 创建检索器
retriever = index.as_retriever(
    similarity_top_k=3,
    image_similarity_top_k=3
)

# 执行文搜图检索
query = "房屋架构图"
results = retriever.text_to_image_retrieve(query)

print(f"\n🔍 查询: \"{query}\"")
print("-" * 50)
for i, result in enumerate(results, 1):
    file_path = result.node.metadata.get("file_path", "未知")
    score = result.score if hasattr(result, 'score') else 0.0
    print(f"  [{i}] {Path(file_path).name}: {score:.4f}")

# 3. 显示结果
display_retrieval_results(results, title="文搜图: 架构图", max_display=6)

  对比CLIP版本的改动

  • dim=512 (CLIP也是512,维度相同)

  • image_embed_model=embed_model (换成Qwen3-VL适配器)

  • 效果提升:召回精度稍有提升约20%

4.3 Qwen3-VL-Reranker精排器 链接到标题

  想要在召回的基础上,在增加精度。这时就需要Reranker(精排器)登场。

4.3.1 Qwen3VLNodePostprocessor 链接到标题

  LlamaIndex提供了BaseNodePostprocessor接口,我们将Qwen3-VL-Reranker封装为标准的后处理器:

from llama_index.core.postprocessor.types import BaseNodePostprocessor
from llama_index.core.schema import NodeWithScore, QueryBundle
from llama_index.core import QueryBundle
from typing import List, Optional, Any
from pydantic import Field
from pathlib import Path
import sys
import importlib.util

class Qwen3VLNodePostprocessor(BaseNodePostprocessor):
    """Qwen3-VL-Reranker-2B 的 LlamaIndex NodePostprocessor 实现,用于多模态精排"""
    
    top_n: int = Field(default=5, description="精排后返回的前 N 个结果数量")
    model: Any = Field(default=None, description="Reranker 模型实例")
    image_base_dir: str = Field(default="images", 
                                description="图片存储的基础目录,用于解析相对路径")
    
    def __init__(self, model_path: str = None, top_n: int = 5, 
                 image_base_dir: str = "images", **kwargs):
        """初始化 Reranker,加载模型并注入官方脚本"""
        super().__init__(top_n=top_n, image_base_dir=image_base_dir, **kwargs)
        
        print(f"\n🔧 加载 Qwen3-VL-Reranker-2B (NodePostprocessor)")
        
        # 1. 确定模型路径,若未提供则从 ModelScope 下载
        if model_path is None:
            RERANKER_MODEL_MS = "qwen/Qwen3-VL-Reranker-2B"
            model_path = snapshot_download(RERANKER_MODEL_MS, cache_dir=str(MODEL_CACHE_DIR))
        
        # 2. 动态加载模型权重目录下的官方推理脚本
        script_path = Path(model_path) / "scripts" / "qwen3_vl_reranker.py"
        
        if not script_path.exists():
            raise FileNotFoundError(f"未找到官方脚本: {script_path}")
        
        # 使用 importlib 动态导入本地模块
        spec = importlib.util.spec_from_file_location("qwen3_vl_reranker_script", script_path)
        reranker_module = importlib.util.module_from_spec(spec)
        sys.modules["qwen3_vl_reranker_script"] = reranker_module
        spec.loader.exec_module(reranker_module)
        
        # 3. 实例化官方 Reranker 类
        OfficialReranker = reranker_module.Qwen3VLReranker
        # 使用 object.__setattr__ 绕过 Pydantic 对 model 字段的直接赋值限制
        object.__setattr__(self, 'model', OfficialReranker(model_name_or_path=model_path))
        
        print("✅ Reranker 加载完成 (BaseNodePostprocessor)")
    
    @classmethod
    def class_name(cls) -> str:
        return "Qwen3VLNodePostprocessor"
    
    def _get_image_path(self, node: NodeWithScore) -> Optional[str]:
        """从节点中提取图片路径,支持多种 metadata 键名和路径解析逻辑"""
        # 1. 检查节点对象属性
        if hasattr(node.node, 'image_path'):
            return str(Path(node.node.image_path).resolve())
        
        metadata = node.node.metadata
        
        # 2. 检查 metadata 中的 file_path
        if 'file_path' in metadata:
            return str(Path(metadata['file_path']).resolve())
        
        # 3. 检查 metadata 中的 image_path
        if 'image_path' in metadata:
            image_path = metadata['image_path']
            path_obj = Path(image_path)
            if path_obj.is_absolute() or path_obj.exists():
                return str(path_obj.resolve())
            # 尝试从 base_dir 解析相对路径
            full_path = Path(self.image_base_dir) / path_obj
            if full_path.exists():
                return str(full_path.resolve())
            return image_path
        
        # 4. 检查 metadata 中的 file_name,结合 base_dir
        if 'file_name' in metadata:
            file_name = metadata['file_name']
            full_path = Path(self.image_base_dir) / file_name
            return str(full_path.resolve())
        
        return None
    
    def _postprocess_nodes(
        self,
        nodes: List[NodeWithScore],
        query_bundle: Optional[QueryBundle] = None
    ) -> List[NodeWithScore]:
        """
        LlamaIndex 标准后处理接口实现:调用模型并根据分数重新排序
        
        支持两种查询模式:
        - 文搜图:query_str 为文本描述
        - 图搜图:query_str 为图片路径
        """
        
        if not nodes:
            return []
        
        if query_bundle is None:
            return nodes[:self.top_n]
        
        query_str = query_bundle.query_str
        
        # 判断查询类型:图片路径 or 文本
        query_path = Path(query_str)
        is_image_query = (
            query_path.exists() and 
            query_path.suffix.lower() in {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp'}
        )
        
        query_type = "图搜图" if is_image_query else "文搜图"
        print(f"\n✨ VLM 精排 [{query_type}] ({len(nodes)}{self.top_n})")
        
        # 1. 构造官方 Reranker 的输入文档列表
        documents = []
        valid_indices = []
        
        for i, node in enumerate(nodes):
            image_path = self._get_image_path(node)
            
            if image_path is None:
                print(f"节点 {i+1} 缺少图片路径,跳过")
                continue
            
            documents.append({"image": image_path})
            valid_indices.append(i)
        
        if not documents:
            print("❌ 没有有效的图片节点,跳过精排")
            return nodes[:self.top_n]
        
        print(f"   📊 成功提取 {len(documents)}/{len(nodes)} 个图片路径")
        
        # 2. ⭐ 根据查询类型构造不同的 query 输入
        if is_image_query:
            query_input = {"image": str(query_path.resolve())}
            print(f"    查询图片: {query_path.name}")
        else:
            query_input = {"text": query_str}
            print(f"    查询文本: {query_str[:50]}...")
        
        # 3. 构造推理输入字典
        inputs = {
            "instruction": "Retrieve images relevant to the query.",
            "query": query_input,  # ⭐ 支持图片或文本
            "documents": documents,
            "fps": 1.0
        }
        
        try:
            # 4. 执行模型推理
            scores = self.model.process(inputs)
            
            # 5. 更新有效节点分数
            for i, score in enumerate(scores):
                original_idx = valid_indices[i]
                nodes[original_idx].score = float(score)
            
            # 6. 无效节点分数清零
            for i in range(len(nodes)):
                if i not in valid_indices:
                    nodes[i].score = 0.0
            
            # 7. 根据新分数进行降序排序
            nodes.sort(key=lambda x: x.score or 0.0, reverse=True)
            
            score_list = [f"{n.score:.4f}" for n in nodes[:self.top_n]]
            print(f"\n   📊 Top-{self.top_n} 精排分数: {score_list}")
            print(f"   ✅ VLM精排完成")
            
            return nodes[:self.top_n]
            
        except Exception as e:
            print(f"❌ Reranker 失败: {e}")
            return nodes[:self.top_n]

# 重新初始化 Reranker 实例,设置默认为5个结果
reranker = Qwen3VLNodePostprocessor(top_n=5)

  Reranker工作流程

  1. 接收召回的候选结果(如50张图片)

  2. 对每张图片与查询进行精细打分

  3. 按分数重新排序

  4. 返回Top-N结果

4.3.2 精排前后效果对比 链接到标题

  我们对比加入Reranker前后的分数变化:

# 创建检索器
retriever = index.as_retriever(image_similarity_top_k=8)  # 召回15张

# 执行检索(无精排)
query = "房屋的平面布局图"
results_before = retriever.text_to_image_retrieve(query)

print("\n【召回阶段】Qwen3-VL Embedding召回:")
for i, r in enumerate(results_before[:5], 1):
    file_name = Path(r.node.metadata['file_path']).name
    print(f"  [{i}] {file_name}: {r.score:.4f}")

# 显示结果
display_retrieval_results(results_before, title=f"Retrieval: Embedding召回", max_display=6)
# 添加Reranker精排
from llama_index.core.query_engine import RetrieverQueryEngine
from llama_index.core import QueryBundle

query_engine = RetrieverQueryEngine(
    retriever=retriever,  # 初始检索器
    node_postprocessors=[reranker]  # 节点后处理器(Reranker精排)
)

# 执行检索(有精排)
response = query_engine.query(query)
results_after = response.source_nodes

print("\n【精排阶段】Qwen3-VL Reranker精排后:")
for i, r in enumerate(results_after, 1):
    file_name = Path(r.node.metadata['file_path']).name
    print(f"  [{i}] {file_name}: {r.score:.4f}")

# 显示结果
display_retrieval_results(results_after, title=f"Retrieval: Qwen3-VL Reranker精排后", max_display=6)

  关键发现

  • 分数计算机制

    • 召回阶段:

      • Milvus 向量检索返回的是 Inner Product (IP) 分数

      • 对于 Qwen3-VL (1024维,未归一化向量):IP(内积) = sum(query_vec[i] * doc_vec[i])

      • 值域: 可以是任意实数,通常在 [0, 100] 范围内

    • 精排阶段:

      • Qwen3-VL-Reranker-2B 是一个交叉编码器模型

      • 它直接对 (query, image) 配对进行打分

      • 与召回阶段分数不兼容,需要转换或重新排序,输出的是相关性概率或logits,通常归一化到 [0, 1],这是 Cross-Encoder 模型的标准输出范围

  • 排序优化

    • 召回阶段:只看相对排名,绝对值无意义

    • 精排阶段:参考下面的概率区间解读

      • 0.7+ 的分数已达到生产环境标准

      • 0.6+ : 非常相关 (Very Relevant)

      • 0.4-0.6 : 相关 (Relevant) ← 您的结果在这里

      • 0.2-0.4 : 弱相关 (Weakly Relevant)

      • 0.0-0.2 : 不相关 (Not Relevant)

4.4 双索引架构(文本+图像分离) 链接到标题

  在MultiModalVectorStoreIndex内部,LlamaIndex使用了双索引架构

  • text_store:存储文本向量(如图片的描述文本)

  • image_store:存储图像向量

4.4.1 双索引的作用 链接到标题

  为什么要分离?

双索引架构对比

架构优势劣势适用场景
单索引(混合存储)简单,无需额外配置文本和图像向量混在一起,检索时需要额外过滤小规模演示
双索引(分离存储)检索时可指定模态,更高效配置稍复杂生产环境

  伪代码实现(不用运行)(已在4.2.2中展示):

storage_context = StorageContext.from_defaults(
    vector_store=text_store,   # 文本索引
    image_store=image_store     # 图像索引
)

  检索时的模态选择

# 文搜图:查询文本向量 → 匹配图像向量
retriever.text_to_image_retrieve(query)

# 图搜图:查询图像向量 → 匹配图像向量
retriever.image_to_image_retrieve(image_path)

4.4.2 统一向量空间的优势 链接到标题

  Qwen3-VL的关键优势:文本和图像在同一个512维语义空间

  实验验证:

# 同一张图片的两种表示
image_path = "./architecture.png"
caption = "这是一张微服务架构图"

# 方式1:直接编码图片
img_vec = embedder.encode_image(image_path)

# 方式2:编码图片描述
text_vec = embedder.encode_text(caption)

# 计算相似度
similarity = dot(img_vec, text_vec) / (norm(img_vec) * norm(text_vec))
print(f"图片向量 vs 描述向量 相似度: {similarity:.4f}")
# 典型输出: 0.72 (非常高!)

  这意味着什么?

  • 用户查询"微服务架构图"时,既能匹配到图片本身(视觉特征),也能匹配到图片的文字描述(语义特征)

  • RRF融合时,图片向量和描述向量的分数可以直接比较(因为在同一空间)

  • 这正是批次二中VLM Captioning方案无法做到的(OpenAI Embedding和CLIP不在同一空间)

4.5 黄金架构完整实现 链接到标题

  现在我们整合所有组件,实现完整的多路召回 + RRF融合 + Reranker精排黄金架构。

4.5.1 三路召回架构 链接到标题

  黄金架构使用三路召回:

  实现代码

# ========================================================
# 🔧 诊断当前 embed_model 的维度
# ========================================================

print("\n" + "="*60)
print("🔍 诊断当前 embed_model")
print("="*60 + "\n")

# 1. 检查 embedder 是否存在
if 'embedder' in globals():
    test_vec = embedder.encode_text("测试")
    print(f"✅ embedder 输出维度: {len(test_vec)}")
else:
    print("❌ embedder 不存在")

# 2. 检查 embed_model 是否存在
if 'embed_model' in globals():
    test_vec = embed_model._get_text_embedding("测试")
    print(f"✅ embed_model 输出维度: {len(test_vec)}")
    
    if len(test_vec) != 512:
        print(f"   ⚠️ 警告:维度不是 512!这就是报错的原因")
    else:
        print(f"   ✅ 维度正确")
else:
    print("❌ embed_model 不存在")

print("="*60)

加载qwen3-vl已经存入milvus的集合数据 链接到标题

from llama_index.core.indices import MultiModalVectorStoreIndex

# 加载已有索引
vector_store = MilvusVectorStore(
    uri=MILVUS_URI,
    collection_name="qwen3_vl_text",
    dim=512,
    overwrite=False  # 不覆盖已有集合
)

image_store = MilvusVectorStore(
    uri=MILVUS_URI,
    collection_name="qwen3_vl_image",
    dim=512,
    overwrite=False  # 不覆盖已有集合
)

# 注意:这里直接使用上面定义好的 text_store 和 image_store
# 如果是完全重启环境,需要重新定义 MilvusVectorStore(..., overwrite=False)
index_reload = MultiModalVectorStoreIndex.from_vector_store(
    vector_store=text_store,
    image_vector_store=image_store,
    image_embed_model=embed_model
)
print("✅ 从 Milvus 加载已有索引")

qwen_retriever = index_reload.as_retriever()
# 文搜图
test_results = qwen_retriever.text_to_image_retrieve("衣服")

print(f"\n Milvus持久化索引检索结果 (Query: architecture diagram):")
for i, result in enumerate(test_results, 1):
    file_path = result.node.metadata.get("file_path", "未知")
    score = result.score if hasattr(result, 'score') else 0.0
    print(f"  [{i}] {Path(file_path).name}: {score:.4f}")

# 显示结果
display_retrieval_results(test_results, title=f"Retrieval: 文搜图", max_display=6)
# 图搜图
qwen_results = qwen_retriever.image_to_image_retrieve("images/20251216110347_90_50.png")

print(f"\n Milvus持久化索引检索结果 (Query: architecture diagram):")
for i, result in enumerate(qwen_results, 1):
    file_path = result.node.metadata.get("file_path", "未知")
    score = result.score if hasattr(result, 'score') else 0.0
    print(f"  [{i}] {Path(file_path).name}: {score:.4f}")

# 显示结果
display_retrieval_results(qwen_results, title=f"Retrieval: 图搜图", max_display=6)

LlamaIndex 的 MultiModalVectorStoreIndex.from_vector_store() 存在 Bug:

  • 即使您正确设置了 similarity_metric=“IP”

  • 但它创建的检索器内部没有正确使用这个参数

  • 导致检索时使用了错误的度量方式,文搜图错误,图搜图有时甚至返回 0 结果

  • 而自定义检索器直接使用 pymilvus,完全绕过了这个问题,成功返回 结果!

自定义Milvus 多模态检索器 链接到标题

from llama_index.core.retrievers import BaseRetriever
from llama_index.core.schema import NodeWithScore, ImageNode, QueryBundle
from pymilvus import connections, Collection
import numpy as np
from typing import List
from pathlib import Path

class DirectMilvusImageRetriever(BaseRetriever):
    """
    自定义 Milvus 多模态检索器
    支持:文本查询(文搜图)和图片查询(图搜图)
    """
    
    def __init__(
        self, 
        collection_name: str = "qwen3_vl_image", 
        embed_model = None, 
        uri: str = "http://localhost:19530",
        similarity_top_k: int = 10,
        normalize_scores: bool = True  # ⭐ 新增:是否归一化分数
    ):
        self.collection_name = collection_name
        self.embed_model = embed_model
        self.similarity_top_k = similarity_top_k
        self.normalize_scores = normalize_scores
        
        # 连接 Milvus
        connections.connect(uri=uri)
        self.collection = Collection(collection_name)
        self.collection.load()
        
        super().__init__()
    
    def _is_image_path(self, query_str: str) -> bool:
        """判断是否为图片路径"""
        path = Path(query_str)
        if path.exists() and path.is_file():
            return True
        image_exts = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'}
        if path.suffix.lower() in image_exts:
            return True
        return False
    
    def _normalize_scores(self, results: List[NodeWithScore]) -> List[NodeWithScore]:
        """
        归一化分数到 [0, 1] 范围
        使用 Min-Max 归一化
        """
        if not results:
            return results
        
        # 提取所有分数
        scores = [r.score for r in results]
        min_score = min(scores)
        max_score = max(scores)
        
        # 避免除以零
        score_range = max_score - min_score
        if score_range == 0:
            # 所有分数相同,全部设为 1.0
            for r in results:
                r.score = 1.0
        else:
            # Min-Max 归一化: (x - min) / (max - min)
            for r in results:
                r.score = (r.score - min_score) / score_range
        
        return results
    
    def _retrieve(self, query_bundle: QueryBundle) -> List[NodeWithScore]:
        """
        LlamaIndex 标准检索接口
        支持文本查询和图片查询
        """
        query_str = query_bundle.query_str
        
        # 1. 判断查询类型并生成 embedding
        if self._is_image_path(query_str):
            query_embedding = self.embed_model.get_image_embedding(query_str)
        else:
            query_embedding = self.embed_model.get_text_embedding(query_str)
        
        # 2. 搜索 Milvus
        search_params = {"metric_type": "IP", "params": {"nprobe": 10}}
        results = self.collection.search(
            data=[query_embedding],
            anns_field="embedding",
            param=search_params,
            limit=self.similarity_top_k,
            output_fields=["file_path", "file_name"]
        )
        
        # 3. 转换为 NodeWithScore
        nodes_with_scores = []
        for hit in results[0]:
            node = ImageNode(
                text="",
                image_path=hit.entity.get("file_path", ""),
                metadata={
                    "file_path": hit.entity.get("file_path", ""),
                    "file_name": hit.entity.get("file_name", "")
                }
            )
            node_with_score = NodeWithScore(node=node, score=float(hit.distance))
            nodes_with_scores.append(node_with_score)
        
        # 4. ⭐ 归一化分数(如果启用)
        if self.normalize_scores:
            nodes_with_scores = self._normalize_scores(nodes_with_scores)
        
        return nodes_with_scores
    
    def image_to_image_retrieve(self, image_path: str) -> List[NodeWithScore]:
        """
        兼容原有的 image_to_image_retrieve 接口
        """
        query_bundle = QueryBundle(query_str=image_path)
        return self._retrieve(query_bundle)


# 使用方法:
print("\n💡 使用自定义检索器...")
custom_retriever = DirectMilvusImageRetriever(
        collection_name="qwen3_vl_image",
        embed_model=embed_model,
        uri=MILVUS_URI,
        similarity_top_k=10  # 设置默认召回数量
    )

query_image = Path("images/20251216110347_90_50.png")
results = custom_retriever.image_to_image_retrieve(str(query_image))

print(f"📊 自定义检索结果: {len(results)} 条")
for i, result in enumerate(results, 1):
    print(f"  [{i}] {result.node.metadata.get('file_name', 'N/A')}: {result.score:.4f}")
from llama_index.core import VectorStoreIndex, StorageContext, Settings
from llama_index.vector_stores.milvus import MilvusVectorStore
from llama_index.core.indices import MultiModalVectorStoreIndex
from llama_index.retrievers.bm25 import BM25Retriever
from llama_index.core import Document
from llama_index.core.node_parser import SentenceSplitter

# 全局配置
MILVUS_URI = "http://localhost:19530"

# ========================================================
# ⚡ 关键修复:设置全局 embed_model
# ========================================================

# 这一步非常重要!确保所有 retriever 使用正确的 512 维 embed_model
print("\n设置全局 embed_model (512 维)...")

# 1. 重新初始化 embed_model(确保 512 维)
embedder = Qwen3VLEmbedding(output_dim=512)
embed_model = Qwen3VLMultiModalEmbedding(qwen3_embedder=embedder)

# 2. 设置全局 Settings.embed_model
Settings.embed_model = embed_model

# 3. 验证
test_vec = embed_model._get_text_embedding("测试")
print(f"全局 embed_model 设置完成,维度: {len(test_vec)}")

if len(test_vec) != 512:
    print(f" 警告:维度不是 512!({len(test_vec)})")
else:
    print("维度正确,可以安全使用")

# ========================================================
# 函数定义
# ========================================================
def build_qwen_index():
    """构建 Qwen3-VL 图片索引(返回标准检索器)"""
    print("\n🔧 构建 Qwen3-VL 图片索引...")
    
    # 创建自定义检索器(兼容 LlamaIndex 标准接口)
    retriever = DirectMilvusImageRetriever(
        collection_name="qwen3_vl_image",
        embed_model=embed_model,
        uri=MILVUS_URI,
        similarity_top_k=10  # 设置默认召回数量
    )
    
    print("✅ Qwen3-VL 图片索引构建完成")
    return retriever


def build_caption_index():
    """加载 Qwen3-VL 文本索引 (qwen3_vl_text)"""
    print("\n🔧 加载 Qwen3-VL 文本索引 (Milvus: qwen3_vl_text)...")
    
    vector_store = MilvusVectorStore(
        uri=MILVUS_URI,
        collection_name="qwen3_vl_text",
        dim=512,
        #similarity_metric="IP",
        overwrite=False
    )
    
    # ⭐ 使用 Settings.embed_model(已在上面设置为 512 维)
    index = VectorStoreIndex.from_vector_store(
        vector_store=vector_store,
        embed_model=Settings.embed_model
    )
    
    print("✅ Qwen3-VL 文本索引加载完成")
    return index

def build_bm25_index():
    """基于 captions 构建 BM25 检索器"""
    if 'captions' not in globals():
        print("❌ 全局变量 'captions' 不存在,请先运行 3.3 节生成描述代码")
        return None
    print("\n📊 构建 BM25 索引...")
    
    documents = [
        Document(
            text=item["caption"],
            metadata={
                "image_path": item["image_path"],
                "file_name": item["image_name"]
            }
        ) for item in captions
    ]
    
    splitter = SentenceSplitter(chunk_size=512)
    nodes = splitter.get_nodes_from_documents(documents)
    
    bm25_retriever = BM25Retriever.from_defaults(
        nodes=nodes,
        similarity_top_k=50
    )
    
    print("✅ BM25 索引构建完成")
    return bm25_retriever

检查目前全局的embed_model模型 链接到标题

embed_model.model_name

重新加载milvus数据 链接到标题

from pymilvus import connections, Collection, FieldSchema, CollectionSchema, DataType, utility
from pathlib import Path
import numpy as np

def rebuild_qwen_index_manually():
    """手动重建 Qwen3-VL 图片索引(完全绕过 LlamaIndex)"""
    print("\n🔧 手动重建 Qwen3-VL 图片索引...")
    
    # 1. 连接 Milvus
    connections.connect(uri=MILVUS_URI)
    
    collection_name = "qwen3_vl_image"
    
    # 2. 删除旧集合(如果存在)
    if utility.has_collection(collection_name):
        print(f"   删除旧集合: {collection_name}")
        utility.drop_collection(collection_name)
    
    # 3. 创建新集合 schema
    fields = [
        FieldSchema(name="id", dtype=DataType.VARCHAR, is_primary=True, max_length=100),
        FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=512),
        FieldSchema(name="file_path", dtype=DataType.VARCHAR, max_length=500),
        FieldSchema(name="file_name", dtype=DataType.VARCHAR, max_length=200),
    ]
    schema = CollectionSchema(fields, description="Qwen3-VL Image Index")
    
    print(f"   创建新集合: {collection_name}")
    collection = Collection(collection_name, schema)
    
    # 4. 创建索引(IP 度量)
    index_params = {
        "metric_type": "IP",
        "index_type": "FLAT",
        "params": {}
    }
    collection.create_index(field_name="embedding", index_params=index_params)
    print("   ✅ 索引创建完成")
    
    # 5. 加载图片并生成 embedding
    BASE_DIR = Path.cwd()
    image_dir = BASE_DIR / "images"
    image_files = list(image_dir.glob("*.png")) + list(image_dir.glob("*.jpg"))
    
    print(f"   找到 {len(image_files)} 张图片")
    
    # 6. 批量插入数据
    ids = []
    embeddings = []
    file_paths = []
    file_names = []
    
    print("   正在生成 embedding...")
    for i, img_path in enumerate(image_files, 1):
        print(f"     处理 [{i}/{len(image_files)}]: {img_path.name}", end='\r')
        
        # 生成 embedding
        emb = embed_model.get_image_embedding(str(img_path))
        
        ids.append(f"img_{i}")
        embeddings.append(emb)
        file_paths.append(str(img_path))
        file_names.append(img_path.name)
    
    print()  # 换行
    
    # 7. 插入数据
    print(f"   插入 {len(ids)} 条数据到 Milvus...")
    data = [ids, embeddings, file_paths, file_names]
    collection.insert(data)
    collection.flush()  # ⭐ 重要:强制刷新
    
    # 8. 验证插入结果
    collection.load()
    count = collection.num_entities
    print(f"   ✅ 验证:集合中有 {count} 条数据")
    
    if count != len(image_files):
        print(f"   ⚠️  警告:预期 {len(image_files)} 条,实际 {count} 条")
    
    print("✅ 索引重建完成!")
    
    # 9. 返回自定义检索器
    return DirectMilvusImageRetriever(
        collection_name=collection_name,
        embed_model=embed_model,
        uri=MILVUS_URI,
        similarity_top_k=10
    )


# 执行重建
qwen_retriever = rebuild_qwen_index_manually()

实现三路召回“文搜图” 链接到标题

from llama_index.core.retrievers import QueryFusionRetriever
from llama_index.core.query_engine import RetrieverQueryEngine
from llama_index.core import QueryBundle

# 1. 构建三路索引(代码见前面章节)
qwen_retriever = build_qwen_index()          # Qwen3-VL图片索引
caption_index = build_caption_index()    # Qwen3-VL描述索引
bm25_retriever = build_bm25_index()      # BM25索引

# 2. 创建三路检索器
# qwen_retriever = qwen_index.as_retriever(similarity_top_k=20)
caption_retriever = caption_index.as_retriever(similarity_top_k=20)
# bm25_retriever 已经是retriever

# 3. QueryFusionRetriever自动RRF融合
fusion_retriever = QueryFusionRetriever(
    retrievers=[qwen_retriever, caption_retriever, bm25_retriever],
    similarity_top_k=10,  # 融合后召回15张
    num_queries=1,        # 不生成query变体
    mode="reciprocal_rerank",  # ⭐ RRF算法
    use_async=False
)

# 4. 添加Reranker精排
query_engine = RetrieverQueryEngine(
    retriever=fusion_retriever,
    node_postprocessors=[reranker]  # ⭐ Reranker精排
)

# 5. 执行检索,三路混合检索,就不能使用text_to_image_retrieve了
query = "房屋架构图"
response = query_engine.query(query)
results = response.source_nodes

# 6. 展示结果
print(f"\n🎯 黄金架构最终结果 (Top-5):")
for i, r in enumerate(results, 1):
    file_name = r.node.metadata.get('file_name', 'unknown')
    print(f"  [{i}] {file_name}: {r.score:.4f}")
# 展示排名第一图片
display_local_image("images/20251216110347_90_50.png")

实现“图搜图” 链接到标题

from pathlib import Path

# 配置图片目录
BASE_DIR = Path.cwd()
image_dir = BASE_DIR / "images"  # 请替换为你的图片目录路径

# 临时方案:只用 qwen_retriever
image_files = list(image_dir.glob("*.png")) + list(image_dir.glob("*.jpg"))
if image_files:
    query_image = image_files[0]
    
    print(f"\n🔍 图搜图查询: {query_image.name}")
    print("-" * 50)
    
    # 直接使用 qwen_retriever(绕过融合)
    qwen_retriever = build_qwen_index()
    results = qwen_retriever.image_to_image_retrieve(str(query_image))

    print(f"📊 召回结果: {len(results)} 张图片")

    # 2. 精排阶段:构造 QueryBundle(使用图片路径作为query)
    query_bundle = QueryBundle(query_str=query_image)

    # 3. 调用 Reranker 精排
    reranked_results = reranker._postprocess_nodes(results, query_bundle)

    # 4. 展示结果
    for i, r in enumerate(reranked_results, 1):
        file_name = r.node.metadata.get('file_path', 'unknown')
        print(f"  [{i}] {Path(file_name).name}: {r.score:.4f}")
    
    print(f"📊 精排结果: {len(reranked_results)} 张图片")


    # 显示图片
    display_retrieval_results(reranked_results, title="图搜图", max_display=6)

小结 链接到标题

  在高级篇中,我们完成了从0.3到0.7+的跨越式提升:

高级篇核心成果

步骤核心能力技术亮点
4.1Qwen3-VL-Embedding模型封装ModelScope下载、512维统一向量空间
4.2LlamaIndex MultiModalEmbedding适配器框架化集成、替换CLIP
4.3Qwen3-VL-Reranker精排器BaseNodePostprocessor、分数提升2倍左右
4.4双索引架构文本/图像分离、统一向量空间验证
4.5黄金架构完整实现三路召回+RRF+Reranker

  关键认知回顾

  1. 不要跳过任何一环:Embedding(召回)和Reranker(精排)缺一不可

  2. 向量空间统一的价值:Qwen3-VL图文在同一空间,RRF融合更准确

  3. 两阶段架构的本质:快速筛选(召回)+ 精细排序(精排),性能与精度兼顾

  4. 企业级标准:0.7+分数、300ms+耗时、三路召回,三者缺一不可

4.5.2 三大路径全面对比 链接到标题

  在深入理解了三种技术方案后,让我们用一张对比表格进行总结:

多模态检索三大技术路径全面对比

维度CLIPVLM CaptioningQwen3-VL
自然图像检索⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
文档/图表检索⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
视频检索❌ 不支持❌ 不支持✅ 原生支持
图中文字识别❌ 不支持✅ 支持✅ 支持
索引速度~10ms/张2-5s/张~100ms/张
查询延迟<50ms<100ms<50ms + Rerank
存储成本1x1x1x(可压缩至 1/8)
中文场景依赖提示原生优秀
API 成本免费(开源)高(按量付费)免费(开源)

  从表格可以清晰看出:Qwen3-VL 在几乎所有维度上都达到了最优或接近最优,同时保持了极低的存储成本。这正是它被称为"2026 年新标准"的原因。

  • CLIP:速度极快、成本极低,但无法识别图中文字,适合海量自然图像

  • VLM Captioning:语义理解最深,但成本高、速度慢,适合小数据量高精度场景

  • Qwen3-VL:统一图/文/视频嵌入,配合 Reranker 达到 SOTA 精度,存储仅为 ColPali 的 1/30

  • 架构必要性:单靠Embedding或单靠Reranker都达不到这个效果,必须两阶段结合

五、 从检索到智能助理:Agentic RAG架构 链接到标题

  在前面三个批次中,我们完成了从CLIP到Qwen3-VL黄金架构的完整演进,已经能够实现0.7+的高精度检索。但是,这套系统仍然是被动的:

  • 不懂意图:用户说"找一张类似这个的图,但要红色的",系统不知道该用图搜图还是文搜图

  • 不会变通:检索结果不满意时,无法自动换关键词重试

  • 无法交互:每次查询都是孤立的,没有上下文记忆

  核心矛盾:我们构建的是一个"检索系统"(Retrieval System),而用户需要的是"智能助手"(AI Assistant)。

  本批次将引入Agent(智能代理)架构,使用LangChain 1.0的Agent能力,赋予检索系统一个"大脑",实现从Retrieval-Augmented Generation (RAG)Agentic RAG 的跨越。

5.1 传统RAG vs Agentic RAG架构对比 链接到标题

  先理解"被动检索"和"主动智能助理"的本质区别:

传统RAG vs Agentic RAG

核心维度传统RAG (批次1-3)Agentic RAG (批次4)
执行逻辑线性管道 (Pipeline)动态规划 (Loop)
意图识别❌ 硬编码(参数控制)LLM语义理解
工具使用单一(Retrieval)多工具自动路由
多轮对话❌ 无状态有记忆(Memory)
适用场景明确查询模糊、复杂、多步任务

  架构演进示意

5.1.1 大脑与手脚的分离设计 链接到标题

  Agentic RAG采用双层架构

  • 大脑层(LangChain Agent):负责"思考"

    • 理解用户意图

    • 决定调用哪个工具

    • 组合多个工具完成复杂任务

  • 手脚层(LlamaIndex检索引擎):负责"执行"

    • 将批次3的黄金架构封装为工具(Tools)

    • 对Agent透明,只提供简单的接口

5.2 LangChain工具封装 链接到标题

  Agent无法直接调用Python类,需要将检索能力封装为标准的LangChain Tool。

5.2.1 search_images工具定义 链接到标题

  这是核心工具,支持三种模式:文搜图、图搜图、图文混合搜:

from pathlib import Path
import json

CAPTION_CACHE_DIR = Path("./caption_cache")

def load_caption_cache() -> dict:
    """从缓存目录加载所有 Caption 到字典
    
    Returns:
        dict: {图片名: caption文本} 的映射
    """
    if not CAPTION_CACHE_DIR.exists():
        return {}
    
    cache = {}
    # 遍历所有缓存文件 (格式: 图片名_模型名.txt)
    for cache_file in CAPTION_CACHE_DIR.glob("*.txt"):
        # 从文件名中解析出原始图片名 (去掉 _模型名.txt 后缀)
        parts = cache_file.stem.rsplit("_", 1)  # 按最后一个下划线分割
        if len(parts) >= 1:
            image_stem = parts[0]  # 图片文件名(不含扩展名)
            caption = cache_file.read_text(encoding="utf-8")
            cache[image_stem] = caption
    
    return cache
from typing import Dict, List
from pathlib import Path
from pymilvus import connections, Collection, FieldSchema, CollectionSchema, DataType, utility
import numpy as np
from llama_index.retrievers.bm25 import BM25Retriever
from llama_index.core.schema import TextNode
from collections import defaultdict

# 全局配置
MILVUS_URI = "http://localhost:19530"

class Qwen3VLRetrievalEngine:
    """封装多模态检索逻辑,提供统一接口"""
    
    def __init__(self):
        self.embedder = None
        self.embed_adapter = None
        self.qwen_retriever = None  # ⭐ 改用自定义检索器
        self.caption_retriever = None
        self.bm25_retriever = None
        self.reranker = None
        self.caption_cache = {}
        
    def initialize(self, image_dir: Path, use_reranker: bool = True):
        """一键初始化所有组件"""
        print("\n⚙️  初始化检索引擎...")
        
        # 1. 加载 Embedder
        self.embedder = Qwen3VLEmbedding(output_dim=512)
        self.embed_adapter = Qwen3VLMultiModalEmbedding(self.embedder)
        
        # 2. 加载 Caption Cache
        self.caption_cache = load_caption_cache()
        print(f"   加载 {len(self.caption_cache)} 条 Caption 缓存")
        
        # 3. 加载图片
        image_dir = Path(image_dir)
        image_paths = list(image_dir.glob("*.png")) + list(image_dir.glob("*.jpg"))
        
        # 4. 构建索引
        self._build_indices(image_paths, image_dir)
        
        # 5. 加载 Reranker
        if use_reranker:
            self.reranker = Qwen3VLNodePostprocessor()
            
        print("✅ 检索引擎就绪")
        
    def _build_indices(self, image_paths: List[Path], image_dir: Path):
        """构建三路混合索引(手动方法)"""
        print("   构建三路索引...")
        
        # ============================================================
        # 路径1: Qwen-VL 图片索引(手动构建)
        # ============================================================
        print("   [1/3] 构建图片向量索引...")
        self._build_image_index(image_paths)
        
        # ============================================================
        # 路径2: Caption 索引(如果需要)
        # ============================================================
        if self.caption_cache:
            print("   [2/3] 构建 Caption 索引...")
            # 这里可以继续用手动方法,或者跳过
            # 暂时跳过,因为图片索引已经包含了 caption 信息
            pass
        
        # ============================================================
        # 路径3: BM25
        # ============================================================
        print("   [3/3] 构建 BM25 索引...")
        nodes = []
        for img_path in image_paths:
            caption = self.caption_cache.get(img_path.stem, "")
            text = f"{img_path.name} {caption}" if caption else img_path.name
            node = TextNode(
                text=text,
                metadata={"file_name": img_path.name, "file_path": str(img_path)}
            )
            nodes.append(node)
        
        self.bm25_retriever = BM25Retriever.from_defaults(
            nodes=nodes, 
            similarity_top_k=30
        )
        
    def _build_image_index(self, image_paths: List[Path]):
        """手动构建图片索引(避免 LlamaIndex 的字段冲突)"""
        connections.connect(uri=MILVUS_URI)
        
        collection_name = "qwen3_vl_image"
        
        # 删除旧集合
        if utility.has_collection(collection_name):
            utility.drop_collection(collection_name)
        
        # 创建集合
        fields = [
            FieldSchema(name="id", dtype=DataType.VARCHAR, is_primary=True, max_length=100),
            FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=512),
            FieldSchema(name="file_path", dtype=DataType.VARCHAR, max_length=500),
            FieldSchema(name="file_name", dtype=DataType.VARCHAR, max_length=200),
        ]
        schema = CollectionSchema(fields, description="Qwen3-VL Image Index")
        collection = Collection(collection_name, schema)
        
        # 创建索引
        index_params = {"metric_type": "IP", "index_type": "FLAT", "params": {}}
        collection.create_index(field_name="embedding", index_params=index_params)
        
        # 批量插入
        ids, embeddings, file_paths, file_names = [], [], [], []
        for i, img_path in enumerate(image_paths, 1):
            print(f"     处理 [{i}/{len(image_paths)}]: {img_path.name}", end='\r')
            emb = self.embed_adapter.get_image_embedding(str(img_path))
            ids.append(f"img_{i}")
            embeddings.append(emb)
            file_paths.append(str(img_path))
            file_names.append(img_path.name)
        
        print()  # 换行
        collection.insert([ids, embeddings, file_paths, file_names])
        collection.flush()
        collection.load()
        
        # 创建自定义检索器
        self.qwen_retriever = DirectMilvusImageRetriever(
            collection_name=collection_name,
            embed_model=self.embed_adapter,
            uri=MILVUS_URI,
            similarity_top_k=30,
            normalize_scores=True
        )
        
    def text_to_image_search(self, query: str, top_k: int = 5) -> List[Dict]:
        """文搜图: 三路召回 + RRF + Reranker"""
        print(f"🔍 文搜图: {query}")
        
        # 1. 图片向量召回
        image_results = self.qwen_retriever.image_to_image_retrieve(query)
        
        # 2. BM25 召回
        bm25_results = self.bm25_retriever.retrieve(query) if self.bm25_retriever else []
        
        # 3. RRF 融合
        node_scores = defaultdict(float)
        node_map = {}
        
        all_results = [image_results, bm25_results]
        for results in all_results:
            for rank, result in enumerate(results, 1):
                file_name = result.node.metadata.get("file_name", "")
                rrf_score = 1.0 / (60 + rank)
                node_scores[file_name] += rrf_score
                if file_name not in node_map:
                    node_map[file_name] = result
        
        sorted_items = sorted(node_scores.items(), key=lambda x: x[1], reverse=True)
        fused_results = [node_map[fn] for fn, _ in sorted_items]
        
        # 4. Reranker(如果启用)
        if self.reranker:
            from llama_index.core import QueryBundle
            fused_results = self.reranker.postprocess_nodes(
                fused_results[:top_k * 2], 
                QueryBundle(query_str=query)
            )
        
        return self._format_results(fused_results[:top_k])

    def image_to_image_search(self, image_path: str, query: str = None, top_k: int = 5) -> List[Dict]:
        """图搜图"""
        query_path = Path(image_path)
        print(f"🖼️ 图搜图: {query_path.name}")
        
        results = self.qwen_retriever.image_to_image_retrieve(str(query_path))
        
        # 过滤自身
        results = [r for r in results if Path(r.node.metadata['file_path']).resolve() != query_path.resolve()]
        
        # Reranker(如果有文本约束)
        if query and self.reranker:
            from llama_index.core import QueryBundle
            results = self.reranker.postprocess_nodes(
                results[:top_k * 2], 
                QueryBundle(query_str=query)
            )
        
        return self._format_results(results[:top_k])

    def _format_results(self, results) -> List[Dict]:
        return [
            {
                "rank": i,
                "file_name": res.node.metadata.get("file_name"),
                "image_path": res.node.metadata.get("file_path"),
                "score": res.score
            }
            for i, res in enumerate(results, 1)
        ]

# 使用
retrieval_engine = Qwen3VLRetrievalEngine()
retrieval_engine.initialize(image_dir="images")
from langchain.tools import tool
from typing import Optional
from pathlib import Path
import threading
# ============================================================
# 全局单例:检索引擎(只初始化一次)
# ============================================================
class SingletonRetrievalEngine:
    """单例模式的检索引擎"""
    _instance = None
    _lock = threading.Lock()
    
    def __new__(cls):
        if cls._instance is None:
            with cls._lock:
                if cls._instance is None:
                    cls._instance = super().__new__(cls)
                    cls._instance._initialized = False
        return cls._instance
    
    def initialize_once(self, image_dir: str):
        """确保只初始化一次"""
        if self._initialized:
            print("⚡ 检索引擎已初始化,跳过")
            return
        
        print("🔧 首次初始化检索引擎...")
        self.engine = Qwen3VLRetrievalEngine()
        self.engine.initialize(image_dir=image_dir, use_reranker=True)
        self._initialized = True
        print("✅ 初始化完成")
    
    def get_engine(self):
        """获取检索引擎实例"""
        if not self._initialized:
            raise RuntimeError("检索引擎未初始化,请先调用 initialize_once()")
        return self.engine
        
# 全局初始化(在工具定义前执行一次)
_retrieval_singleton = SingletonRetrievalEngine()
_retrieval_singleton.initialize_once(image_dir="images")

定义多模态检索Tool工具 链接到标题

from langchain.tools import tool
from typing import Optional, List
from pathlib import Path
import json

@tool
def search_images(
    query: Optional[str] = None, 
    image_path: Optional[str] = None, 
    top_k: int = 5,
    filter_type: Optional[str] = None,  # ⭐ 新增:过滤文件类型
    min_score: Optional[float] = None   # ⭐ 新增:最低分数阈值
) -> str:
    """
    多模态图像搜索工具(增强版)。
    
    支持三种模式:
    1. 文搜图: 只提供 query(如 "找架构图")
    2. 图搜图: 只提供 image_path(找相似图片)
    3. 图文混合: 同时提供(先视觉召回,再文本精排)
    
    Args:
        query: 可选,文本查询描述
        image_path: 可选,参考图片路径
        top_k: 返回结果数量,默认5
        filter_type: 可选,文件类型过滤(如 "png", "jpg")
        min_score: 可选,最低相似度阈值(0-1)
        
    Returns:
        JSON 格式的检索结果,包含:
        - mode: 检索模式
        - num_results: 结果数量
        - results: 结果列表(包含文件名、路径、分数)
        
    Examples:
        - search_images(query="房屋架构图", top_k=3)
        - search_images(image_path="/path/to/ref.png")
        - search_images(query="现代风格", image_path="/path/to/ref.png")
    """
    try:
        # 参数验证
        if not query and not image_path:
            return json.dumps({
                "error": "必须提供 query 或 image_path 至少一个参数"
            }, ensure_ascii=False)
        
        # 获取引擎
        engine = _retrieval_singleton.get_engine()
        
        # 执行检索
        if image_path and Path(image_path).exists():
            results = engine.image_to_image_search(
                image_path, 
                query=query,
                top_k=top_k * 2  # 先多召回,后过滤
            )
            mode = "图文混合" if query else "图搜图"
        elif query:
            results = engine.text_to_image_search(query, top_k=top_k * 2)
            mode = "文搜图"
        else:
            return json.dumps({
                "error": f"图片路径不存在: {image_path}"
            }, ensure_ascii=False)
        
        # ⭐ 应用过滤条件
        if filter_type:
            results = [
                r for r in results 
                if r["file_name"].lower().endswith(f".{filter_type.lower()}")
            ]
        
        if min_score is not None:
            results = [r for r in results if r["score"] >= min_score]
        
        # 截取 top_k
        results = results[:top_k]
        
        if not results:
            return json.dumps({
                "message": "未找到符合条件的图片"
            }, ensure_ascii=False)
        
        # 返回结果
        output = {
            "mode": mode,
            "query": query,
            "reference_image": image_path,
            "filters": {
                "type": filter_type,
                "min_score": min_score
            },
            "num_results": len(results),
            "results": results
        }
        
        return json.dumps(output, ensure_ascii=False, indent=2)
        
    except Exception as e:
        import traceback
        return json.dumps({
            "error": str(e),
            "traceback": traceback.format_exc()
        }, ensure_ascii=False)
# ============================================================
# 使用示例
# ============================================================

# 测试 1: 文搜图
print("测试 1: 文搜图")
result = search_images.invoke({"query": "架构图", "top_k": 3})
print(result)
# 测试 2: 图搜图
print("\n测试 2: 图搜图")
image_path = "/Users/mac/大模型资料/多模态RAG/七大场景llama-Index进阶实战/测试文档&图片/test_images/20251216110347_90_50.png"
result = search_images.invoke({"image_path": image_path, "top_k": 3})
print(result)
# 测试 3: 图文混合
print("\n测试 3: 图文混合")
result = search_images.invoke({
    "query": "现代风格的架构图",
    "image_path": image_path,
    "top_k": 3
})
print(result)

  设计亮点

  1. 单一入口,内部路由:Agent不需要纠结用哪个工具,search_images内部自动判断

  2. 图文混合检索:同时提供image_pathquery时,先用图片召回,再用文本精排

  3. 返回格式化字符串:Agent能理解的文本格式(而非Python对象)

5.2.2 describe_image工具定义 链接到标题

  第二个工具:让Agent能够理解图片内容。

import base64
from langchain_core.messages import HumanMessage
from pathlib import Path
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool

# 初始化LLM(全局,避免重复创建)
llm_vision = ChatOpenAI(model="gpt-4o", temperature=0)

@tool
def describe_image(image_path: str) -> str:
    """使用 GPT-4o 描述图片内容"""
    if not Path(image_path).exists():
        return "❌ 图片不存在"
    
    try:
        # 读取图片并编码
        with open(image_path, "rb") as image_file:
            encoded_string = base64.b64encode(image_file.read()).decode('utf-8')
        
        msg = HumanMessage(content=[
            {
                "type": "text",
                "text": "请详细描述这张图片的内容,包括主要物体、场景和显著特征。"
            },
            {
                "type": "image_url",
                "image_url": {"url": f"data:image/jpeg;base64,{encoded_string}"}
            }
        ])
        
        response = llm_vision.invoke([msg])
        return f"📸 图片描述 ({Path(image_path).name}):\n{response.content}"
        
    except Exception as e:
        return f"❌ 描述失败: {str(e)}"

# 测试工具
desc = describe_image.invoke({"image_path": "images/20251216110324_89_50.png"})
print(desc)

  应用场景

  • 用户:“先帮我看看这张图里是什么,然后找相似的”

  • Agent会先调用describe_image理解图片,再调用search_images检索

5.2.3 工具列表与Agent创建 链接到标题

  将两个工具组合并创建Agent:

from langchain.agents import create_agent
from langchain_openai import ChatOpenAI
from langgraph.checkpoint.memory import InMemorySaver

# 定义工具列表
tools = [search_images, describe_image]

# 定义System Prompt(Agent的"宪法")
system_prompt = """你是一个智能多模态检索助手。

工具使用策略:
1. 用户想找图片 -> 使用 search_images
2. 用户想理解图片 -> 使用 describe_image
3. 用户提供参考图片 + 文本描述 -> search_images同时传入两个参数

回复要求:
- 必须列出找到的图片路径
- 解释为什么这张图片符合要求
- 简洁专业
"""

# 初始化LLM(全局,避免每次创建Agent时重复初始化)
llm_agent = ChatOpenAI(model="gpt-4o", temperature=0)

agent = create_agent(
    model=llm_agent,
    tools=tools,
    system_prompt=system_prompt,
    checkpointer=InMemorySaver()  # ⭐ 记忆能力
)

print("✅ Agent创建完成")

  关键参数

  • system_prompt:定义Agent的行为准则,防止幻觉

  • checkpointer=InMemorySaver():赋予Agent记忆力,支持多轮对话

5.3 Agent构建与ReAct循环 链接到标题

  现在Agent已经创建好,我们来理解它的工作原理——ReAct(Reasoning + Acting)循环

5.3.1 ReAct循环可视化 链接到标题

  当用户发送一个查询时,Agent会进入一个循环:

  ReAct三步骤

  1. Observe(观察):用户说了什么?上下文有什么?工具返回了什么?

  2. Think(思考):我还缺什么信息?该用哪个工具?参数怎么设置?

  3. Act(行动):调用工具或返回答案

5.3.2 实际运行日志解析 链接到标题

  让我们看一个真实的Agent执行过程:

# 执行Agent
config = {"configurable": {"thread_id": "session_1"}}

for event in agent.stream(
    {"messages": [{"role": "user", "content": "找一张架构图"}]},
    config=config,
    stream_mode="values"
):
    if "messages" in event:
        msg = event["messages"][-1]
        
        # 工具调用
        if hasattr(msg, 'tool_calls') and msg.tool_calls:
            print(f"🤖 [Agent决策]: 调用工具 → {msg.tool_calls[0]['name']}")
            print(f"   参数: {msg.tool_calls[0]['args']}")
        
        # 最终回复
        elif msg.type == "ai":
            print(f"\n🤖: {msg.content}")

  关键观察

  • Agent理解了"找架构图"的意图,自动选择了search_images工具

  • 自动设置了正确的参数(query='架构图', image_path=None

  • 不仅返回结果,还主动解释并追问(体现智能性)

5.4 图文混合检索实战 链接到标题

  现在演示Agentic RAG的核心能力——图文混合检索。

5.4.1 视觉召回 + 文本精排 链接到标题

  用户需求:“找一张类似这个背包的图,但要红色的”

# 用户输入(包含参考图片路径)
user_query = """
找一张类似这个背包的图,但要红色的。
参考图片:images/Image_rm5lrzrm5lrzrm5l.png
"""

# Agent执行
for event in agent.stream(
    {"messages": [{"role": "user", "content": user_query}]},
    config=config,
    stream_mode="values"
):
    if "messages" in event:
        msg = event["messages"][-1]
        if msg.type == "ai" and msg.content:
            print(f"🤖: {msg.content}")

  技术解析

  1. Agent智能解析:从自然语言中提取了两个关键信息——参考图片路径和文本约束(“红色”)

  2. 工具内部路由search_images检测到同时提供了image_pathquery,自动触发图文混合模式

  3. 双重过滤:先用图片向量召回相似背包,再用Reranker根据"红色"精排

小结 链接到标题

  在智能体篇中,我们完成了从"检索系统"到"智能助手"的终极跨越:

智能体篇核心成果

步骤核心能力技术亮点
5.1传统RAG vs Agentic RAG对比双层架构设计(大脑+手脚)
5.2LangChain工具封装search_images、describe_image、单一入口路由
5.3Agent构建与ReAct循环create_agent、InMemorySaver记忆
5.4图文混合检索实战视觉召回+文本精排、多步任务拆解
5.5多轮对话演示上下文记忆、交互式对话

  关键认知回顾

  1. Agent不是万能的:适合复杂、模糊、多步任务,但简单查询用传统RAG更快

  2. 工具封装的艺术:单一入口+内部路由,降低Agent决策负担

  3. ReAct循环的价值:Observe-Think-Act,让AI真正"会思考"

  4. 记忆的重要性InMemorySaver+thread_id,赋予Agent人类般的对话能力

  生产环境考虑

  • 延迟:ReAct循环意味着多次LLM调用,延迟约1-3秒(vs 传统RAG的300ms)

  • 成本:每次查询约消耗0.01-0.05美元(GPT-4o API调用)

  • 可靠性:需要处理Agent幻觉、工具调用失败等边界情况

场景化选型 链接到标题

  技术选型从来不是"哪个最强就用哪个",而是"哪个最适合当前场景"。本章将结合企业实际需求,给出具体的选型建议,并介绍我们在后续实战中将采用的"黄金架构"。

技术选型决策树 链接到标题

  面对三种技术方案,如何快速决策?我们可以沿着以下决策路径进行判断:

在多模态检索场景中,技术选型需要根据具体业务场景做出精准决策。基于本课件的完整技术路径实践,我们总结出以下四大场景与对应的最优方案:

  1. 自然图像/电商场景 → CLIP 双编码器 对于电商"以图搜货"、社交媒体图片匹配等自然图像场景,CLIP 是首选方案。其优势在于:编码速度极快(~10ms/张)、模型完全开源免费、512 维向量存储成本低。但需注意其局限性:中文语义理解有限、对细粒度图文对应关系把握不足,适合对精度要求不高、强调速度与成本的海量图库粗筛场景。

  2. 文档/图表场景 → Qwen3-VL (Embedding + Reranker) 对于企业文档库、技术图表、建筑平面图等结构化视觉内容,Qwen3-VL 黄金架构是当前最优解。其核心是双阶段设计:先用 Qwen3-VL-Embedding 快速召回候选(支持三路融合:视觉向量 + 文本向量 + BM25 关键词),再用 Qwen3-VL-Reranker 精排 Top-K 结果。根据 MMEB-v2 基准测试,该方案在视觉文档任务上达到 86.3 分,且存储成本仅为 ColPali 的 1/30,实现了性能与成本的双重突破。

  3. 视频搜索场景 → Qwen3-VL (Video Mode) 对于安防监控、视频内容检索等场景,Qwen3-VL 的视频模式支持直接对视频帧进行编码和检索,无需预先切帧处理,显著降低工程复杂度。

  4. 小数据/可解释场景 → VLM Captioning 当数据量较小(<1000 张)且需要结果可解释性时,VLM 描述生成方案更具优势。通过 GPT-4o 等 VLM 为每张图片生成自然语言描述,再用传统文本检索(如 BM25 + 向量混合),可实现高可解释性检索——用户可直接看到匹配的描述文本,便于结果解释和调试。

最终建议:对于大多数企业级多模态 RAG 应用,推荐采用 Qwen3-VL + 三路召回 + Reranker 精排的黄金架构,在保证高精度的同时兼顾成本与可扩展性,在绝大多数场景下都能提供最佳的性价比。