如何使用 ColQwen2、Milvus 和 Qwen3.5 建立多模式 RAG

  • Engineering
March 06, 2026
Lumina Wang

現在,您可以將 PDF 上傳到任何現代的 LLM,並提出相關問題。對於少量的文件來說,這個方法很好用。但大多數 LLM 的上限都是幾百頁的上下文,所以大篇幅的語料根本不適合。即使符合要求,您也得花錢處理每個查詢的每頁內容。就同一個 500 頁的文件集提出一百個問題,你就得為 500 頁的文件付一百倍的費用。這很快就會變得昂貴。

Retrieval-augmented generation (RAG) 透過將編制索引與解答分開來解決這個問題。您只需對文件編碼一次,將表示法儲存在向量資料庫中,在查詢時只擷取最相關的頁面傳送給 LLM。模型會在每次查詢時讀取三頁,而不是您的整個語料庫。這使得在不斷成長的資料集中建立文件問與答非常實用。

本教學教導您使用三個公開授權的元件建立多模式 RAG 管道:

  • ColQwen2 將每個 PDF 頁面的影像編碼為多向量嵌入,取代傳統的 OCR 與文字分塊步驟。
  • Milvus會儲存這些向量,並在查詢時處理相似性搜尋,只擷取最相關的頁面。
  • Qwen3.5-397B-A17B會讀取擷取的頁面影像,並根據所見生成答案。

到最後,您將擁有一個可運作的系統,它能接收 PDF 和問題,找出最相關的頁面,並根據模型所看到的內容傳回答案。

什麼是多模式 RAG?

前面的介紹說明了為什麼 RAG 在規模上很重要。下一個問題是您需要哪一種 RAG,因為傳統的方法有一個盲點。

傳統的 RAG 擷取文件中的文字,將其嵌入為向量,在查詢時擷取最接近的匹配項目,然後將這些文字區塊傳送給 LLM。這對於具有簡潔格式的大量文字內容非常有效。但當您的文件包含下列內容時,就會出現問題

  • 表格,其意義取決於行列與標題之間的關係。
  • 圖表:資訊完全視覺化,沒有對應的文字。
  • 掃描文件或手寫筆記,OCR 輸出不可靠或不完整。

Multimodal RAG 以影像編碼取代文字擷取。您將每頁渲染為影像,使用視覺語言模型進行編碼,並在查詢時擷取網頁影像。LLM 會看到原始頁面 - 表格、圖表、格式等所有內容 - 並根據所見回答問題。

多模式 RAG 管道結構:ColQwen2 用於編碼,Milvus 用於搜索,Qwen3.5 用於生成

管道如何運作

技術堆疊

元件選擇角色
PDF 處理pdf2image + poppler將 PDF 頁面呈現為高解析度影像
嵌入模型colqwen2-v1.0視覺語言模型;將每頁編碼成 ~755 128 dim 的修補向量
向量資料庫Milvus Lite儲存修補向量並處理相似性搜尋;本機執行,無需伺服器設定
生成模型Qwen3.5-397B-A17B透過 OpenRouter API 呼叫的多模態 LLM;讀取擷取的頁面影像以產生答案

使用 ColQwen2+ Milvus+ Qwen3.5-397B-A17B 分步實現多模態 RAG

環境設定

  1. 安裝 Python 相依性
pip install colpali-engine pymilvus openai pdf2image torch pillow tqdm
  1. 安裝 PDF 渲染引擎 Poppler
# macOS
brew install poppler

# Ubuntu/Debian sudo apt-get install poppler-utils

# Windows: download from https://github.com/oschwartz10612/poppler-windows

  1. 下載嵌入模型 ColQwen2

從 HuggingFace 下載 vidore/colqwen2-v1.0-merged (~4.4 GB) 並儲存到本機:

mkdir -p ~/models/colqwen2-v1.0-merged
# Download all model files to this directory
  1. 取得 OpenRouter API 金鑰

