Módulo 3: Query Optimization

Cápsula 05: Query decomposition — cuando una pregunta son varias preguntas

Descripción de la cápsula

Hasta ahora vimos técnicas para queries que están mal expresadas: ambiguas (expansion), incompletas (rewriting con contexto), keyword-style (rewriting estructural). Esta cápsula cubre un caso distinto: queries que están bien expresadas pero contienen múltiples preguntas en una.

"Compara FastAPI vs Flask para autenticación, y recomienda cuál usar en producción." "¿Cómo configuro PostgreSQL en Docker, conecto FastAPI con SQLAlchemy, y deployo a AWS?" "¿Por qué HNSW es más rápido que IVF, y cuándo conviene usar PQ?"

Estas queries son perfectamente claras. El usuario sabe lo que quiere. El problema es que la respuesta requiere chunks de múltiples documentos distintos. Una sola búsqueda con el embedding de la query completa devuelve docs que tocan algunos aspectos pero raramente cubren todos. Decomposition divide la query en sub-queries independientes, busca cada una por separado, y combina los resultados.

Esta cápsula te enseña a detectar cuándo una query es candidata a decomposition (no todas lo son), cómo descomponer correctamente preservando la pregunta original, y cómo agregar los resultados sin saturar el contexto del LLM.

Al finalizar esta cápsula serás capaz de:

  • ✅ Identificar las tres clases de queries que se benefician de decomposition: comparativas, multi-paso, causales
  • ✅ Implementar decomposition con LLM y structured outputs
  • ✅ Buscar sub-queries en paralelo y agregar resultados con deduplication
  • ✅ Decidir cuándo decomposition agrega valor vs cuándo la query directa alcanza
  • ✅ Anticipar la trampa de "explosión de contexto": agregar 30 chunks satura al LLM
  • ✅ Combinar decomposition con retrieve-then-rerank para precision máxima

Tiempo estimado: 30-35 minutos


Cuándo usar decomposition

Tres patrones de query donde decomposition es la herramienta correcta:

Patrón 1: queries comparativas

"Compara FastAPI vs Flask para autenticación"

→ Sub-query 1: "¿Cómo funciona la autenticación en FastAPI?"
→ Sub-query 2: "¿Cómo funciona la autenticación en Flask?"
→ Sub-query 3: "¿Cuáles son las diferencias clave entre FastAPI y Flask en auth?"

Sin decomposition, una búsqueda única con la query completa probablemente devuelve docs sobre uno de los dos frameworks pero no del otro — la query "compara A vs B" tiene un embedding que no apunta claramente a A ni a B.

Patrón 2: queries multi-paso

"¿Cómo configuro PostgreSQL en Docker, conecto FastAPI con SQLAlchemy, y deployo a AWS?"

→ Sub-query 1: "¿Cómo configurar PostgreSQL en un contenedor Docker?"
→ Sub-query 2: "¿Cómo conectar FastAPI con SQLAlchemy a una base de datos PostgreSQL?"
→ Sub-query 3: "¿Cómo deployar una aplicación FastAPI a AWS?"

Cada sub-query corresponde a un aspecto independiente del problema. Una búsqueda única recupera docs que tocan tangencialmente todos los temas pero no profundiza en ninguno.

Patrón 3: queries causales con seguimiento

"¿Por qué HNSW es más rápido que IVF, y cuándo conviene usar PQ?"

→ Sub-query 1: "¿Por qué HNSW es más rápido que IVF? Mecanismo interno"
→ Sub-query 2: "¿Cuándo se usa PQ en lugar de HNSW o IVF?"
→ Sub-query 3 (opcional): "Trade-offs entre HNSW, IVF y PQ"

Las queries causales suelen mezclar "por qué" con "cómo aplicar" — son dos preguntas distintas que necesitan dos respuestas distintas.

Cuándo NO descomponer

