Como construir um RAG multimodal com ColQwen2, Milvus e Qwen3.5
Atualmente, é possível carregar um PDF em qualquer LLM moderno e fazer perguntas sobre ele. Para um punhado de documentos, isso funciona bem. Mas a maioria dos LLMs limita-se a algumas centenas de páginas de contexto, pelo que um corpus de grandes dimensões simplesmente não se adequa. Mesmo quando se adequa, está a pagar para processar todas as páginas em cada consulta. Se fizer uma centena de perguntas sobre o mesmo conjunto de 500 páginas de documentos, está a pagar por 500 páginas uma centena de vezes. Isso fica caro rapidamente.
A geração aumentada por recuperação (RAG) resolve este problema separando a indexação da resposta. Codifica-se os documentos uma vez, armazena-se as representações numa base de dados vetorial e, no momento da consulta, recuperam-se apenas as páginas mais relevantes para enviar para o LLM. O modelo lê três páginas por consulta, não todo o seu corpus. Isso faz com que seja prático construir perguntas e respostas de documentos sobre colecções que continuam a crescer.
Este tutorial guia-o na construção de um pipeline RAG multimodal com três componentes de licença livre:
- ColQwen2 codifica cada página de PDF como uma imagem em embeddings multi-vectoriais, substituindo o passo tradicional de OCR e de fragmentação de texto.
- O Milvus armazena esses vectores e trata da pesquisa de semelhanças no momento da consulta, recuperando apenas as páginas mais relevantes.
- O Qwen3.5-397B-A17B lê as imagens das páginas recuperadas e gera uma resposta com base no que vê.
No final, terá um sistema funcional que recebe um PDF e uma pergunta, encontra as páginas mais relevantes e devolve uma resposta baseada no que o modelo vê.
O que é o RAG multimodal?
A introdução abordou por que o RAG é importante em escala. A questão seguinte é saber de que tipo de RAG precisa, porque a abordagem tradicional tem um ponto cego.
O RAG tradicional extrai texto de documentos, incorpora-o como vectores, recupera as correspondências mais próximas no momento da consulta e passa esses pedaços de texto para um LLM. Isso funciona bem para conteúdo com muito texto e formatação limpa. Mas não funciona quando os seus documentos contêm:
- Tabelas, onde o significado depende da relação entre linhas, colunas e cabeçalhos.
- Gráficos e diagramas, onde a informação é inteiramente visual e não tem equivalente em texto.
- Documentos digitalizados ou notas manuscritas, em que o resultado do OCR não é fiável ou está incompleto.
O RAG multimodal substitui a extração de texto pela codificação de imagens. Cada página é apresentada como uma imagem, codificada com um modelo de linguagem de visão e recuperada no momento da consulta. O LLM vê a página original - tabelas, figuras, formatação e tudo - e responde com base no que vê.
Estrutura do Pipeline RAG Multimodal: ColQwen2 para codificação, Milvus para pesquisa, Qwen3.5 para geração
Como funciona o pipeline
Pilha técnica
| Componente | Escolha | Função |
|---|---|---|
| Processamento de PDF | pdf2image + poppler | Renderiza páginas PDF como imagens de alta resolução |
| Modelo de incorporação | colqwen2-v1.0 | Modelo de linguagem de visão; codifica cada página em ~755 vectores de 128 dígitos |
| Base de dados de vectores | Milvus Lite | Armazena vectores de retalhos e trata da pesquisa de semelhanças; funciona localmente sem configuração de servidor |
| Modelo de geração | Qwen3.5-397B-A17B | LLM multimodal chamado via API OpenRouter; lê imagens de páginas recuperadas para gerar respostas |
Implementação passo-a-passo para RAG multimodal com ColQwen2+ Milvus+ Qwen3.5-397B-A17B
Configuração do ambiente
- Instalar as dependências Python
pip install colpali-engine pymilvus openai pdf2image torch pillow tqdm
- Instalar o Poppler, o mecanismo de renderização de PDF
# macOS
brew install poppler
# Ubuntu/Debian
sudo apt-get install poppler-utils
# Windows: download from https://github.com/oschwartz10612/poppler-windows
- Descarregar o modelo de incorporação, ColQwen2
Baixe vidore/colqwen2-v1.0-merged do HuggingFace (~4.4 GB) e salve-o localmente:
mkdir -p ~/models/colqwen2-v1.0-merged
# Download all model files to this directory
- Obter uma chave de API do OpenRouter
Inscreva-se e gere uma chave em https://openrouter.ai/settings/keys.
Etapa 1: Importar dependências e configurar
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}")
Saída: Dispositivo: cpu
Etapa 2: carregar o modelo de incorporação
ColQwen2 é um modelo de linguagem de visão que codifica imagens de documentos em representações multi-vectoriais ao estilo ColBERT. Cada página produz várias centenas de vetores de patches de 128 dimensões.
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}")
Saída:
Etapa 3: inicializar o Milvus
Este tutorial usa o Milvus Lite, que é executado como um ficheiro local com configuração zero - não é necessário um processo de servidor separado.
Esquema da base de dados:
id: INT64, chave primária auto-incrementada
doc_id: INT64, número da página (qual a página do PDF)
patch_idx: INT64, índice de correção dentro dessa página
vetor: FLOAT_VECTOR(128), a incorporação de 128 dimensões do patch
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.”)
Saída: Coleção Milvus criada.
Etapa 4: converter páginas PDF em imagens
Você renderiza cada página a 150 DPI. Nenhuma extração de texto acontece aqui - o pipeline trata cada página puramente como uma imagem.
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)))
Saída:
Passo 5: Codificar imagens e inserir no Milvus
O ColQwen2 codifica cada página em patch embeddings multi-vectoriais. Em seguida, insere cada patch como uma linha separada no 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]}")
Resultado: Codificou 17 páginas, ~755 patches por página, 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.")
Saída: 17 páginas indexadas, 12835 patches no total.
Um PDF de 17 páginas produz 12.835 registos de vectores de amostras - aproximadamente 755 amostras por página.
Etapa 6: Recuperar - Codificação da consulta + Reranking do MaxSim
Esta é a lógica de recuperação principal. Funciona em três fases:
Codificar a consulta em múltiplos vectores de símbolos.
Procurar no Milvus os patches mais próximos de cada vetor de token.
Agregar por página usando o MaxSim: para cada token da consulta, pegar o patch com maior pontuação em cada página e, em seguida, somar essas pontuações em todos os tokens. A página com a pontuação total mais alta é a melhor correspondência.
Como funciona o MaxSim: Para cada vetor de token de consulta, encontra o fragmento de documento com o produto interno mais elevado (o "max" no MaxSim). Em seguida, soma essas pontuações máximas em todos os tokens de consulta para obter uma pontuação total de relevância por página. Pontuação mais alta = correspondência semântica mais forte entre a consulta e o conteúdo visual da página.
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]}")
Saída:
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))))
Passo 7: Gerar uma resposta com o LLM multimodal
Envia as imagens da página recuperada - não o texto extraído - juntamente com a pergunta do utilizador para o Qwen3.5. O LLM lê as imagens diretamente para produzir uma resposta.
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}")
Resultados:
Conclusão
Neste tutorial, construímos um pipeline RAG multimodal que pega num PDF, converte cada página numa imagem, codifica essas imagens em patch embeddings multi-vectoriais com o ColQwen2, armazena-as no Milvus e recupera as páginas mais relevantes no momento da consulta utilizando a pontuação MaxSim. Em vez de extrair texto e esperar que o OCR preserve o layout, o pipeline envia as imagens originais das páginas para o Qwen3.5, que as lê visualmente e gera uma resposta.
Este tutorial é um ponto de partida, não uma implantação de produção. Algumas coisas para ter em mente à medida que avança.
Sobre as compensações:
- O armazenamento é escalonado com a contagem de páginas. Cada página produz ~755 vectores, pelo que um corpus de 1000 páginas significa aproximadamente 755 000 linhas no Milvus. O índice FLAT usado aqui funciona para demonstrações, mas seria melhor usar IVF ou HNSW para colecções maiores.
- A codificação é mais lenta do que a incorporação de texto. O ColQwen2 é um modelo de visão de 4,4 GB. A codificação de imagens demora mais tempo por página do que a incorporação de pedaços de texto. Para um trabalho de indexação em lote que é executado uma vez, isso geralmente é bom. Para a ingestão em tempo real, vale a pena fazer um benchmarking.
- Esta abordagem funciona melhor para documentos visualmente ricos. Se os seus PDFs são maioritariamente texto simples, de uma só coluna, sem tabelas ou figuras, o RAG tradicional baseado em texto pode recuperar com mais precisão e custar menos a executar.
Sobre o que tentar a seguir:
- Trocar por um LLM multimodal diferente. Este tutorial usa o Qwen3.5 via OpenRouter, mas o pipeline de recuperação é independente do modelo. Pode apontar o passo de geração para GPT-4o, Gemini, ou qualquer modelo multimodal que aceite entradas de imagem.
- Ampliar o Milvus. O Milvus Lite é executado como um arquivo local, o que é ótimo para prototipagem. Para cargas de trabalho de produção, o Milvus em Docker/Kubernetes ou Zilliz Cloud (Milvus totalmente gerido) lida com corpora maiores sem que tenha de gerir a infraestrutura.
- Faça experiências com diferentes tipos de documentos. O pipeline aqui usa um PDF de comparação, mas funciona da mesma forma em contratos digitalizados, desenhos de engenharia, demonstrações financeiras ou documentos de pesquisa com figuras densas.
Para começar, instale o Milvus Lite com pip install pymilvus e pegue os pesos ColQwen2 do HuggingFace.
Tem perguntas, ou quer mostrar o que construiu? O Milvus Slack é a forma mais rápida de obter ajuda da comunidade e da equipa. Se preferir uma conversa individual, pode reservar um tempo no nosso horário de expediente.
Continuar a ler
Try Managed Milvus for Free
Zilliz Cloud is hassle-free, powered by Milvus and 10x faster.
Get StartedLike the article? Spread the word



