Módulo 6: RAG Multimodal
2. Embeddings de Imágenes
Descripción
Un embedding es un vector numérico que captura el significado semántico de un dato. Para texto, esto ya lo conoces: "perro" y "can" tienen embeddings cercanos. Pero ¿cómo haces lo mismo con una imagen? ¿Cómo conviertes una foto de un perro en un vector que puedas comparar con el texto "perro"? Esa es la pregunta central de esta cápsula.
Los embeddings de imágenes son la pieza fundamental del RAG multimodal. Sin ellos, no puedes indexar imágenes ni buscarlas. Hay dos estrategias principales: generar una descripción textual de la imagen con un modelo de visión y luego obtener el embedding del texto, o usar un modelo multimodal como CLIP que genera embeddings directamente en un espacio compartido texto-imagen.
Por qué importa: Sin embeddings de imágenes, tu RAG es solo texto. Con ellos, puedes buscar "diagrama de arquitectura" y recuperar tanto párrafos que mencionan arquitectura como diagramas visuales que la ilustran. Esta cápsula te da las dos herramientas para lograrlo.
Conexión con el módulo: Los embeddings que generas aquí se almacenan en el índice (cápsula 03), se buscan con retrieval híbrido (cápsula 04), y forman el core del proyecto (cápsula 08).
Embeddings de Texto: La Base que Ya Conoces
Antes de saltar a imágenes, revisemos cómo funcionan los embeddings de texto, porque la misma lógica se extiende a imágenes.
from openai import OpenAI
from dotenv import load_dotenv
load_dotenv()
client = OpenAI()
def text_embedding(text: str) -> list[float]:
response = client.embeddings.create(
model="text-embedding-3-small",
input=text
)
return response.data[0].embedding
emb_perro = text_embedding("perro")
emb_can = text_embedding("can")
emb_auto = text_embedding("automóvil")
print(f"Dimensión del embedding: {len(emb_perro)}")
print(f"Primeros 5 valores: {emb_perro[:5]}")
Cada texto se convierte en un vector de 1536 dimensiones (para text-embedding-3-small). Textos con significado similar producen vectores cercanos.
Similitud coseno
Para medir qué tan cercanos son dos embeddings, usamos similitud coseno: un valor entre -1 y 1 donde 1 = idénticos, 0 = sin relación, -1 = opuestos.
import numpy as np
def cosine_similarity(a: list[float], b: list[float]) -> float:
vec_a = np.array(a)
vec_b = np.array(b)
dot_product = np.dot(vec_a, vec_b)
norm_product = np.linalg.norm(vec_a) * np.linalg.norm(vec_b)
if norm_product == 0:
return 0.0
return float(dot_product / norm_product)
sim_perro_can = cosine_similarity(emb_perro, emb_can)
sim_perro_auto = cosine_similarity(emb_perro, emb_auto)
print(f"perro ↔ can: {sim_perro_can:.4f}")
print(f"perro ↔ automóvil: {sim_perro_auto:.4f}")
Resultado esperado: perro ↔ can tendrá un score alto (~0.85+), mientras que perro ↔ automóvil será más bajo (~0.50-0.65).
Estrategia 1: Imagen → Descripción → Embedding
Cómo funciona
Esta es la estrategia más simple y la que mejor funciona con las APIs actuales de OpenAI:
Imagen
↓ (Vision API: gpt-4o-mini)
Descripción textual: "Diagrama de arquitectura mostrando tres microservicios..."
↓ (Embeddings API: text-embedding-3-small)
Vector [0.02, -0.15, 0.33, ...] (1536 dimensiones)
Conviertes la imagen en texto usando un modelo con visión, y luego conviertes ese texto en un embedding. El embedding resultante es comparable con cualquier otro embedding de texto.
Ventajas y limitaciones
| Aspecto | Detalle |
|---|---|
| Ventaja | Compatible con cualquier vector store que soporte embeddings de texto |
| Ventaja | La descripción es interpretable — puedes leerla y verificar qué "vio" el modelo |
| Ventaja | Usa la misma API de embeddings para todo (texto e imágenes) |
| Limitación | Calidad depende de la descripción — si el modelo describe mal, el embedding es malo |
| Limitación | Doble costo: una llamada a Vision + una llamada a Embeddings |
| Limitación | Pierde información visual que el texto no puede capturar (colores exactos, layout espacial) |
Implementación completa
from openai import OpenAI
import base64
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
client = OpenAI()
DESCRIPTION_PROMPT = (
"Describe esta imagen de forma concisa y precisa para indexación "
"en un sistema de búsqueda. Incluye: qué muestra, elementos principales, "
"tipo de contenido (diagrama, foto, gráfico, tabla, captura de pantalla). "
"Máximo 2-3 oraciones."
)
def encode_image(image_path: str) -> str:
path = Path(image_path)
if not path.exists():
raise FileNotFoundError(f"Imagen no encontrada: {image_path}")
with open(path, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8")
def get_mime_type(image_path: str) -> str:
ext = Path(image_path).suffix.lower()
mime_map = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
}
mime = mime_map.get(ext)
if not mime:
raise ValueError(f"Formato no soportado: {ext}")
return mime
def describe_image(image_path: str, model: str = "gpt-4o-mini") -> str:
b64 = encode_image(image_path)
mime = get_mime_type(image_path)
response = client.chat.completions.create(
model=model,
messages=[{
"role": "user",
"content": [
{"type": "text", "text": DESCRIPTION_PROMPT},
{
"type": "image_url",
"image_url": {"url": f"data:{mime};base64,{b64}"}
}
]
}],
max_tokens=150
)
return response.choices[0].message.content
def image_to_embedding(image_path: str) -> dict:
description = describe_image(image_path)
embedding = client.embeddings.create(
model="text-embedding-3-small",
input=description
).data[0].embedding
return {
"embedding": embedding,
"description": description,
"source": image_path
}
result = image_to_embedding("test_docs/diagram.png")
print(f"Descripción: {result['description']}")
print(f"Embedding dim: {len(result['embedding'])}")
Prompt para la descripción
El prompt que usas para describir la imagen impacta directamente la calidad del embedding. Un prompt genérico como "Describe esta imagen" produce descripciones vagas. Un prompt específico para tu caso de uso produce descripciones que se indexan mejor.
PROMPTS_POR_DOMINIO = {
"tecnico": (
"Describe esta imagen técnica: qué tipo de diagrama o figura es, "
"qué componentes o elementos muestra, qué relaciones hay entre ellos. "
"Usa terminología técnica precisa."
),
"producto": (
"Describe este producto: tipo, color, forma, material aparente, "
"contexto de uso. Incluye detalles visuales relevantes para búsqueda."
),
"documento": (
"Describe el contenido visual de esta página: tablas, gráficos, "
"figuras, diagramas. Qué información transmiten."
),
"general": DESCRIPTION_PROMPT,
}
def describe_image_for_domain(
image_path: str,
domain: str = "general"
) -> str:
prompt = PROMPTS_POR_DOMINIO.get(domain, PROMPTS_POR_DOMINIO["general"])
b64 = encode_image(image_path)
mime = get_mime_type(image_path)
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{
"type": "image_url",
"image_url": {"url": f"data:{mime};base64,{b64}"}
}
]
}],
max_tokens=200
)
return response.choices[0].message.content
Estrategia 2: Embeddings Multimodales con CLIP
Cómo funciona CLIP
CLIP (Contrastive Language-Image Pre-training) es un modelo de OpenAI (open source a través de Hugging Face) que fue entrenado con 400 millones de pares imagen-texto. Aprendió a poner imágenes y textos en el mismo espacio vectorial: una foto de un gato y el texto "gato" producen vectores cercanos.
Texto: "gato naranja" → CLIP → vector [0.12, -0.08, ...] (512 dim)
Imagen: foto_gato.jpg → CLIP → vector [0.11, -0.09, ...] (512 dim)
↑ cercanos ↑
A diferencia de la estrategia 1, aquí no hay paso intermedio de descripción. La imagen se convierte directamente en un vector comparable con texto.
Setup de CLIP
pip install transformers torch pillow
Implementación
from transformers import CLIPProcessor, CLIPModel
from PIL import Image
import torch
import numpy as np
CLIP_MODEL_NAME = "openai/clip-vit-base-patch32"
clip_model = CLIPModel.from_pretrained(CLIP_MODEL_NAME)
clip_processor = CLIPProcessor.from_pretrained(CLIP_MODEL_NAME)
clip_model.eval()
def clip_image_embedding(image_path: str) -> list[float]:
image = Image.open(image_path).convert("RGB")
inputs = clip_processor(images=image, return_tensors="pt")
with torch.no_grad():
features = clip_model.get_image_features(**inputs)
normalized = features / features.norm(dim=-1, keepdim=True)
return normalized[0].numpy().tolist()
def clip_text_embedding(text: str) -> list[float]:
inputs = clip_processor(text=[text], return_tensors="pt", padding=True)
with torch.no_grad():
features = clip_model.get_text_features(**inputs)
normalized = features / features.norm(dim=-1, keepdim=True)
return normalized[0].numpy().tolist()
img_emb = clip_image_embedding("test_docs/diagram.png")
txt_emb = clip_text_embedding("diagrama de arquitectura")
sim = cosine_similarity(img_emb, txt_emb)
print(f"CLIP embedding dim: {len(img_emb)}")
print(f"Similitud imagen ↔ texto: {sim:.4f}")
Comparación CLIP vs distintos textos
def compare_image_with_texts(
image_path: str,
texts: list[str]
) -> list[tuple[str, float]]:
img_emb = clip_image_embedding(image_path)
results = []
for text in texts:
txt_emb = clip_text_embedding(text)
sim = cosine_similarity(img_emb, txt_emb)
results.append((text, sim))
results.sort(key=lambda x: x[1], reverse=True)
return results
texts = [
"diagrama de arquitectura de software",
"foto de un gato",
"gráfico de ventas mensuales",
"código Python en una terminal",
"paisaje montañoso",
]
rankings = compare_image_with_texts("test_docs/diagram.png", texts)
for text, score in rankings:
print(f" {score:.4f} — {text}")
Comparación de estrategias
| Aspecto | Estrategia 1 (Vision → Embedding) | Estrategia 2 (CLIP) |
|---|---|---|
| Dimensión | 1536 (text-embedding-3-small) | 512 (clip-vit-base-patch32) |
| Costo | ~$0.01-0.03 por imagen (Vision + Embedding) | Gratuito (modelo local) |
| Latencia | ~2-5 seg por imagen (llamada API) | ~0.05-0.2 seg (inferencia local) |
| Calidad texto | Alta — embeddings de texto de OpenAI son excelentes | Menor — CLIP fue entrenado para alineación, no para NLU profundo |
| Calidad imagen | Depende de la descripción del modelo | Directa — sin pérdida por traducción a texto |
| Espacio compartido | NO — embeddings de imagen están en espacio de texto | SÍ — texto e imagen viven en el mismo espacio |
| Requisitos | Solo API key de OpenAI | GPU recomendada (funciona en CPU, más lento) |
| Mejor para | Documentos donde la descripción captura bien el contenido | Búsqueda por similitud visual (productos, fotos) |
¿Cuándo usar cada una?
Usa Estrategia 1 (Vision → Embedding) cuando:
- Tu corpus es documentación técnica con diagramas
- La información importante es CONCEPTUAL (qué representa el diagrama)
- Ya usas embeddings de OpenAI para texto
- No quieres instalar modelos locales
Usa Estrategia 2 (CLIP) cuando:
- Tu corpus es visual (catálogos, fotos, arte)
- La información importante es VISUAL (colores, formas, composición)
- Necesitas bajo costo y baja latencia
- Quieres búsqueda imagen → imagen
Embeddings por Batch
Cuando tienes muchas imágenes (o muchos textos), procesar uno por uno es ineficiente. Aquí tienes utilidades para procesamiento por batch.
Batch de embeddings de texto (OpenAI)
def batch_text_embeddings(
texts: list[str],
batch_size: int = 100
) -> list[list[float]]:
all_embeddings = []
for i in range(0, len(texts), batch_size):
batch = texts[i:i + batch_size]
response = client.embeddings.create(
model="text-embedding-3-small",
input=batch
)
batch_embs = [item.embedding for item in response.data]
all_embeddings.extend(batch_embs)
return all_embeddings
descriptions = ["Descripción imagen 1", "Descripción imagen 2", "..."]
embeddings = batch_text_embeddings(descriptions)
print(f"Generados {len(embeddings)} embeddings")
Batch de embeddings CLIP
def batch_clip_image_embeddings(
image_paths: list[str]
) -> list[list[float]]:
images = [Image.open(p).convert("RGB") for p in image_paths]
inputs = clip_processor(images=images, return_tensors="pt", padding=True)
with torch.no_grad():
features = clip_model.get_image_features(**inputs)
normalized = features / features.norm(dim=-1, keepdim=True)
return normalized.numpy().tolist()
paths = ["img1.png", "img2.png", "img3.png"]
clip_embs = batch_clip_image_embeddings(paths)
print(f"Generados {len(clip_embs)} embeddings CLIP")
Batch de descripciones con Vision (async)
import asyncio
from openai import AsyncOpenAI
async def describe_image_async(
async_client: AsyncOpenAI,
image_path: str
) -> dict:
b64 = encode_image(image_path)
mime = get_mime_type(image_path)
response = await async_client.chat.completions.create(
model="gpt-4o-mini",
messages=[{
"role": "user",
"content": [
{"type": "text", "text": DESCRIPTION_PROMPT},
{
"type": "image_url",
"image_url": {"url": f"data:{mime};base64,{b64}"}
}
]
}],
max_tokens=150
)
return {
"path": image_path,
"description": response.choices[0].message.content
}
async def batch_describe_images(
image_paths: list[str],
max_concurrent: int = 5
) -> list[dict]:
async_client = AsyncOpenAI()
semaphore = asyncio.Semaphore(max_concurrent)
async def limited_describe(path: str) -> dict:
async with semaphore:
return await describe_image_async(async_client, path)
tasks = [limited_describe(p) for p in image_paths]
return await asyncio.gather(*tasks)
# results = asyncio.run(batch_describe_images(["img1.png", "img2.png"]))
Vector Databases: Intro a ChromaDB
Los embeddings por sí solos son solo vectores en memoria. Para buscar eficientemente entre miles o millones de embeddings, necesitas un vector store: una base de datos optimizada para búsqueda por similitud vectorial.
ChromaDB: por qué lo usamos
ChromaDB es un vector store open source, ligero, que funciona localmente sin servidor externo. Es la opción ideal para desarrollo y prototipado.
| Característica | ChromaDB |
|---|---|
| Instalación | pip install chromadb |
| Servidor | No necesita — funciona embebido |
| Persistencia | En memoria o disco |
| Búsqueda | Similitud coseno, L2, IP |
| Metadata | Filtros sobre metadata arbitraria |
| Embedding functions | Built-in para OpenAI, Sentence Transformers, etc. |
Primeros pasos con ChromaDB
import chromadb
from chromadb.utils import embedding_functions
openai_ef = embedding_functions.OpenAIEmbeddingFunction(
model_name="text-embedding-3-small"
)
chroma_client = chromadb.Client()
collection = chroma_client.create_collection(
name="test_embeddings",
embedding_function=openai_ef,
metadata={"hnsw:space": "cosine"}
)
collection.add(
documents=[
"Python es un lenguaje de programación versátil",
"JavaScript se usa principalmente para desarrollo web",
"Diagrama de arquitectura con tres microservicios",
],
ids=["doc_1", "doc_2", "doc_3"],
metadatas=[
{"type": "text", "topic": "python"},
{"type": "text", "topic": "javascript"},
{"type": "image_desc", "topic": "architecture"},
]
)
results = collection.query(
query_texts=["microservicios"],
n_results=2
)
for doc, meta, dist in zip(
results["documents"][0],
results["metadatas"][0],
results["distances"][0]
):
print(f" [{dist:.4f}] ({meta['type']}) {doc[:60]}...")
chroma_client.delete_collection("test_embeddings")
Almacenar embeddings de imagen en ChromaDB
def store_image_embeddings(
collection,
image_paths: list[str]
) -> None:
for i, path in enumerate(image_paths):
result = image_to_embedding(path)
collection.add(
documents=[result["description"]],
embeddings=[result["embedding"]],
ids=[f"img_{i}"],
metadatas=[{
"type": "image",
"source": path,
"description": result["description"]
}]
)
print(f"Indexada: {path} → {result['description'][:50]}...")
Búsqueda por Similitud: Texto ↔ Imagen
Con embeddings de texto e imágenes en el mismo índice, puedes buscar en ambas direcciones.
Buscar imágenes con texto
def search_images_by_text(
collection,
query: str,
n: int = 5
) -> list[dict]:
results = collection.query(
query_texts=[query],
n_results=n,
where={"type": "image"}
)
matches = []
for doc, meta, dist in zip(
results["documents"][0],
results["metadatas"][0],
results["distances"][0]
):
matches.append({
"description": doc,
"source": meta.get("source", "unknown"),
"distance": dist,
"similarity": 1 - dist / 2,
})
return matches
Buscar texto con imagen
def search_text_by_image(
collection,
image_path: str,
n: int = 5
) -> list[dict]:
desc = describe_image(image_path)
results = collection.query(
query_texts=[desc],
n_results=n,
where={"type": "text"}
)
matches = []
for doc, meta, dist in zip(
results["documents"][0],
results["metadatas"][0],
results["distances"][0]
):
matches.append({
"text": doc,
"metadata": meta,
"distance": dist,
})
return matches
Búsqueda combinada (sin filtro de tipo)
def search_all(
collection,
query: str,
n: int = 10
) -> list[dict]:
results = collection.query(
query_texts=[query],
n_results=n
)
matches = []
for doc, meta, dist in zip(
results["documents"][0],
results["metadatas"][0],
results["distances"][0]
):
matches.append({
"content": doc,
"type": meta.get("type", "unknown"),
"distance": dist,
})
return matches
all_results = search_all(collection, "arquitectura de microservicios")
for r in all_results:
print(f" [{r['type']}] {r['distance']:.4f} — {r['content'][:60]}...")
Troubleshooting
Error: "Invalid image format"
SUPPORTED_FORMATS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
def validate_image(image_path: str) -> bool:
path = Path(image_path)
if not path.exists():
print(f"ERROR: Archivo no existe: {image_path}")
return False
if path.suffix.lower() not in SUPPORTED_FORMATS:
print(f"ERROR: Formato no soportado: {path.suffix}")
return False
size_mb = path.stat().st_size / (1024 * 1024)
if size_mb > 20:
print(f"ERROR: Archivo muy grande: {size_mb:.1f} MB (máx 20 MB)")
return False
return True
Error: "Embedding dimension mismatch"
Si mezclas embeddings de OpenAI (1536d) con CLIP (512d), no puedes compararlos ni almacenarlos en la misma collection.
Solución: Usa una sola estrategia por collection.
- Collection "openai_index": solo embeddings de text-embedding-3-small
- Collection "clip_index": solo embeddings de CLIP
Descripciones vagas del modelo de visión
Si el modelo describe una imagen como "Una imagen con texto y formas", el embedding resultante será poco útil.
Solución: Usa un prompt específico para tu dominio (ver sección de prompts).
- Para diagramas: pide componentes y relaciones
- Para productos: pide tipo, color, material
- Para gráficos: pide tipo de gráfico, variables, tendencia
ChromaDB: "Collection already exists"
collection = chroma_client.get_or_create_collection(
name="multimodal",
embedding_function=openai_ef
)
CLIP: lentitud en CPU
CLIP en CPU puede tardar ~1-2 segundos por imagen. Con GPU, baja a ~0.05s.
device = "cuda" if torch.cuda.is_available() else "cpu"
clip_model = CLIPModel.from_pretrained(CLIP_MODEL_NAME).to(device)
def clip_image_embedding_gpu(image_path: str) -> list[float]:
image = Image.open(image_path).convert("RGB")
inputs = clip_processor(images=image, return_tensors="pt")
inputs = {k: v.to(device) for k, v in inputs.items()}
with torch.no_grad():
features = clip_model.get_image_features(**inputs)
normalized = features / features.norm(dim=-1, keepdim=True)
return normalized[0].cpu().numpy().tolist()
Ejercicios
Ejercicio 1: Embedding de texto para búsqueda semántica
Genera embeddings para una lista de queries y una lista de descripciones de imágenes. Encuentra el par query-descripción más similar.
Ver solución
def find_best_match(
queries: list[str],
descriptions: list[str]
) -> list[tuple[str, str, float]]:
query_embs = batch_text_embeddings(queries)
desc_embs = batch_text_embeddings(descriptions)
matches = []
for i, q_emb in enumerate(query_embs):
best_score = -1
best_desc = ""
for j, d_emb in enumerate(desc_embs):
score = cosine_similarity(q_emb, d_emb)
if score > best_score:
best_score = score
best_desc = descriptions[j]
matches.append((queries[i], best_desc, best_score))
return matches
queries = [
"diagrama de base de datos",
"foto de equipo de desarrollo",
"gráfico de rendimiento del servidor",
]
descriptions = [
"Fotografía de un grupo de personas en una oficina moderna",
"Diagrama entidad-relación con tablas usuarios, pedidos y productos",
"Gráfico de líneas mostrando latencia del servidor en los últimos 30 días",
]
for query, desc, score in find_best_match(queries, descriptions):
print(f" {query} → {desc[:50]}... ({score:.4f})")
Ejercicio 2: Comparar estrategias de embedding
Dada una imagen de un diagrama técnico, genera embeddings con ambas estrategias (Vision+Embedding y CLIP). Busca contra un conjunto de textos y compara cuál estrategia produce mejores resultados.
Ver solución
def compare_strategies(
image_path: str,
search_texts: list[str]
) -> dict:
vision_result = image_to_embedding(image_path)
vision_emb = vision_result["embedding"]
clip_img_emb = clip_image_embedding(image_path)
vision_scores = []
clip_scores = []
for text in search_texts:
text_emb_openai = text_embedding(text)
vision_scores.append({
"text": text,
"score": cosine_similarity(vision_emb, text_emb_openai)
})
text_emb_clip = clip_text_embedding(text)
clip_scores.append({
"text": text,
"score": cosine_similarity(clip_img_emb, text_emb_clip)
})
vision_scores.sort(key=lambda x: x["score"], reverse=True)
clip_scores.sort(key=lambda x: x["score"], reverse=True)
return {
"vision_description": vision_result["description"],
"vision_ranking": vision_scores,
"clip_ranking": clip_scores,
}
texts = [
"arquitectura de microservicios",
"receta de cocina",
"diagrama de flujo de datos",
"paisaje natural",
"código fuente Python",
]
comparison = compare_strategies("test_docs/diagram.png", texts)
print(f"Descripción Vision: {comparison['vision_description']}")
print("\nRanking Vision+Embedding:")
for r in comparison["vision_ranking"]:
print(f" {r['score']:.4f} — {r['text']}")
print("\nRanking CLIP:")
for r in comparison["clip_ranking"]:
print(f" {r['score']:.4f} — {r['text']}")
Ejercicio 3: Búsqueda de imagen más similar en ChromaDB
Crea una collection en ChromaDB con 5+ documentos (mix de texto y descripciones de imagen). Implementa una función que reciba un query de texto y devuelva el resultado más similar, indicando si es texto o imagen.
Ver solución
def build_and_search_index(
text_chunks: list[str],
image_descriptions: list[dict],
query: str
) -> dict:
openai_ef = embedding_functions.OpenAIEmbeddingFunction(
model_name="text-embedding-3-small"
)
chroma = chromadb.Client()
coll = chroma.get_or_create_collection(
"search_test", embedding_function=openai_ef
)
for i, chunk in enumerate(text_chunks):
coll.add(
documents=[chunk],
ids=[f"text_{i}"],
metadatas=[{"type": "text"}]
)
for i, img in enumerate(image_descriptions):
coll.add(
documents=[img["description"]],
ids=[f"img_{i}"],
metadatas=[{
"type": "image",
"source": img["source"]
}]
)
results = coll.query(query_texts=[query], n_results=1)
best = {
"content": results["documents"][0][0],
"type": results["metadatas"][0][0]["type"],
"distance": results["distances"][0][0],
}
chroma.delete_collection("search_test")
return best
result = build_and_search_index(
text_chunks=[
"FastAPI es un framework web moderno para Python",
"Docker permite contenerizar aplicaciones",
"PostgreSQL es una base de datos relacional",
],
image_descriptions=[
{"description": "Diagrama de arquitectura con API Gateway y microservicios", "source": "arch.png"},
{"description": "Captura de terminal mostrando logs de Docker Compose", "source": "docker.png"},
],
query="cómo se comunican los microservicios"
)
print(f"Resultado: [{result['type']}] {result['content']}")
Ejercicio 4: Top-K con scores normalizados
Implementa una función que busque los K resultados más relevantes y devuelva scores normalizados entre 0 y 1 (donde 1 = match perfecto).
Ver solución
def search_top_k_normalized(
collection,
query: str,
k: int = 5
) -> list[dict]:
results = collection.query(
query_texts=[query],
n_results=k
)
if not results["documents"][0]:
return []
distances = results["distances"][0]
max_dist = max(distances) if distances else 1.0
matches = []
for doc, meta, dist in zip(
results["documents"][0],
results["metadatas"][0],
distances
):
normalized_score = 1.0 - (dist / max_dist) if max_dist > 0 else 1.0
matches.append({
"content": doc,
"type": meta.get("type", "unknown"),
"raw_distance": dist,
"normalized_score": round(normalized_score, 4),
})
return matches
Resumen
- Los embeddings de imágenes convierten contenido visual en vectores comparables con texto.
- Estrategia 1 (Vision → Embedding): describe la imagen con un LLM y genera embedding del texto. Simple, compatible, pero pierde información visual.
- Estrategia 2 (CLIP): genera embeddings directamente en un espacio compartido texto-imagen. Más preciso para búsqueda visual, gratuito y local.
- La similitud coseno mide qué tan cercanos son dos vectores (0 a 1 para vectores normalizados).
- ChromaDB es un vector store local ideal para desarrollo: almacena embeddings con metadata y permite búsqueda por similitud.
- Para volúmenes grandes, usa batch processing y async para describir imágenes en paralelo.
- El prompt de descripción impacta directamente la calidad del embedding en la Estrategia 1.
Recursos Adicionales
- CLIP Paper (Radford et al. 2021) — El paper original de CLIP
- Hugging Face CLIP — Modelo CLIP disponible
- OpenAI Embeddings — Guía oficial de embeddings
- ChromaDB Docs — Documentación completa de ChromaDB
- Cosine Similarity Explained — Guía visual de similitud coseno