Query¿Decomponer?Por qué
"¿Cómo implemento OAuth2 en FastAPI?"❌ NoUna sola pregunta, una sola respuesta
"FastAPI auth"❌ No (usar expansion)Es ambigua, no compleja
"¿Cuál es el M óptimo para HNSW?"❌ NoPregunta específica única
"Compara HNSW vs IVF"✅ SíComparativa explícita
"Pasos para deployar app a producción"✅ SíMulti-paso implícito

Heurística simple: si la query usa palabras como "compara", "diferencias entre", "y también", "además", o tiene múltiples oraciones interrogativas, es candidata. Si es una sola pregunta directa, no.


Implementación con structured outputs

# query_decomposition.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 DecomposedQuery(BaseModel):
    is_decomposable: bool = Field(
        description="True if the query benefits from decomposition (multi-aspect, comparative, multi-step). False if it's a single direct question."
    )
    sub_queries: List[str] = Field(
        description="If decomposable, list of independent sub-queries that together cover the original. Empty if not decomposable.",
        max_items=6,
    )
    reasoning: str = Field(
        description="One-sentence explanation of why the query is or isn't decomposable"
    )


SYSTEM_PROMPT = """You are an expert at analyzing search queries and decomposing complex ones.

Given a user query, decide:
1. If it's a SIMPLE single question → mark as not decomposable, return empty sub-queries
2. If it's COMPARATIVE (compares 2+ things) → decompose into individual aspect queries + comparison
3. If it's MULTI-STEP (asks for sequential steps) → decompose into one query per step
4. If it's CAUSAL+APPLICATION (mixes "why" with "how") → decompose into separate why/how queries

Rules:
- Each sub-query must be ANSWERABLE INDEPENDENTLY
- Together, sub-queries must cover the FULL original query
- Use natural language for sub-queries
- Maximum 6 sub-queries (typically 3-4 is ideal)

Examples:

Query: "How do I implement OAuth2 in FastAPI?"
→ NOT decomposable. Single direct question.

Query: "Compare FastAPI and Flask for authentication"
→ DECOMPOSABLE:
   1. "How does authentication work in FastAPI?"
   2. "How does authentication work in Flask?"
   3. "What are the key differences between FastAPI and Flask authentication?"

Query: "How do I deploy a FastAPI app with PostgreSQL on AWS?"
→ DECOMPOSABLE:
   1. "How do I configure PostgreSQL for production?"
   2. "How do I connect FastAPI to PostgreSQL using SQLAlchemy?"
   3. "How do I deploy a FastAPI application to AWS?"
"""


def decompose_query(query: str) -> DecomposedQuery:
    """Analyze and possibly decompose a complex query."""
    response = client.beta.chat.completions.parse(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user", "content": f'Query: "{query}"\n\nAnalyze and decompose if appropriate.'},
        ],
        response_format=DecomposedQuery,
        temperature=0.2,
    )
    return response.choices[0].message.parsed


# Probar
queries = [
    "How do I implement OAuth2 in FastAPI?",
    "Compare FastAPI and Flask for authentication",
    "How do I deploy a FastAPI app with PostgreSQL on AWS?",
    "What is HNSW?",
    "Why is HNSW faster than IVF, and when should I use PQ?",
]

for q in queries:
    result = decompose_query(q)
    print(f"\nQuery: {q}")
    print(f"  Decomposable: {result.is_decomposable}")
    print(f"  Reasoning: {result.reasoning}")
    if result.sub_queries:
        print(f"  Sub-queries:")
        for i, sq in enumerate(result.sub_queries, 1):
            print(f"    {i}. {sq}")

Output esperado:

Query: How do I implement OAuth2 in FastAPI?
  Decomposable: False
  Reasoning: Single direct question with one specific topic, no comparison or multi-step structure.

Query: Compare FastAPI and Flask for authentication
  Decomposable: True
  Reasoning: Comparative query requiring information about each framework separately and their differences.
  Sub-queries:
    1. How does authentication work in FastAPI?
    2. How does authentication work in Flask?
    3. What are the key differences between FastAPI and Flask authentication?

