Módulo 3: Query Optimization
Cápsula 03: Query expansion — cuando una query no es suficiente
Descripción de la cápsula
En la cápsula 02 viste que las queries del usuario son frecuentemente ambiguas, incompletas o mal formuladas. La técnica más directa para resolver el problema de ambigüedad es query expansion: en vez de buscar con la query original (única, posiblemente mal interpretada), generas múltiples versiones que cubren las interpretaciones probables, buscas con cada una, y combinas los rankings.
El insight pedagógico clave: si el usuario escribe "fastapi auth", no sabes si quiere OAuth2, JWT, API keys, basic auth o sesiones. Pero puedes generar 5 queries que cubran las 5 interpretaciones, buscar con cada una, y devolverle al LLM los mejores resultados de las 5 búsquedas combinadas. La probabilidad de capturar la respuesta correcta sube de ~50% (con la query original ambigua) a ~85% (con las 5 expandidas).
Esta cápsula te enseña cómo generar expansions de calidad con un LLM (no es trivial — un prompt mal diseñado produce expansions ruidosas), cómo combinar resultados con Reciprocal Rank Fusion, y cómo decidir cuándo expandir vs cuándo usar la query directa.
Al finalizar esta cápsula serás capaz de:
- ✅ Generar expansiones de queries con un LLM usando prompts calibrados
- ✅ Implementar Reciprocal Rank Fusion (RRF) para combinar múltiples rankings
- ✅ Decidir cuándo expandir (queries ambiguas) vs cuándo no (queries específicas)
- ✅ Calcular el costo extra (latencia + LLM calls + retrieval calls) y justificarlo
- ✅ Anticipar la trampa más cara: expansiones que cambian la intención del usuario
- ✅ Implementar expansion con cache para queries repetidas
Tiempo estimado: 30-35 minutos
El insight: una query, múltiples interpretaciones, múltiples búsquedas
Piensa en cómo googleas cuando no encuentras algo. La primera query no funciona, así que pruebas variantes:
Query 1: "fastapi auth" → no encontró lo que querías
Query 2: "fastapi oauth2" → encontró pero no específico
Query 3: "fastapi password bearer" → encontró exactamente lo que querías
Lo que tú haces manualmente con 3 queries iterativas, query expansion lo hace automáticamente con 5 queries en paralelo y combina los resultados:
┌─────────────────────────────────────────────────┐
│ User query: "fastapi auth" │
└────────────────────┬────────────────────────────┘
│ LLM expansion
▼
┌─────────────────────────────────────────────────┐
│ Query 1: "How to implement OAuth2 in FastAPI?" │
│ Query 2: "FastAPI JWT authentication" │
│ Query 3: "FastAPI API key auth" │
│ Query 4: "FastAPI session-based auth" │
│ Query 5: "FastAPI security best practices" │
└────────────────────┬────────────────────────────┘
│ Cada query → search
▼
┌──────────────────────────────────────────────────┐
│ 5 result sets (top-10 cada uno = 50 docs) │
└────────────────────┬─────────────────────────────┘
│ Reciprocal Rank Fusion
▼
┌─────────────────────────────────────────────────┐
│ Top-5 final: docs que aparecen alto en │
│ MÚLTIPLES queries (señal fuerte de relevancia) │
└─────────────────────────────────────────────────┘
Por qué funciona: un documento que aparece en posición #2 con la query "FastAPI OAuth2" y en posición #1 con "FastAPI security best practices" tiene señal de relevancia más fuerte que uno que aparece solo en una de las búsquedas. RRF captura esa intersección de rankings.
Generar expansions de calidad con un LLM
El prompt es la parte sensible. Un prompt mal diseñado genera expansions:
- Demasiado parecidas (no agregan diversidad de interpretación)
- Demasiado distintas (cambian la intención del usuario)
- Genéricas (no aprovechan vocabulario específico del dominio)
# query_expansion.py
from openai import OpenAI
from pydantic import BaseModel, Field
from typing import List
import os
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
class ExpandedQueries(BaseModel):
queries: List[str] = Field(
description="Diverse alternative queries that cover different interpretations of the original",
min_items=3,
max_items=8,
)
reasoning: str = Field(
description="One-sentence explanation of how the queries cover different interpretations"
)
SYSTEM_PROMPT = """You are an expert at expanding search queries for retrieval systems.
Given a short or ambiguous user query, generate 5 alternative queries that:
1. Cover the most likely DIFFERENT interpretations of the original
2. Use natural language (full questions, not keywords)
3. Preserve the user's INTENT — don't add unrelated topics
4. Use specific terminology when relevant (e.g., if the topic is FastAPI, use FastAPI-specific terms)
5. Are mutually distinct (each one explores a different angle)
Examples:
Input: "fastapi auth"
Output queries:
1. How to implement OAuth2 authentication in FastAPI?
2. FastAPI JWT token authentication tutorial
3. How to use API keys for authentication in FastAPI?
4. FastAPI session-based authentication with cookies
5. Best practices for securing FastAPI endpoints
Input: "ml deployment"
Output queries:
1. How to deploy machine learning models to production?
2. ML model deployment with Docker containers
3. Serverless deployment for machine learning models (AWS Lambda, Cloud Run)
4. Real-time vs batch ML model serving
5. CI/CD pipelines for ML model deployment"""
def expand_query(query: str, num_expansions: int = 5) -> ExpandedQueries:
"""Generate diverse query expansions preserving original intent."""
response = client.beta.chat.completions.parse(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{
"role": "user",
"content": f'User query: "{query}"\n\nGenerate {num_expansions} alternative queries.',
},
],
response_format=ExpandedQueries,
temperature=0.5, # algo de creatividad para diversidad
)
return response.choices[0].message.parsed
# Probar
expanded = expand_query("fastapi auth", num_expansions=5)
print(f"Reasoning: {expanded.reasoning}\n")
for i, q in enumerate(expanded.queries, 1):
print(f"{i}. {q}")
Output esperado:
Reasoning: The original query 'fastapi auth' is ambiguous and could refer to several authentication methods. The expansions cover the main implementation patterns.
1. How to implement OAuth2 authentication in FastAPI applications?
2. FastAPI JWT token authentication implementation guide
3. How to use API key authentication in FastAPI?
4. Implementing session-based authentication in FastAPI with cookies
5. FastAPI security best practices and common authentication patterns
Por qué structured outputs
Sin structured outputs, los LLMs tienden a:
- Devolver formatos inconsistentes (a veces numerados, a veces bullets, a veces texto libre)
- Agregar comentarios o explicaciones en el medio que rompen el parsing
- Devolver más o menos queries que las pedidas
response_format=ExpandedQueries (con Pydantic) garantiza una lista limpia parseable.
Por qué temperature=0.5
temperature=0produce expansions casi idénticas a la query original (poca diversidad).temperature=1.0produce expansions creativas pero a veces fuera de tema.0.5es sweet spot: diversas pero respetan la intención.
Reciprocal Rank Fusion (RRF): la fusión correcta
Después de buscar con las 5 queries, tienes 5 result sets. ¿Cómo combinar 5 rankings en uno solo?
Opción mala 1 — sumar scores: los scores de cosine de distintas queries no son comparables. Sumarlos da rankings sin sentido.
Opción mala 2 — usar solo el top-1 de cada uno: desperdicia los demás resultados. Si un documento aparece como #2 en 4 queries distintas, eso es señal fuerte que se ignora.
Opción correcta — Reciprocal Rank Fusion (RRF): combina rankings ignorando los scores absolutos. Solo importa la posición de cada documento en cada ranking.
Fórmula
RRF_score(doc) = Σ_i 1 / (k + rank_i(doc))
donde:
k = constante (típicamente 60)
rank_i(doc) = posición del doc en el ranking i (1, 2, 3, ...)
Si el doc no aparece en un ranking, contribuye 0.
Intuición:
- Doc en posición #1 contribuye 1/(60+1) = 0.0164
- Doc en posición #5 contribuye 1/(60+5) = 0.0154
- Doc en posición #20 contribuye 1/(60+20) = 0.0125
Las contribuciones son no lineales — diferencia entre top-5 y top-20 importa, pero diferencia entre #1 y #2 es pequeña. La función premia documentos que aparecen consistentemente alto en múltiples rankings.
Implementación
# rrf.py
from typing import List
from collections import defaultdict
def reciprocal_rank_fusion(
rankings: List[List[str]],
k: int = 60,
) -> List[tuple[str, float]]:
"""
Combine multiple rankings using Reciprocal Rank Fusion.
Args:
rankings: List of rankings, each ranking is a list of doc_ids ordered by relevance.
k: RRF constant (default 60, value used in original paper).
Returns:
List of (doc_id, rrf_score) tuples, sorted by score descending.
"""
rrf_scores = defaultdict(float)
for ranking in rankings:
for rank, doc_id in enumerate(ranking, start=1):
rrf_scores[doc_id] += 1.0 / (k + rank)
sorted_results = sorted(rrf_scores.items(), key=lambda x: -x[1])
return sorted_results
# Ejemplo de uso
ranking_1 = ["doc_a", "doc_b", "doc_c", "doc_d", "doc_e"] # query 1
ranking_2 = ["doc_b", "doc_a", "doc_f", "doc_c", "doc_g"] # query 2
ranking_3 = ["doc_c", "doc_a", "doc_b", "doc_h", "doc_d"] # query 3
fused = reciprocal_rank_fusion([ranking_1, ranking_2, ranking_3])
print("Documento : Score RRF")
for doc_id, score in fused[:5]:
print(f" {doc_id}: {score:.4f}")
Output:
Documento : Score RRF
doc_a: 0.0492 ← aparece en posición 1, 2, 2 (top 3 en todos)
doc_b: 0.0476 ← aparece en posición 2, 1, 3 (top 3 en todos)
doc_c: 0.0467 ← aparece en posición 3, 4, 1
doc_d: 0.0312 ← aparece en posición 4, -, 5
doc_e: 0.0164 ← aparece solo en una
doc_a gana porque está consistentemente en top-3 de las 3 queries. doc_e queda último porque solo aparece en una.
Pipeline completo end-to-end
# expansion_pipeline.py
import chromadb
from chromadb.utils import embedding_functions
import os
openai_ef = embedding_functions.OpenAIEmbeddingFunction(
api_key=os.getenv("OPENAI_API_KEY"),
model_name="text-embedding-3-small",
)
chroma_client = chromadb.PersistentClient(path="./chroma_db")
collection = chroma_client.get_collection("docs", embedding_function=openai_ef)
def search_with_expansion(query: str, top_k: int = 5, num_expansions: int = 5):
"""
Pipeline completo: expand query → search each → fuse with RRF → top-K final.
"""
# 1. Expandir
expansion_result = expand_query(query, num_expansions=num_expansions)
queries_to_search = [query] + expansion_result.queries # incluir la original también
# 2. Buscar con cada query (en paralelo si es posible)
all_rankings = []
for q in queries_to_search:
results = collection.query(query_texts=[q], n_results=10)
all_rankings.append(results['ids'][0])
# 3. Fusion con RRF
fused = reciprocal_rank_fusion(all_rankings)
top_ids = [doc_id for doc_id, _ in fused[:top_k]]
# 4. Recuperar documentos finales
final_docs = collection.get(ids=top_ids)
return final_docs
# Probar
query = "fastapi auth"
results = search_with_expansion(query, top_k=5)
print(f"Query: {query}\n")
print(f"Top 5 después de expansion + RRF:")
for i, (doc, doc_id) in enumerate(zip(results['documents'], results['ids']), 1):
print(f"\n#{i} [{doc_id}]")
print(f" {doc[:120]}...")
Optimización: queries en paralelo
Si tienes 5 queries que buscar, hacerlo secuencial agrega 5x la latencia. Paraleliza:
from concurrent.futures import ThreadPoolExecutor
def parallel_retrieve(queries: list[str], top_k_per_query: int = 10):
"""Ejecuta múltiples retrievals en paralelo."""
def search_one(q):
return collection.query(query_texts=[q], n_results=top_k_per_query)
with ThreadPoolExecutor(max_workers=5) as executor:
results_list = list(executor.map(search_one, queries))
return [r['ids'][0] for r in results_list]
Con 5 workers paralelos, las 5 queries de retrieval ejecutan en el tiempo de 1.
Cuándo expandir y cuándo no
Query expansion no es gratis: agrega ~600-800ms (1 LLM call + 5 retrieval calls + RRF) y ~$0.001 por query (LLM call). No siempre vale.
Cuándo SÍ expandir
| Tipo de query | ¿Expandir? | Por qué |
|---|---|---|
| Ambigua (1-3 tokens) | ✅ Sí | Cubre múltiples interpretaciones |
| Genérica ("how to deploy") | ✅ Sí | El usuario no especifica el caso |
| Conceptual amplia ("authentication") | ✅ Sí | Multiple interpretations |
| Multilingüe sin idioma específico | ✅ Sí | Expandir en varios idiomas |
Cuándo NO expandir
| Tipo de query | ¿Expandir? | Por qué |
|---|---|---|
| Muy específica con identificadores | ❌ No | El usuario sabe exactamente qué quiere |
| Match exacto de error code | ❌ No | Expandir puede perder el match exacto |
| Queries con SLA estricto (<200ms) | ❌ No | La latencia extra rompe SLA |
| Queries cortas pero unívocas ("Stripe API key") | ❌ No | No hay ambigüedad real |
Skip dinámico
Lo ideal es decidir por query si vale expandir:
def should_expand(query: str) -> bool:
"""Heurística simple para decidir si expandir."""
word_count = len(query.split())
# Queries muy cortas son ambiguas → expandir
if word_count <= 3:
return True
# Queries con identificadores exactos → no expandir
has_identifier = any(c in query for c in ['_', '`', '(', ')', '/']) or \
any(word.isupper() for word in query.split() if len(word) > 2)
if has_identifier:
return False
# Queries largas y bien formuladas → no expandir
if word_count > 12 and "?" in query:
return False
# Default: expandir
return True
def smart_search(query: str, top_k: int = 5):
if should_expand(query):
return search_with_expansion(query, top_k=top_k)
else:
results = collection.query(query_texts=[query], n_results=top_k)
return results
Beneficio: ~30-50% de queries no necesitan expansion. Ahorra latencia y costo en esos casos sin perder calidad.
Trampas y errores comunes
Trampa 1: prompt sin ejemplos lleva a expansions débiles
El error:
Generate 5 search queries related to: "fastapi auth"
Síntoma: el LLM genera variaciones triviales ("fastapi authentication", "fastapi auth tutorial") que no agregan diversidad.
Cómo prevenir: prompt con ejemplos concretos (few-shot). Los LLMs aprenden mucho mejor de ejemplos que de instrucciones abstractas.
Trampa 2: temperature=0 genera expansions idénticas
El error: temperature=0 por consistencia.
Síntoma: las 5 "expansions" son casi idénticas. RRF no aprovecha diversidad.
Cómo prevenir: temperature=0.4-0.6 para diversidad sin desviarse de la intención.
Trampa 3: ignorar la query original
El error:
queries_to_search = expanded_queries # solo las 5 generadas
Síntoma: si la query original ya era buena, pierdes su ranking en la fusión.
Cómo prevenir: siempre incluir la query original además de las expandidas. La query original es una "expansion" más, con peso igual:
queries_to_search = [query] + expanded_queries
Trampa 4: número de expansions demasiado alto
El error: generar 10-20 expansions "para estar seguros".
Síntoma:
- Latencia se dispara (10 retrievals + LLM call más larga).
- Costo sube linealmente.
- Calidad mejora marginalmente — 5 expansions cubren las interpretaciones probables; las 10-20 agregan ruido.
Cómo prevenir: mantener entre 4 y 6 expansions. Sweet spot empírico.
Trampa 5: expansions que cambian la intención
El error: prompt sin guardrails. El LLM expande "why is FastAPI slow" a queries como "how to make FastAPI faster".
Síntoma: la expansion ahora busca sobre "cómo optimizar" cuando el usuario quería entender "por qué a veces es lento". El sistema responde lo opuesto.
Cómo prevenir: instrucción explícita en el prompt: "Preserve the user's INTENT — don't add unrelated topics or change the question's stance."
Trampa 6: cachear sin considerar variantes triviales
El error: cachear expansion para "fastapi auth" y "FastAPI Auth" como queries distintas.
Síntoma: cache hit rate bajo (~10%) porque cada variante de capitalization/whitespace genera nueva expansion.
Cómo prevenir: normalizar la query antes de cachear:
import hashlib
def cache_key(query: str) -> str:
normalized = query.lower().strip()
normalized = " ".join(normalized.split()) # collapse whitespace
return hashlib.md5(normalized.encode()).hexdigest()
Hit rate típico con normalización: 40-60%.
Ejercicio aplicado
Escenario: eres AI Engineer en una empresa de soporte técnico para herramientas DevOps. Datos del log:
- 10,000 queries reales del último mes
- 51% son queries cortas/ambiguas (1-4 tokens) — "k8s deploy", "docker timeout", "jenkins fail"
- 30% son queries con identificadores exactos — "
kubectl get podsnot working", "ERR_NETWORK_TIMEOUT_504" - 19% son queries bien formuladas
Sistema actual: cosine + cross-encoder rerank. Métricas:
- Precision@5: 84%
- Recall@5: 68% (este es el problema — sistema no encuentra muchos docs relevantes)
Tu trabajo:
- Decide si query expansion ayudaría aquí. Justifica con los datos.
- Diseña la implementación incluyendo skip dinámico.
- Estima impacto esperado y costo extra mensual (5K queries/día).
Solución
1. Query expansion sí ayudaría — el problema es recall, no precision
El diagnóstico clave: precision (84%) está bien pero recall (68%) está bajo. Eso sugiere que el sistema no encuentra los documentos relevantes, no que los rankee mal. Query expansion ataca exactamente ese problema generando múltiples interpretaciones que cubren más documentos relevantes.
Análisis del log:
- 51% queries ambiguas → cada una probablemente está perdiendo 30-40% de recall por interpretación errada
- Aplicando query expansion a esas 51%, recall esperado de la categoría: 50% → 75%
- Recall global esperado: 68% → 82-85%
Para queries con identificadores (30%): NO expandir. Expandir "ERR_NETWORK_TIMEOUT_504" genera variantes que pierden el match exacto del código. Skip dinámico es esencial.
Para queries bien formuladas (19%): NO expandir. Son específicas, no necesitan más interpretaciones.
2. Implementación con skip dinámico
def smart_pipeline(query: str, top_k: int = 5):
"""Pipeline con expansion condicional."""
# Heurística para decidir si expandir
word_count = len(query.split())
has_identifier = any(c in query for c in ['_', '`', '/']) or \
any(re.match(r'[A-Z][A-Z_]+\d*', w) for w in query.split()) # ej: ERR_TIMEOUT_504
is_well_formed = word_count > 8 and "?" in query
if has_identifier or is_well_formed:
# Skip expansion: query directa + retrieval + rerank
return search_with_rerank_only(query, top_k=top_k)
elif word_count <= 5:
# Query corta/ambigua: usar expansion
return search_with_expansion_and_rerank(query, top_k=top_k)
else:
# Default: pipeline directo
return search_with_rerank_only(query, top_k=top_k)
def search_with_expansion_and_rerank(query: str, top_k: int = 5):
# 1. Expandir
expansion = expand_query(query, num_expansions=5)
queries_to_search = [query] + expansion.queries
# 2. Retrieval paralelo
all_rankings = parallel_retrieve(queries_to_search, top_k_per_query=15)
# 3. RRF para combinar
fused = reciprocal_rank_fusion(all_rankings)
candidate_ids = [doc_id for doc_id, _ in fused[:30]]
# 4. Recuperar documentos completos
candidates = collection.get(ids=candidate_ids)
# 5. Cross-encoder rerank sobre los 30 candidatos
reranked = cross_encoder_rerank(query, candidates['documents'], top_k=top_k)
return reranked
3. Estimación de impacto y costo
Impacto esperado:
| Categoría de query | % del tráfico | Recall actual | Recall con expansion |
|---|---|---|---|
| Ambiguas (expansion) | 51% | ~50% | ~80% |
| Identificadores (skip) | 30% | ~85% | sin cambio |
| Bien formuladas (skip) | 19% | ~80% | sin cambio |
Recall global esperado: 0.51 × 0.80 + 0.30 × 0.85 + 0.19 × 0.80 = 81.5% (vs 68% actual = +13.5 puntos).
Latencia esperada:
- Queries con expansion (51%): 1500ms (vs 400ms sin expansion) → +1100ms en esa categoría.
- Queries sin expansion (49%): sin cambio.
- Latencia promedio: 0.51 × 1500 + 0.49 × 400 = 961ms (vs 400ms baseline).
Si la latencia extra es problema: considerar query expansion solo para queries que detectes como ambiguas con un threshold más estricto (ej: 1-3 tokens en vez de 1-5).
Costo extra mensual:
queries_per_day = 5000
expansion_rate = 0.51 # 51% expanden
expanded_queries_per_day = queries_per_day * expansion_rate
# Cada expansion: 1 LLM call (~500 tokens in, ~200 tokens out con gpt-4o-mini)
cost_per_expansion = (500 / 1_000_000 * 0.15) + (200 / 1_000_000 * 0.60)
# = $0.000195
monthly_cost = expanded_queries_per_day * 30 * cost_per_expansion
print(f"Costo mensual de expansion: ${monthly_cost:.2f}")
Output: ~$15/mes. Despreciable vs el valor del producto.
Plan de validación:
- Construir eval set de 100 queries (mezcla de las tres categorías) con ground truth.
- Implementar smart_pipeline con feature flag.
- A/B test 1 semana: 50% pipeline actual, 50% smart_pipeline.
- Métrica primaria: recall@5. Secundaria: precision@5 (no debería caer), latencia p95.
- Si recall sube >10 puntos sin caída de precision >2%, deployar.
Riesgos a monitorear:
- Latencia de queries expandidas: debe estar bajo 2 segundos. Si supera, ajustar paralelismo.
- Detector de "should_expand": medir falso positivos (expandió queries específicas) y falso negativos (no expandió queries ambiguas). Refinar la heurística.
- LLM expansion quality: revisar manualmente 50 expansions semanal — ¿preservan intención?
Resumen y siguiente paso
Lo que aprendiste:
- Query expansion genera múltiples versiones de una query ambigua y combina los resultados para mejorar recall.
- Mejora típica: +20-30% recall, especialmente en queries cortas o conceptuales.
- Costo: ~600-800ms latencia + ~$0.001 por query (LLM call para expansion).
- Generar expansions de calidad requiere prompt con ejemplos (few-shot), structured outputs, y temperature=0.4-0.6.
- Reciprocal Rank Fusion combina rankings ignorando scores absolutos. Es la técnica correcta vs sumar scores ingenuamente.
- Skip dinámico: no expandir queries con identificadores exactos o bien formuladas. Ahorra ~50% del costo extra.
- Incluir la query original en el set de búsqueda, no solo las expansiones.
- Trampa común: expansiones que cambian la intención del usuario. Prompt explícito previene.
Checkpoint: antes de avanzar, deberías poder:
- Implementar query expansion con structured outputs y RRF en menos de 100 líneas.
- Diseñar skip dinámico con heurísticas para detectar queries que NO necesitan expansion.
- Calcular costo extra mensual de query expansion para un volumen dado.
Siguiente cápsula: 04 — Query Rewriting.
Query expansion ataca queries ambiguas. Query rewriting ataca queries incompletas o mal formuladas. La idea: en vez de generar múltiples queries paralelas, transformar la query original en una mejor versión (con más contexto, mejor estructurada). Es la técnica complementaria — y juntas cubren los tres modos de falla que viste en M03/02.
Recursos
- Reciprocal Rank Fusion Paper (Cormack et al., 2009) — Paper original
- LangChain — Multi-Query Retriever — Implementación con LangChain
- LlamaIndex — Query Transformation — Patrones alternativos
- Pinecone — Query Optimization — Tutorial práctico
- OpenAI — Structured Outputs — Para implementación robusta
- Anthropic — Contextual Retrieval — Técnica complementaria
Tiempo estimado: 30-35 minutos Siguiente: 04-query-rewriting.md