https://openrouter.ai/settings/keys 註冊並產生一個金鑰。

步驟 1: 匯入相依性並進行設定

import os, io, base64
import torch
import numpy as np
from PIL import Image
from tqdm import tqdm
from pdf2image import convert_from_path

from openai import OpenAI from pymilvus import MilvusClient, DataType from colpali_engine.models import ColQwen2, ColQwen2Processor

# — Configuration — EMBED_MODEL = os.path.expanduser(“~/models/colqwen2-v1.0-merged”) EMBED_DIM = 128 # ColQwen2 output vector dimension MILVUS_URI = “./milvus_demo.db” # Milvus Lite local file COLLECTION = “doc_patches” TOP_K = 3 # Number of pages to retrieve CANDIDATE_PATCHES = 300 # Candidate patches per query token

# OpenRouter LLM config OPENROUTER_API_KEY = os.environ.get( “OPENROUTER_API_KEY”, , ) GENERATION_MODEL = “qwen/qwen3.5-397b-a17b”

# Device selection DEVICE = “cuda” if torch.cuda.is_available() else “cpu” DTYPE = torch.bfloat16 if DEVICE == “cuda” else torch.float32 print(f"Device: {DEVICE}")

輸出:裝置:cpu

步驟 2:載入嵌入模型

ColQwen2是一個視覺語言模型,可將文件影像編碼為 ColBERT 風格的多向量表示法。每個頁面會產生數百個 128 維的修補向量。

print(f"Loading embedding model: {EMBED_MODEL}")
emb_model = ColQwen2.from_pretrained(
    EMBED_MODEL,
    torch_dtype=DTYPE,
    attn_implementation="flash_attention_2" if DEVICE == "cuda" else None,
    device_map=DEVICE,
).eval()
emb_processor = ColQwen2Processor.from_pretrained(EMBED_MODEL)
print(f"Embedding model ready on {DEVICE}")

輸出:

步驟 3:初始化 Milvus

本教學使用 Milvus Lite,它以本地檔案的方式執行,零組態 - 不需要獨立的伺服器進程。

資料庫模式:

id:INT64, 自增主鍵

doc_id:INT64,頁號(PDF 的哪一頁)

patch_idx:INT64,該頁中的補丁索引

向量:FLOAT_VECTOR(128),補丁的 128 維嵌入值

milvus_client = MilvusClient(uri=MILVUS_URI)

if milvus_client.has_collection(COLLECTION): milvus_client.drop_collection(COLLECTION)

schema = milvus_client.create_schema(auto_id=True, enable_dynamic_field=True) schema.add_field(“id”, DataType.INT64, is_primary=True) schema.add_field(“doc_id”, DataType.INT64) schema.add_field(“patch_idx”, DataType.INT64) schema.add_field(“vector”, DataType.FLOAT_VECTOR, dim=EMBED_DIM)

index = milvus_client.prepare_index_params() index.add_index(field_name=“vector”, index_type=“FLAT”, metric_type=“IP”) milvus_client.create_collection(COLLECTION, schema=schema, index_params=index) print(“Milvus collection created.”)

輸出:建立 Milvus 套件。

步驟 4:將 PDF 頁面轉換成影像

您以 150 DPI 渲染每頁。這裡不會進行文字萃取 - 傳輸管道會將每頁純粹視為影像。

PDF_PATH = "Milvus vs Zilliz.pdf"  # Replace with your own PDF
images = [p.convert("RGB") for p in convert_from_path(PDF_PATH, dpi=150)]
print(f"{len(images)} pages loaded.")

# Preview the first page images[0].resize((400, int(400 * images[0].height / images[0].width)))

輸出:

步驟 5:編碼影像並插入 Milvus

ColQwen2 將每頁編碼為多向量修補嵌入。然後,您可以在 Milvus 中以獨立行的形式插入每個修補碼。