Query: How do I deploy a FastAPI app with PostgreSQL on AWS?
  Decomposable: True
  Reasoning: Multi-step query covering three independent topics: PostgreSQL setup, FastAPI integration, and AWS deployment.
  Sub-queries:
    1. How do I configure PostgreSQL for production use?
    2. How do I connect a FastAPI application to PostgreSQL using SQLAlchemy?
    3. How do I deploy a FastAPI application to AWS?

Query: What is HNSW?
  Decomposable: False
  Reasoning: Definitional question with one direct answer.

Query: Why is HNSW faster than IVF, and when should I use PQ?
  Decomposable: True
  Reasoning: Causal-and-application query mixing "why" comparison with "when to use" application advice.
  Sub-queries:
    1. Why is HNSW faster than IVF for vector search?
    2. When should I use PQ instead of HNSW or IVF?

Ventaja del structured output con is_decomposable: el modelo decide si descomponer o no. No tienes que llamar al LLM, parsearlo, ver si tiene sentido la decomposition. Una sola llamada determina ambas cosas.


Pipeline completo: decompose → search en paralelo → agregar

# decomposition_pipeline.py
from concurrent.futures import ThreadPoolExecutor
from collections import OrderedDict


def search_with_decomposition(query: str, top_k_per_subquery: int = 5):
    """
    Pipeline completo:
    1. Decompose si aplica
    2. Buscar cada sub-query en paralelo
    3. Agregar resultados deduplicados
    """
    decomp = decompose_query(query)

    # Caso 1: query simple, no descomponer
    if not decomp.is_decomposable:
        results = collection.query(query_texts=[query], n_results=top_k_per_subquery * 2)
        return {
            "is_decomposed": False,
            "sub_queries": [query],
            "documents": results['documents'][0],
            "metadatas": results['metadatas'][0],
            "ids": results['ids'][0],
        }

    # Caso 2: query compleja, descomponer
    queries_to_search = decomp.sub_queries

    def search_one(q):
        return collection.query(query_texts=[q], n_results=top_k_per_subquery)

    # Paralelo: 3-5 sub-queries en paralelo es trivial
    with ThreadPoolExecutor(max_workers=5) as executor:
        all_results = list(executor.map(search_one, queries_to_search))

    # Agregar resultados con deduplicación (preservando primera aparición)
    unique_results = OrderedDict()
    for sub_query_results in all_results:
        for doc_id, doc, meta in zip(
            sub_query_results['ids'][0],
            sub_query_results['documents'][0],
            sub_query_results['metadatas'][0],
        ):
            if doc_id not in unique_results:
                unique_results[doc_id] = (doc, meta)

    return {
        "is_decomposed": True,
        "sub_queries": queries_to_search,
        "documents": [d for d, m in unique_results.values()],
        "metadatas": [m for d, m in unique_results.values()],
        "ids": list(unique_results.keys()),
    }

Limitar el contexto al LLM

Si descompones en 4 sub-queries y cada una devuelve 5 docs, terminas con ~20 docs únicos para pasar al LLM. Eso es demasiado contexto — el LLM se distrae con ruido y la calidad de respuesta empeora ("lost in the middle").

Solución: rerank después de agregar.

def decompose_then_rerank(query: str, final_top_k: int = 8):
    """
    Decompose + agregar + rerank para limitar el contexto final al LLM.
    """
    aggregated = search_with_decomposition(query, top_k_per_subquery=5)

    # Si no descompuso, devolver directo
    if not aggregated["is_decomposed"]:
        return aggregated["documents"][:final_top_k]

    # Rerank con la query ORIGINAL (no las sub-queries)
    # para priorizar docs que aborden el conjunto, no aspectos aislados
    reranked = cross_encoder_rerank(
        query=query,
        documents=aggregated["documents"],
        top_k=final_top_k,
    )

    return reranked

