Cómo construir RAG multimodal con ColQwen2, Milvus y Qwen3.5
Hoy en día, puedes subir un PDF a cualquier LLM moderno y hacer preguntas sobre él. Para un puñado de documentos, eso funciona bien. Pero la mayoría de los LLM tienen un límite máximo de unos cientos de páginas de contexto, por lo que un corpus grande simplemente no cabe. Incluso si cabe, hay que pagar para procesar cada página en cada consulta. Si se hacen cien preguntas sobre el mismo conjunto de documentos de 500 páginas, se paga cien veces por 500 páginas. Eso sale caro enseguida.
La generación aumentada por recuperación (RAG) resuelve este problema separando la indexación de la respuesta. Los documentos se codifican una vez, las representaciones se almacenan en una base de datos vectorial y, en el momento de la consulta, sólo se recuperan las páginas más relevantes para enviarlas al LLM. El modelo lee tres páginas por consulta, no todo el corpus. Esto hace que sea práctico construir Q&A de documentos sobre colecciones que siguen creciendo.
Este tutorial le guiará a través de la construcción de una canalización RAG multimodal con tres componentes de licencia abierta:
- ColQwen2 codifica cada página PDF como una imagen en incrustaciones multivectoriales, reemplazando el paso tradicional de OCR y fragmentación de texto.
- Milvus almacena esos vectores y gestiona la búsqueda de similitudes en el momento de la consulta, recuperando sólo las páginas más relevantes.
- Qwen3.5-397B-A17B lee las imágenes de las páginas recuperadas y genera una respuesta basada en lo que ve.
Al final, tendrás un sistema operativo que toma un PDF y una pregunta, encuentra las páginas más relevantes y devuelve una respuesta basada en lo que ve el modelo.
¿Qué es RAG multimodal?
En la introducción hemos explicado por qué la GAR es importante a gran escala. La siguiente pregunta es qué tipo de GAR se necesita, porque el enfoque tradicional tiene un punto ciego.
La GAR tradicional extrae texto de los documentos, lo incorpora como vectores, recupera las coincidencias más cercanas en el momento de la consulta y pasa esos trozos de texto a un LLM. Esto funciona bien para contenidos con mucho texto y un formato limpio. Se rompe cuando sus documentos contienen
- Tablas, cuyo significado depende de la relación entre filas, columnas y encabezados.
- Gráficos y diagramas, donde la información es totalmente visual y no tiene equivalente en texto.
- Documentos escaneados o notas manuscritas, en los que el resultado del OCR es poco fiable o incompleto.
El GAR multimodal sustituye la extracción de texto por la codificación de imágenes. Se representa cada página como una imagen, se codifica con un modelo de lenguaje visual y se recuperan las imágenes de las páginas en el momento de la consulta. El LLM ve la página original (tablas, figuras, formato y todo lo demás) y responde basándose en lo que ve.
Estructura del proceso RAG multimodal: ColQwen2 para la codificación, Milvus para la búsqueda, Qwen3.5 para la generación.
Funcionamiento del proceso
Pila tecnológica
| Componente | Elección | Función |
|---|---|---|
| Procesamiento de PDF | pdf2image + poppler | Renderiza páginas PDF como imágenes de alta resolución |
| Modelo de incrustación | colqwen2-v1.0 | Modelo de lenguaje visual; codifica cada página en ~755 vectores de parches de 128 dim. |
| Base de datos de vectores | Milvus Lite | Almacena vectores de parches y gestiona la búsqueda de similitudes; se ejecuta localmente sin configuración de servidor |
| Modelo de generación | Qwen3.5-397B-A17B | LLM multimodal invocado a través de la API OpenRouter; lee imágenes de páginas recuperadas para generar respuestas |
Implementación paso a paso de RAG multimodal con ColQwen2+ Milvus+ Qwen3.5-397B-A17B
Configuración del entorno
- Instalar las dependencias de Python
pip install colpali-engine pymilvus openai pdf2image torch pillow tqdm
- Instalar Poppler, el motor de renderizado PDF
# macOS
brew install poppler
# Ubuntu/Debian
sudo apt-get install poppler-utils
# Windows: download from https://github.com/oschwartz10612/poppler-windows
- Descargar el modelo de incrustación, ColQwen2
Descargue vidore/colqwen2-v1.0-merged de HuggingFace (~4.4 GB) y guárdelo localmente:
mkdir -p ~/models/colqwen2-v1.0-merged
# Download all model files to this directory
- Obtener una clave API OpenRouter
Regístrate y genera una clave en https://openrouter.ai/settings/keys.
Paso 1: Importar dependencias y 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}")
Salida: Dispositivo: cpu
Paso 2: Cargar el modelo de incrustación
ColQwen2 es un modelo de lenguaje de visión que codifica imágenes de documentos en representaciones multivectoriales al estilo ColBERT. Cada página produce varios cientos de vectores de parche de 128 dimensiones.
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}")
Salida:
Paso 3: Inicializar Milvus
Este tutorial utiliza Milvus Lite, que se ejecuta como un archivo local con cero configuración - no se necesita un proceso de servidor separado.
Esquema de la base de datos:
id: INT64, clave primaria autoincrementada
doc_id: INT64, número de página (qué página del PDF)
patch_idx: INT64, índice de parche dentro de esa página
vector: FLOAT_VECTOR(128), la incrustación de 128 dimensiones del parche
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.”)
Salida: Colección Milvus creada.
Paso 4: Convertir páginas PDF en imágenes
Renderiza cada página a 150 DPI. Aquí no se realiza ninguna extracción de texto - la tubería trata cada página puramente como una imagen.
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)))
Salida:
Paso 5: Codificar las imágenes e insertarlas en Milvus
ColQwen2 codifica cada página en incrustaciones de parches multivectoriales. A continuación, inserta cada parche como una fila separada en 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: 17 páginas codificadas, ~755 parches 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.")
Resultado: 17 páginas indexadas, 12835 parches en total.
Un PDF de 17 páginas produce 12.835 registros de vectores de parches, aproximadamente 755 parches por página.
Paso 6: Recuperación: codificación de la consulta + reordenación MaxSim
Este es el núcleo de la lógica de recuperación. Funciona en tres etapas:
Codificar la consulta en múltiples vectores de símbolos.
Buscar en Milvus los parches más cercanos a cada vector de símbolos.
Agregación por páginas mediante MaxSim: para cada token de la consulta, se toma el parche con la puntuación más alta de cada página y, a continuación, se suman las puntuaciones de todos los tokens. La página con la puntuación total más alta es la que mejor coincide.
Cómo funciona MaxSim: Para cada vector de tokens de consulta, se busca el parche del documento con el producto interno más alto (el "max" en MaxSim). A continuación, se suman las puntuaciones máximas de todos los tokens de la consulta para obtener una puntuación de relevancia total por página. Mayor puntuación = mayor coincidencia semántica entre la consulta y el contenido visual de la 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]}")
Resultado:
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))))
Paso 7: Generar una respuesta con el LLM multimodal
Se envían a Qwen3.5 las imágenes de las páginas recuperadas (no el texto extraído) junto con la pregunta del usuario. El LLM lee directamente las imágenes para generar una respuesta.
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}")
Resultado:
Conclusión
En este tutorial, construimos un proceso RAG multimodal que toma un PDF, convierte cada página en una imagen, codifica esas imágenes en incrustaciones de parches multivectoriales con ColQwen2, las almacena en Milvus y recupera las páginas más relevantes en el momento de la consulta utilizando la puntuación MaxSim. En lugar de extraer el texto y esperar que el OCR conserve el diseño, el proceso envía las imágenes originales de las páginas a Qwen3.5, que las lee visualmente y genera una respuesta.
Este tutorial es un punto de partida, no un despliegue de producción. Hay que tener en cuenta algunas cosas a medida que se avanza.
Sobre las compensaciones:
- El almacenamiento se escala con el número de páginas. Cada página produce ~755 vectores, por lo que un corpus de 1.000 páginas significa aproximadamente 755.000 filas en Milvus. El índice FLAT utilizado aquí funciona para las demostraciones, pero sería mejor utilizar IVF o HNSW para colecciones más grandes.
- La codificación es más lenta que la incrustación de texto. ColQwen2 es un modelo de visión de 4,4 GB. La codificación de imágenes lleva más tiempo por página que la incrustación de trozos de texto. Para un trabajo de indexación por lotes que se ejecuta una vez, esto suele estar bien. Para la ingestión en tiempo real, merece la pena realizar una evaluación comparativa.
- Este método funciona mejor con documentos visualmente ricos. Si sus PDF son en su mayoría texto limpio de una sola columna, sin tablas ni figuras, la indexación tradicional basada en texto puede ser más precisa y menos costosa.
Qué probar a continuación:
- Cambie a otro LLM multimodal. Este tutorial utiliza Qwen3.5 a través de OpenRouter, pero el proceso de recuperación es independiente del modelo. Podrías dirigir el paso de generación a GPT-4o, Gemini o cualquier modelo multimodal que acepte entradas de imagen.
- Ampliar Milvus. Milvus Lite se ejecuta como un archivo local, lo que es ideal para la creación de prototipos. Para cargas de trabajo de producción, Milvus en Docker/Kubernetes o Zilliz Cloud (Milvus totalmente gestionado) maneja corpus más grandes sin que usted gestione la infraestructura.
- Experimente con diferentes tipos de documentos. La tubería aquí utiliza un PDF de comparación, pero funciona de la misma manera en contratos escaneados, dibujos de ingeniería, estados financieros o documentos de investigación con figuras densas.
Para empezar, instale Milvus Lite con pip install pymilvus y obtenga los pesos ColQwen2 de HuggingFace.
¿Tienes preguntas o quieres mostrar lo que has construido? El Slack de Milvus es la forma más rápida de obtener ayuda de la comunidad y del equipo. Si prefieres una conversación cara a cara, puedes reservar hora en nuestro horario de oficina.
Seguir leyendo
Try Managed Milvus for Free
Zilliz Cloud is hassle-free, powered by Milvus and 10x faster.
Get StartedLike the article? Spread the word