# Encode all pages
all_page_embs = []
with torch.no_grad():
    for i in tqdm(range(0, len(images), 2), desc="Encoding pages"):
        batch = images[i : i + 2]
        inputs = emb_processor.process_images(batch).to(emb_model.device)
        embs = emb_model(**inputs)
        for e in embs:
            all_page_embs.append(e.cpu().float().numpy())

print(f"Encoded {len(all_page_embs)} pages, ~{all_page_embs[0].shape[0]} patches per page, dim={all_page_embs[0].shape[1]}")

輸出:編碼 17 頁,每頁 ~755 個修補碼,dim=128

# Insert into Milvus
for doc_id, patch_vecs in enumerate(all_page_embs):
    rows = [
        {"doc_id": doc_id, "patch_idx": j, "vector": v.tolist()}
        for j, v in enumerate(patch_vecs)
    ]
    milvus_client.insert(COLLECTION, rows)

total = milvus_client.get_collection_stats(COLLECTION)[“row_count”] print(f"Indexed {len(all_page_embs)} pages, {total} patches total.")

輸出:索引 17 頁,共 12835 個修補碼。

17 頁 PDF 產生 12,835 個修補碼向量記錄 - 每頁約 755 個修補碼。

步驟 6:擷取 - 查詢編碼 + MaxSim 重新排序

這是核心的擷取邏輯。它分三個階段運作:

將查詢編碼為多個標記向量。

搜尋 Milvus 中每個符號向量最接近的補丁。

使用 MaxSim按頁面進行聚合:對於每個查詢標記,在每個頁面中選取得分最高的補丁,然後將所有標記的得分相加。總分最高的頁面即為最佳匹配。

MaxSim 如何運作:對於每個查詢符記向量,您會找出具有最高內乘積 (MaxSim 中的「最大」) 的文件修補區。然後將所有查詢標記的最大分數相加,得到每頁的總相關性分數。分數越高,表示查詢與頁面的視覺內容之間的語意匹配度越高。

question = "What is the difference between Milvus and Zilliz Cloud?"

# 1. Encode the query with torch.no_grad(): query_inputs = emb_processor.process_queries([question]).to(emb_model.device) query_vecs = emb_model(**query_inputs)[0].cpu().float().numpy() print(f"Query encoded: {query_vecs.shape[0]} token vectors")

# 2. Search Milvus for each query token vector doc_patch_scores = {} for qv in query_vecs: hits = milvus_client.search( COLLECTION, data=[qv.tolist()], limit=CANDIDATE_PATCHES, output_fields=[“doc_id”, “patch_idx”], search_params={“metric_type”: “IP”}, )[0] for h in hits: did = h[“entity”][“doc_id”] pid = h[“entity”][“patch_idx”] score = h[“distance”] doc_patch_scores.setdefault(did, {})[pid] = max( doc_patch_scores.get(did, {}).get(pid, 0), score )

# 3. MaxSim aggregation: total score per page = sum of all matched patch scores doc_scores = {d: sum(ps.values()) for d, ps in doc_patch_scores.items()} ranked = sorted(doc_scores.items(), key=lambda x: x[1], reverse=True)[:TOP_K] print(f"Top-{TOP_K} retrieved pages: {[(d, round(s, 2)) for d, s in ranked]}")

輸出:

Query encoded: 24 token vectors
Top-3 retrieved pages: [(16, 161.16), (12, 135.73), (7, 122.58)]
# Display the retrieved pages
context_images = [images[d] for d, _ in ranked if d < len(images)]
for i, img in enumerate(context_images):
    print(f"--- Retrieved page {ranked[i][0]} (score: {ranked[i][1]:.2f}) ---")
    display(img.resize((500, int(500 * img.height / img.width))))

步驟 7:使用多模態 LLM 產生答案

您將擷取到的頁面圖像 (而非擷取的文字) 連同使用者的問題一併傳送至 Qwen3.5。LLM 會直接讀取圖片以產生答案。