Por qué rerankear con la query original (no las sub-queries): la respuesta del LLM debe abordar la query completa del usuario. Documentos que solo cubren un aspecto pierden relevancia frente a documentos que cubren múltiples aspectos. El cross-encoder con la query original prioriza docs holísticos.


Generar la respuesta final

Cuando descompones, el LLM tiene que sintetizar información de múltiples sub-temas. El prompt necesita ser explícito:

def generate_decomposed_answer(query: str, retrieved_docs: list[str]) -> str:
    """Genera respuesta final addressing all aspects of the original query."""
    context = "\n\n---\n\n".join(retrieved_docs)

    prompt = f"""Answer the user's question comprehensively. The question may have
multiple aspects — make sure to address ALL of them in a structured response.

Use ONLY the provided context. If a specific aspect isn't covered in the context,
say so explicitly rather than making up information.

Question: {query}

Context:
{context}

Answer (address all aspects):"""

    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": "You are a technical assistant that provides structured, comprehensive answers."},
            {"role": "user", "content": prompt},
        ],
        temperature=0.0,
    )
    return response.choices[0].message.content

El secreto: la instrucción "address ALL of them" + "if a specific aspect isn't covered, say so explicitly" previene respuestas parciales. Si la decomposition recuperó docs sobre 2 de 3 sub-temas, el LLM va a decir "no encontré información sobre el tercer aspecto" en lugar de inventar.


Trampas y errores comunes

Trampa 1: descomponer queries simples

El error: decomposition activado para todas las queries.

Síntoma: una query simple como "¿qué es HNSW?" se descompone en "definición HNSW" + "para qué sirve HNSW" + "cómo funciona HNSW". El sistema hace 3 búsquedas innecesarias.

Cómo prevenir: el structured output con is_decomposable=False lo previene si el prompt está bien diseñado. Validar con eval set.

Trampa 2: explosión de contexto

El error: descompones en 5 sub-queries × 10 docs cada una = 50 docs únicos al LLM.

Síntoma: latencia se dispara, calidad de respuesta empeora ("lost in the middle"), costo de generation se multiplica.

Cómo prevenir: después de agregar, rerankear y limitar a 5-10 docs finales.

Trampa 3: sub-queries que no son independientes

El error: sub-queries que dependen del resultado de las anteriores.

Query: "¿Cuál es el mejor framework Python para mi caso?"
Sub-queries malas:
  1. "¿Cuáles son los frameworks Python populares?"
  2. "¿Cuál es mejor de los anteriores?"  ← depende de la 1, no se puede buscar sola

Síntoma: las búsquedas de sub-queries que dependen unas de otras devuelven resultados pobres.

Cómo prevenir: prompt explícito "each sub-query must be ANSWERABLE INDEPENDENTLY". Si no se puede formular sub-queries independientes, no descomponer.

Trampa 4: deduplicación naive

El error:

unique_docs = list(set(all_documents))  # set por contenido del doc

Síntoma: docs casi idénticos (con cambios menores de whitespace) se consideran distintos. El contexto se llena con duplicados.

Cómo prevenir: deduplicar por doc_id, no por contenido. Los IDs son estables.

Trampa 5: usar sub-queries para rerank en lugar de la query original

El error: rerank con cada sub-query, agregar rankings.

Síntoma: el rerank prioriza docs que responden bien a aspectos individuales en lugar de docs que cubren la pregunta completa.

Cómo prevenir: rerankear con la query original del usuario. Las sub-queries son solo para retrieval inicial.

Trampa 6: decomposition sin fallback cuando una sub-query falla

El error: una de las sub-queries devuelve cero resultados (tema no en corpus). Tratas eso como cero contribution.

Síntoma: la respuesta del LLM ignora ese aspecto sin avisar.

Cómo prevenir: detectar sub-queries con cero resultados y notificar al LLM:

sub_query_coverage = {}
for sub_q, results in zip(sub_queries, all_results):
    sub_query_coverage[sub_q] = len(results['documents'][0])

