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

AspectoDetalle
VentajaCompatible con cualquier vector store que soporte embeddings de texto
VentajaLa descripción es interpretable — puedes leerla y verificar qué "vio" el modelo
VentajaUsa la misma API de embeddings para todo (texto e imágenes)
LimitaciónCalidad depende de la descripción — si el modelo describe mal, el embedding es malo
LimitaciónDoble costo: una llamada a Vision + una llamada a Embeddings
LimitaciónPierde 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

AspectoEstrategia 1 (Vision → Embedding)Estrategia 2 (CLIP)
Dimensión1536 (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 textoAlta — embeddings de texto de OpenAI son excelentesMenor — CLIP fue entrenado para alineación, no para NLU profundo
Calidad imagenDepende de la descripción del modeloDirecta — sin pérdida por traducción a texto
Espacio compartidoNO — embeddings de imagen están en espacio de textoSÍ — texto e imagen viven en el mismo espacio
RequisitosSolo API key de OpenAIGPU recomendada (funciona en CPU, más lento)
Mejor paraDocumentos donde la descripción captura bien el contenidoBú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ísticaChromaDB
Instalaciónpip install chromadb
ServidorNo necesita — funciona embebido
PersistenciaEn memoria o disco
BúsquedaSimilitud coseno, L2, IP
MetadataFiltros sobre metadata arbitraria
Embedding functionsBuilt-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

  1. CLIP Paper (Radford et al. 2021) — El paper original de CLIP
  2. Hugging Face CLIP — Modelo CLIP disponible
  3. OpenAI Embeddings — Guía oficial de embeddings
  4. ChromaDB Docs — Documentación completa de ChromaDB
  5. Cosine Similarity Explained — Guía visual de similitud coseno