Módulo 3: Query Optimization
Cápsula 04: Query rewriting — transformar la query antes de buscar
Descripción de la cápsula
Query expansion (cápsula 03) ataca el problema de ambigüedad: una query con múltiples interpretaciones se convierte en varias queries que cubren cada una. Query rewriting ataca un problema distinto: queries incompletas, mal formuladas o sin contexto. En vez de generar múltiples versiones paralelas, transformas la query original en una mejor versión y buscas con esa única versión mejorada.
Cuándo quieres cada técnica:
Ambigua ("fastapi auth") → expansion (5 queries paralelas)
Incompleta ("how to deploy") → rewriting con contexto del chat
Mal formulada ("python async perf") → rewriting estructural a natural language
Esta cápsula te enseña a implementar query rewriting con tres variantes (clarificación, contextual, estructural), elegir la apropiada para cada caso, y combinarla con otras técnicas (rewriting + expansion en cascada para queries que tienen ambos problemas).
Al finalizar esta cápsula serás capaz de:
- ✅ Implementar query rewriting con LLM y structured outputs
- ✅ Diferenciar tres patrones: clarificación, rewriting con contexto del chat, rewriting estructural
- ✅ Combinar rewriting con expansion para queries con problemas múltiples
- ✅ Decidir cuándo rewriting es suficiente vs cuándo además necesitas expansion
- ✅ Anticipar la trampa más sutil: rewriting que cambia la intención del usuario
- ✅ Implementar guardrails que detectan rewrites problemáticos antes de usarlos
Tiempo estimado: 30-35 minutos
El insight: una query optimizada para tu sistema, no para Google
Como vimos en M03/02, los usuarios tipean queries optimizadas para Google (cortas, keyword-style). Tu sistema RAG necesita queries optimizadas para semantic search (oraciones completas en lenguaje natural). Rewriting es la traducción.
Query del usuario: "fastapi auth"
│
│ Rewriting (LLM)
▼
Query rewritten: "How do I implement authentication in FastAPI applications,
including OAuth2, JWT, and session-based methods?"
│
│ Buscar (cosine similarity)
▼
Top-K results
Diferencia clave con expansion:
- Expansion: 1 query → 5 queries → 5 búsquedas → fusión
- Rewriting: 1 query → 1 query mejorada → 1 búsqueda
Rewriting es más rápido (1 retrieval vs 5), más barato (1 LLM call más simple), pero solo funciona para problemas resolubles con una sola transformación. Para queries genuinamente ambiguas, expansion gana.
Tres variantes de rewriting
Variante 1: clarificación estructural
Transforma queries keyword-style en oraciones naturales completas. La forma más simple.
# query_rewriting.py
from openai import OpenAI
from pydantic import BaseModel, Field
import os
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
class RewrittenQuery(BaseModel):
rewritten: str = Field(description="The improved query in natural language")
reasoning: str = Field(description="One-sentence explanation of what changed")
STRUCTURAL_PROMPT = """You are an expert at rewriting search queries for retrieval systems.
Given a keyword-style or fragmentary query, rewrite it as a complete, natural-language
question. Preserve ALL keywords and the user's intent. Do NOT add information that
wasn't implied in the original.
Examples:
Original: "python async performance"
Rewritten: "How does Python's async/await pattern affect performance in concurrent applications?"
Original: "kubernetes pod restart"
Rewritten: "How do I restart a Kubernetes pod, and what are the implications?"
Original: "fastapi sql injection"
Rewritten: "How can I prevent SQL injection in FastAPI applications?"
Now rewrite the user's query."""
def rewrite_structural(query: str) -> RewrittenQuery:
"""Convert keyword-style query to natural language."""
response = client.beta.chat.completions.parse(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": STRUCTURAL_PROMPT},
{"role": "user", "content": f'Original: "{query}"\nRewritten:'},
],
response_format=RewrittenQuery,
temperature=0.1, # baja para consistencia, no creatividad
)
return response.choices[0].message.parsed
# Probar
test_queries = ["python async performance", "kubernetes pod restart", "fastapi sql injection"]
for q in test_queries:
result = rewrite_structural(q)
print(f"Original: {q}")
print(f"Rewritten: {result.rewritten}")
print(f"Reasoning: {result.reasoning}\n")
Variante 2: rewriting con contexto del chat
Cuando el usuario está en una conversación, su query "incompleta" suele tener contexto implícito en mensajes previos. Aprovechalo.
CONTEXTUAL_PROMPT = """You are an expert at rewriting search queries using conversation context.
The user is in a chat about a specific topic. Their previous messages may contain
context that the current query implicitly references.
Given the chat history and the current (potentially incomplete) query, rewrite the
query to be self-contained — including any context that's missing.
Rules:
1. ONLY add context that's clearly implied by the conversation
2. Do NOT add unrelated information
3. Preserve the user's exact intent and tone
4. If the query is already complete, return it unchanged
Examples:
Chat history:
- User: "I'm building a FastAPI app"
- Bot: "Great! What would you like to know?"
Current query: "how to add authentication"
Rewritten: "How do I add authentication to my FastAPI app?"
Chat history:
- User: "I'm using PostgreSQL with SQLAlchemy"
- Bot: "Sounds good. Any specific issue?"
Current query: "the connection keeps dropping"
Rewritten: "Why does my SQLAlchemy connection to PostgreSQL keep dropping?"
Chat history:
- User: "I'm deploying to AWS"
- Bot: "OK, are you using ECS or EKS?"
- User: "ECS"
- Bot: "Got it"
Current query: "how to set environment variables"
Rewritten: "How do I set environment variables in an ECS deployment on AWS?"
"""
def rewrite_with_context(query: str, chat_history: list[str]) -> RewrittenQuery:
"""Rewrite incomplete query using chat history."""
history_str = "\n".join(chat_history[-5:]) # últimos 5 mensajes
response = client.beta.chat.completions.parse(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": CONTEXTUAL_PROMPT},
{
"role": "user",
"content": f"Chat history:\n{history_str}\n\nCurrent query: \"{query}\"\nRewritten:",
},
],
response_format=RewrittenQuery,
temperature=0.2,
)
return response.choices[0].message.parsed
# Probar
chat = [
"User: I'm using FastAPI with PostgreSQL",
"Bot: Great, what would you like to know?",
"User: my queries are slow",
]
result = rewrite_with_context("how to optimize", chat)
print(f"Rewritten: {result.rewritten}")
# Output: "How do I optimize slow PostgreSQL queries in my FastAPI application?"
Variante 3: rewriting clarificador (typos, ambigüedad)
Para queries con typos, abreviaciones, o ambigüedad menor que se puede resolver con una sola interpretación dominante.
CLARIFY_PROMPT = """Rewrite this search query to fix typos, expand abbreviations, and clarify
without changing meaning. If the original is already clear, return it unchanged.
Examples:
Original: "fastpi autentication" → "FastAPI authentication"
Original: "k8s pod stuck" → "Kubernetes pod stuck"
Original: "psql conn timout" → "PostgreSQL connection timeout"
Original: "How to use OAuth2 in FastAPI?" → "How to use OAuth2 in FastAPI?" (sin cambios)
Original query: "{query}"
Rewritten:"""
def rewrite_clarify(query: str) -> str:
"""Fix typos and expand abbreviations."""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "user", "content": CLARIFY_PROMPT.format(query=query)},
],
temperature=0.0, # determinístico para consistencia
max_tokens=100,
)
return response.choices[0].message.content.strip()
Combinando rewriting con expansion en cascada
Para queries que tienen MÚLTIPLES problemas (incompleta + ambigua), combinar las dos técnicas:
def smart_query_optimization(query: str, chat_history: list[str] = None) -> list[str]:
"""
Pipeline completo de query optimization:
1. Rewriting (incompleta → completa, mal formulada → bien formulada)
2. Expansion (si la query rewritten es ambigua → múltiples interpretaciones)
"""
# Paso 1: rewriting según contexto
if chat_history and len(chat_history) > 0:
rewritten = rewrite_with_context(query, chat_history).rewritten
elif is_keyword_style(query):
rewritten = rewrite_structural(query).rewritten
else:
rewritten = query # ya está bien
# Paso 2: ¿la query rewritten sigue siendo ambigua?
if is_ambiguous(rewritten):
# Aplicar expansion sobre la query rewritten
expansion = expand_query(rewritten, num_expansions=4)
return [rewritten] + expansion.queries
else:
# Una sola query es suficiente
return [rewritten]
def is_keyword_style(query: str) -> bool:
"""Heurística simple para detectar keyword-style."""
word_count = len(query.split())
if word_count <= 5 and "?" not in query:
# Pocas palabras + sin signo de pregunta = probablemente keyword
return True
return False
def is_ambiguous(query: str) -> bool:
"""Detectar ambigüedad post-rewriting."""
word_count = len(query.split())
# Si después del rewrite sigue siendo corta, probablemente ambigua
if word_count <= 6:
return True
return False
Ejemplos:
Query original: "fastapi auth" (corta + ambigua)
Rewriting → "How do I implement authentication in FastAPI?" (estructurada)
Expansion → ["How do I implement OAuth2 auth in FastAPI?",
"How do I implement JWT auth in FastAPI?", ...]
Query original: "how to optimize" (incompleta, en chat de PostgreSQL)
Rewriting con contexto → "How do I optimize slow PostgreSQL queries?"
Expansion no necesaria (query es completa y específica)
Query original: "explain HNSW" (clara y específica)
Rewriting → "Explain HNSW algorithm" (sin cambio significativo)
Expansion no necesaria
Cuándo usar cada técnica
| Estado de la query | Mejor técnica | Por qué |
|---|---|---|
| Keyword style ("python async performance") | Rewriting estructural | Convertir a natural language, una sola query mejor |
| Incompleta con contexto disponible ("how to deploy" en chat de FastAPI) | Rewriting contextual | Agregar contexto del chat |
| Ambigua sin contexto ("fastapi auth") | Expansion | Múltiples interpretaciones probables |
| Con typo ("fastpi autentication") | Rewriting clarificador | Fix de spelling sin cambiar intención |
| Bien formulada ("How do I implement OAuth2 in FastAPI?") | Ninguna | Ya es óptima, skip optimization |
| Múltiples problemas (incompleta + ambigua) | Rewriting + Expansion | Cascada |
Trampas y errores comunes
Trampa 1: rewriting que cambia la intención
El error: prompt sin guardrails. El LLM "rewrites" "why is FastAPI slow" a "how to make FastAPI faster".
Síntoma: el sistema responde "para hacer FastAPI más rápido, hace X" cuando el usuario quería entender por qué a veces es lento.
Cómo prevenir: instrucción explícita en el prompt: "Preserve the user's exact intent. Do NOT change a 'why' question to a 'how to' question."
Trampa 2: rewriting con LLM lento por defecto
El error: usar GPT-4 para rewriting cuando GPT-4o-mini alcanza.
Síntoma: rewriting agrega 800ms cuando podría agregar 200ms.
Cómo prevenir: GPT-4o-mini es suficiente para rewriting (tarea estructural simple). GPT-4 solo si la calidad medible mejora significativamente — raro.
Trampa 3: chat history demasiado larga
El error: pasas los últimos 50 mensajes del chat al rewriter.
Síntoma: el contexto se vuelve ruidoso, el LLM puede mezclar tópicos de mensajes viejos.
Cómo prevenir: últimos 3-5 mensajes son suficientes. Si la query referencia algo de hace 20 mensajes, no es problema de rewriting — es que el usuario debería ser más explícito.
Trampa 4: aplicar rewriting a TODAS las queries
El error: activas rewriting para todo el tráfico sin discriminar.
Síntoma: queries que ya estaban bien formuladas pasan por LLM call innecesariamente. Costo extra significativo.
Cómo prevenir: detectar queries bien formuladas y saltarlas. Heurística simple:
def needs_rewriting(query: str) -> bool:
word_count = len(query.split())
has_question_mark = "?" in query
has_full_sentence_structure = any(query.lower().startswith(w) for w in [
"how", "what", "why", "when", "where", "which", "can", "should", "is", "do"
])
if word_count > 8 and (has_question_mark or has_full_sentence_structure):
return False # ya bien formulada
return True
Trampa 5: structured outputs en versión chica del modelo
El error: usas gpt-3.5-turbo con structured outputs.
Síntoma: el modelo no soporta structured outputs y devuelve JSON malformado.
Cómo prevenir: structured outputs requiere gpt-4o-mini o superior. Para modelos más viejos, usar prompt + parsing manual con validación estricta.
Trampa 6: rewriting agresivo de queries multilingües
El error: rewriting prompt en inglés. Recibe query en español. El LLM la traduce a inglés "porque es lo natural en el prompt".
Síntoma: la query traducida ya no matchea con docs en español. Recall colapsa.
Cómo prevenir: instrucción explícita "Preserve the original language of the query":
PROMPT_MULTILINGUAL = """Rewrite the query to natural language.
IMPORTANT: Preserve the original language of the query. If the query is in Spanish, the rewrite must also be in Spanish.
"""
Ejercicio aplicado
Escenario: eres AI Engineer en una empresa de soporte al cliente. Datos del log:
- 8000 queries/día
- 65% son queries en chat conversacional (con historial disponible)
- 25% son queries directas (sin contexto previo)
- 10% son queries con typos o abreviaciones técnicas
Análisis del log de 200 queries muestra:
"como deploy app" → Incompleta (en chat de FastAPI)
"k8s pod no funciona" → Abreviación (Kubernetes)
"timeout en mi api" → Incompleta (en chat de cierto endpoint)
"cómo configurar" → Incompleta (sin contexto en chat)
"fastapi async vs sync" → Bien formulada
"por qué falla mi código" → Vaga (necesita más info)
Tu trabajo:
- Diseña el pipeline de rewriting/expansion considerando los datos.
- Define el orden de fallback cuando una técnica no aplica.
- Estima el costo extra mensual.
Solución
1. Pipeline de rewriting con detección de tipo de query
def smart_query_pipeline(query: str, chat_history: list[str] = None) -> str | list[str]:
"""
Pipeline que aplica la técnica correcta según el estado de la query.
Retorna 1 query (si rewriting alcanza) o lista de queries (si expansion también).
"""
# Paso 1: clarificar typos/abreviaciones primero
if has_typo_or_abbreviation(query):
query = rewrite_clarify(query)
# ej: "k8s pod no funciona" → "Kubernetes pod no funciona"
# Paso 2: si está en chat, rewriting con contexto
if chat_history and len(chat_history) >= 2:
rewritten = rewrite_with_context(query, chat_history).rewritten
# ej: "como deploy app" + chat FastAPI → "Cómo deployar una app FastAPI"
# Si la query rewritten es específica, una sola búsqueda alcanza
if not is_ambiguous(rewritten):
return rewritten
# Si todavía es ambigua, expansion adicional
expansion = expand_query(rewritten, num_expansions=3)
return [rewritten] + expansion.queries
# Paso 3: sin chat history pero keyword-style → rewriting estructural
if is_keyword_style(query):
rewritten = rewrite_structural(query).rewritten
if not is_ambiguous(rewritten):
return rewritten
else:
expansion = expand_query(rewritten, num_expansions=4)
return [rewritten] + expansion.queries
# Paso 4: query bien formulada sin contexto → ¿es ambigua?
if is_ambiguous(query):
# Solo expansion, sin rewriting
expansion = expand_query(query, num_expansions=4)
return [query] + expansion.queries
# Paso 5: query bien formulada y específica → directa
return query
2. Orden de fallback
1. ¿Tiene typos? → rewrite_clarify (rápido, casi siempre aplicable)
2. ¿Está en chat con historial? → rewrite_with_context (alta prioridad si chat exists)
3. ¿Es keyword-style? → rewrite_structural
4. ¿Es ambigua después de rewriting? → expansion adicional
5. Sino → query directa
3. Estimación de costo extra
QUERIES_PER_DAY = 8000
DAYS_PER_MONTH = 30
# Distribución por tipo de procesamiento
PCT_NEEDS_REWRITING = 0.50 # ~50% requiere rewriting
PCT_NEEDS_EXPANSION = 0.20 # ~20% además expansion
PCT_NO_OPTIMIZATION = 0.30 # ~30% queries directas
# Cost por LLM call (gpt-4o-mini)
COST_PER_CALL_REWRITING = 0.0001 # rewriting es prompt corto
COST_PER_CALL_EXPANSION = 0.0002 # expansion es prompt más largo
# Cost por retrieval extra (asume 4 retrievals adicionales en queries con expansion)
COST_PER_RETRIEVAL_EXTRA = 0.0001 # OpenAI embedding de la query
monthly_cost = (
QUERIES_PER_DAY * DAYS_PER_MONTH * PCT_NEEDS_REWRITING * COST_PER_CALL_REWRITING +
QUERIES_PER_DAY * DAYS_PER_MONTH * PCT_NEEDS_EXPANSION * COST_PER_CALL_EXPANSION +
QUERIES_PER_DAY * DAYS_PER_MONTH * PCT_NEEDS_EXPANSION * 4 * COST_PER_RETRIEVAL_EXTRA
)
print(f"Costo extra mensual: ${monthly_cost:.2f}")
Resultado: ~$30-40/mes. Despreciable.
Latencia esperada:
- 30% queries directas: sin cambio (~200ms)
- 50% con rewriting solo: +250ms (~450ms total)
- 20% con rewriting + expansion: +900ms (~1100ms total)
Latencia promedio ponderada: ~480ms.
Recall esperado (basado en datos del log):
- Sin optimization: ~62% recall@5
- Con pipeline completo: ~83-85% recall@5 (+21-23 puntos)
Plan de validación:
- Construir eval set con muestras de cada categoría (10 queries por tipo).
- Medir baseline.
- Implementar smart_query_pipeline con feature flag.
- A/B test sobre 30% del tráfico durante 1 semana.
- Si mejora se sostiene y latencia es aceptable, deploy a 100%.
Riesgo principal: rewriting que cambia la intención. Mitigación:
- Loggear todas las queries originales y rewritten.
- Sample manual semanal de 50 queries para detectar rewrites problemáticos.
- Si detectas patrones (ej: cambio de "why" a "how"), refinar el prompt.
Resumen y siguiente paso
Lo que aprendiste:
- Query rewriting transforma una query en una versión mejorada, vs query expansion que genera múltiples versiones paralelas.
- Tres variantes: estructural (keyword → natural language), contextual (con historial de chat), clarificadora (typos/abreviaciones).
- Combinar rewriting + expansion en cascada cuando la query tiene múltiples problemas.
- Mejora típica: +10-15% precision con rewriting, +20-30% recall con expansion. Combinados, ambos.
- Trampa principal: rewriting que cambia la intención. Prompt explícito + sample manual previenen.
- Skip dinámico: queries bien formuladas no necesitan rewriting. Detectar y saltar ahorra costo y latencia.
- Para multilingüe: prompt explícito de preservar el idioma original.
Checkpoint: antes de avanzar, deberías poder:
- Implementar las tres variantes de rewriting con structured outputs.
- Diseñar pipeline en cascada que combina rewriting + expansion según necesidad.
- Detectar queries que NO necesitan rewriting con heurísticas simples.
Siguiente cápsula: 05 — Query Decomposition.
Rewriting y expansion atacan queries cortas o ambiguas. La cápsula 05 cubre el caso opuesto: queries complejas que combinan múltiples preguntas en una. Ej: "¿cómo deployar FastAPI con Docker, configurar SSL, y monitorear con Prometheus?" — son tres preguntas distintas que requieren distintos chunks. Decomposition divide queries complejas en sub-queries y combina resultados.
Recursos
- LangChain — Query Construction Guide — Patrones avanzados de rewriting
- Anthropic — Multi-Step Question Answering — Cuando rewriting solo no alcanza
- Microsoft — Query Rewriting in Production — Caso real con Azure Cognitive Search
- OpenAI — Structured Outputs Guide — Para implementación robusta
- Pinecone — Query Optimization Series — Tutorial completo
- Stanford NLP — Query Reformulation Theory — Foundational
Tiempo estimado: 30-35 minutos Siguiente: 05-query-decomposition.md