# En el prompt al LLM:
missing_aspects = [sq for sq, count in sub_query_coverage.items() if count == 0]
if missing_aspects:
    prompt += f"\n\nNote: No information was found for: {missing_aspects}. Mention this explicitly in your answer."

Ejercicio aplicado

Escenario: eres AI Engineer en una plataforma educativa de programación. El sistema RAG sirve a estudiantes que hacen preguntas sobre cursos de backend.

Análisis del log de queries:

Categoría                           %    Ejemplo
──────────────────────────────────────────────────────────────────
Pregunta directa simple            55%   "¿qué es REST?"
Comparativa entre frameworks       18%   "Django vs FastAPI vs Flask"
Multi-paso (build + deploy)        15%   "cómo crear y deployar API"
Causal + aplicación                 8%   "¿por qué async es más rápido y cómo lo uso?"
Vaga/incompleta                     4%   "ayuda con mi proyecto"

Métricas actuales (sin decomposition):

  • Precision@5: 81%
  • Para queries simples: 88%
  • Para queries comparativas: 62% ← problema
  • Para queries multi-paso: 68% ← problema

Tu trabajo:

  1. Decide si vale agregar decomposition.
  2. Diseña el pipeline incluyendo skip dinámico.
  3. Estima impacto en latencia y costo.
Solución

1. Sí vale agregar decomposition

El diagnóstico clave: precision varía dramáticamente por tipo de query (88% en simples vs 62-68% en complejas). Decomposition ataca exactamente las dos categorías problemáticas (comparativas + multi-paso = 33% del tráfico).

Estimación de impacto:

Categoría                  %     Pre-decomp     Post-decomp     Mejora ponderada
──────────────────────────────────────────────────────────────────────────
Simples                  55%       88%            88% (sin cambio)        0
Comparativas             18%       62%            85% (estimado)          +4.1 pts
Multi-paso               15%       68%            85% (estimado)          +2.6 pts
Causal+aplicación         8%       65% (asumo)    82% (estimado)          +1.4 pts
Vaga                      4%       50%            50% (no aplica)         0

Mejora total esperada en precision@5: +8 puntos (81% → 89%)

2. Pipeline con skip dinámico

def smart_pipeline(query: str, final_top_k: int = 5) -> list[str]:
    """
    Pipeline con decomposition condicional.
    """
    # El LLM decide si descomponer (con structured output is_decomposable)
    decomp = decompose_query(query)

    if not decomp.is_decomposable:
        # Pipeline standard: retrieve + rerank
        results = collection.query(query_texts=[query], n_results=20)
        return cross_encoder_rerank(query, results['documents'][0], top_k=final_top_k)

    # Pipeline con decomposition
    aggregated = search_with_decomposition(query, top_k_per_subquery=5)

    # Rerank con query original sobre los docs agregados
    reranked = cross_encoder_rerank(
        query=query,
        documents=aggregated["documents"],
        top_k=final_top_k,
    )

    return reranked


# Uso end-to-end
def answer_query(query: str) -> str:
    relevant_docs = smart_pipeline(query)
    answer = generate_answer(query, relevant_docs)
    return answer

3. Estimación de latencia y costo

Latencia:

Categoría%Latencia baseCon decompositionPromedio ponderado
Simples55%400ms400ms (skip)220ms
Comparativas18%400ms1500ms270ms
Multi-paso15%400ms1500ms225ms
Causal8%400ms1500ms120ms
Vaga4%400ms400ms16ms
Total promedio:~850ms

(vs 400ms sin decomposition).

+450ms promedio por query. Aceptable para chatbot educativo donde el usuario espera explicaciones detalladas.

Costo:

QUERIES_PER_DAY = 5000
DAYS_PER_MONTH = 30

# Costos por categoría (LLM calls de decomposition + retrieval extras)
PCT_DECOMPOSED = 0.18 + 0.15 + 0.08  # = 0.41 (41% se decompone)