def image_to_uri(img):
    """Convert an image to a base64 data URI for sending to the LLM."""
    img = img.copy()
    w, h = img.size
    if max(w, h) > 1600:
        r = 1600 / max(w, h)
        img = img.resize((int(w * r), int(h * r)), Image.LANCZOS)
    buf = io.BytesIO()
    img.save(buf, format="PNG")
    return f"data:image/png;base64,{base64.b64encode(buf.getvalue()).decode()}"

# Build the multimodal prompt context_images = [images[d] for d, _ in ranked if d < len(images)] content = [ {“type”: “image_url”, “image_url”: {“url”: image_to_uri(img)}} for img in context_images ] content.append({ “type”: “text”, “text”: ( f"Above are {len(context_images)} retrieved document pages.\n" f"Read them carefully and answer the following question:\n\n" f"Question: {question}\n\n" f"Be concise and accurate. If the documents don’t contain " f"relevant information, say so." ), })

# Call the LLM llm = OpenAI(api_key=OPENROUTER_API_KEY, base_url=“https://openrouter.ai/api/v1”) response = llm.chat.completions.create( model=GENERATION_MODEL, messages=[{“role”: “user”, “content”: content}], max_tokens=1024, temperature=0.7, ) answer = response.choices[0].message.content.strip() print(f"Question: {question}\n") print(f"Answer: {answer}")

結果:

結論

在本教程中,我們建立了一個多模態 RAG 管道,它可以取得 PDF,將每頁轉換成圖片,使用 ColQwen2 將這些圖片編碼成多向量斑塊內嵌,然後將它們儲存到 Milvus 中,並在查詢時使用 MaxSim Scoring 檢索最相關的頁面。該管道將原始頁面圖像傳送至 Qwen3.5,Qwen3.5 會直觀讀取這些圖像並產生答案,而不是擷取文字並希望 OCR 保留版面。

本教學只是一個起點,而非生產部署。當您進一步使用時,有幾件事情需要注意。

關於取捨:

  • 儲存空間會隨頁數增加。每頁產生 ~755 個向量,因此 1,000 頁的資料庫在 Milvus 中大約有 755,000 行。這裡使用的 FLAT 索引適用於示範,但對於較大的資料庫,您需要 IVF 或 HNSW。
  • 編碼比文字嵌入慢。ColQwen2 是 4.4 GB 的視覺模型。編碼影像每頁所需的時間比嵌入文字區塊更長。對於執行一次的批次索引工作,這通常沒問題。對於即時擷取,則值得進行基準測試。
  • 此方法最適用於視覺豐富的文件。如果您的 PDF 文件大多是簡潔的單欄文字,沒有表格或圖表,傳統基於文字的 RAG 可能擷取得更精確,執行成本也更低。

下一步該怎麼做?

  • 換一個不同的多模式 LLM。本教學透過 OpenRouter 使用 Qwen3.5,但檢索管道與模型無關。您可以將生成步驟指向 GPT-4o、Gemini 或任何接受影像輸入的多模態模型。
  • 擴充MilvusMilvus Lite 以本機檔案形式執行,非常適合原型開發。對於生產工作負載,Docker/Kubernetes 上的 Milvus 或 Zilliz Cloud (完全管理的 Milvus) 可處理較大的語料庫,而無需您管理基礎架構。
  • 使用不同的文件類型進行實驗。這裡的管道使用的是比較 PDF,但對於掃描過的合約、工程圖紙、財務報表或圖表密集的研究論文,其運作方式相同。

要開始使用,請使用 pip installpymilvus安裝Milvus Lite,並從 HuggingFace 擷取 ColQwen2 權重。

有問題或想炫耀您的成果嗎?Milvus Slack是從社群和團隊獲得協助的最快方式。如果您想要一對一的對話,可以在我們的辦公時間預約時間。

繼續閱讀

    Try Managed Milvus for Free

    Zilliz Cloud is hassle-free, powered by Milvus and 10x faster.

    Get Started

    Like the article? Spread the word

    繼續閱讀