# 1 LLM call para decompose decision (gpt-4o-mini, ~200 tokens in/out)
COST_DECOMPOSE_DECISION = 0.000050

# 4 retrievals extras (vs 1 en pipeline simple)
COST_EXTRA_RETRIEVALS = 4 * 0.000010  # OpenAI embedding extra calls

cost_per_decomposed_query = COST_DECOMPOSE_DECISION + COST_EXTRA_RETRIEVALS
# Pero el decomposition decision se hace para TODAS las queries (decide si descomponer)
# Solo en simples no hace los 4 retrievals extras

monthly_cost = (
    QUERIES_PER_DAY * DAYS_PER_MONTH * COST_DECOMPOSE_DECISION  # decision para todas
    + QUERIES_PER_DAY * DAYS_PER_MONTH * PCT_DECOMPOSED * COST_EXTRA_RETRIEVALS
)
print(f"Costo mensual extra: ${monthly_cost:.2f}")

Resultado: ~$15-20/mes. Despreciable.

Plan de validación:

  1. Construir eval set de 80 queries: 40 simples, 20 comparativas, 15 multi-paso, 5 vagas.
  2. Anotar ground truth (qué docs son relevantes para cada query).
  3. Medir precision@5 sobre cada categoría con baseline (sin decomposition).
  4. Implementar decomposition con feature flag.
  5. Medir nuevamente sobre el mismo eval set.
  6. Si precision en comparativas y multi-paso sube >15 puntos sin caída en simples, deployar.

Métrica de protección:

Monitorear precision en queries simples después del deploy. Si baja >2 puntos (porque el LLM erróneamente está descomponiendo queries simples), revisar el prompt de decompose_query.

Rollback automático: feature flag con threshold — si métrica de protección cae, rollback automático al pipeline anterior.


Resumen y siguiente paso

Lo que aprendiste:

  • Query decomposition divide queries complejas (comparativas, multi-paso, causales) en sub-queries independientes.
  • El LLM decide si descomponer (structured output con is_decomposable). Una sola llamada.
  • Buscar sub-queries en paralelo (ThreadPoolExecutor) para mantener latencia razonable.
  • Después de agregar, rerankear con la query ORIGINAL (no las sub-queries) para priorizar docs holísticos.
  • Limitar contexto final al LLM a 5-10 docs después del rerank — más causa "lost in the middle".
  • Trampa principal: descomponer queries simples. El structured output con detección automática previene.
  • Decomposition + rerank + skip dinámico es la combinación que entrega calidad sin saturar costo.

Checkpoint: antes de avanzar, deberías poder:

  • Identificar las tres clases de queries que se benefician de decomposition.
  • Implementar decomposition con structured outputs y detección automática.
  • Diseñar pipeline en cascada con decomposition + rerank y skip dinámico.

Siguiente cápsula: 06 — HyDE (Hypothetical Document Embeddings).

Hasta acá las técnicas de query optimization manipulan la query como texto. HyDE es distinto: en vez de buscar con el embedding de la query, generas una respuesta hipotética con un LLM y embebes esa respuesta. Lo que buscas en el corpus es "documentos similares al tipo de respuesta que un experto daría" — más cercano semánticamente a los docs reales que la pregunta cruda. Es la técnica más sofisticada del módulo, útil cuando el resto no alcanza.


Recursos

  1. LangChain — Multi-Query and Decomposition — Patrones implementados
  2. LlamaIndex — Sub Question Query Engine — Implementación oficial de decomposition
  3. Anthropic — Multi-Step Reasoning — Patrones complementarios
  4. OpenAI — Structured Outputs — Para implementación robusta
  5. Lost in the Middle Paper — Por qué limitar contexto importa
  6. Self-Ask Prompting (Press et al., 2022) — Decomposition por chain-of-thought

Tiempo estimado: 30-35 minutos Siguiente: 06-hyde.md