Ir al contenido

Query rewriting: limpiar la pregunta antes de buscar

En el laboratorio, las preguntas vienen limpias: “¿Qué establece la regla de dependencia en Clean Architecture?”. Bien escritas, autocontenidas, con vocabulario alineado al corpus.

En producción, las preguntas son otra cosa:

  • Telegráficas: "clean arch testabilidad"
  • Con pronombres: "¿y eso cómo se aplica?"
  • Con referencias a contexto previo (chat multi-turn): "y la diferencia con la otra?"
  • Con typos: "que es la rega de dpendencia"

El embedding de cualquiera de esas es ruidoso. La query telegráfica no tiene contexto suficiente. Los pronombres no significan nada para el retriever. Los typos rompen tanto el embedding como BM25.

Solución: un LLM call antes del retrieve que limpia la query.

pregunta cruda
↓ LLM (rewrite)
pregunta limpia y autocontenida
↓ embedding + retrieve
chunks
↓ generate
respuesta

Costo: 1 LLM call extra. Latencia +200-400ms con llama3.2:3b. Casi obligatorio en chat multi-turn, opcional en single-turn.

packages/01-vercel-ai-sdk/src/query/rewrite.ts — un solo export, fail-soft:

rewrite.ts: API
export async function rewriteQuery(
query: string,
opts: RewriteOptions = {},
): Promise<string>;

Si el LLM falla o devuelve algo inutilizable, retorna la query original. Nunca rompe el pipeline.

rewrite.ts: prompt
const prompt = `Reformulá la siguiente pregunta para que sea autocontenida y específica. Resolvé pronombres ("eso", "él", "lo") y referencias a contexto previo si las hay. Si la pregunta ya es autocontenida, devolvela igual.
Respondé SOLO con la pregunta reformulada, sin explicación, sin etiquetas, sin comillas.
${historyBlock}PREGUNTA ORIGINAL:
${query}
PREGUNTA REFORMULADA:`;

Cuatro detalles:

  • autocontenida y específica: la transformación que querés.
  • resolvé pronombres: explícito porque es el caso más común y el modelo no siempre lo hace por su cuenta.
  • Si la pregunta ya es autocontenida, devolvela igual: importante. Sin esto, el LLM tiende a “mejorar” preguntas que ya estaban bien — agregar palabras de adorno o cambiar el sentido sutilmente.
  • SOLO con la pregunta reformulada, sin explicación: el LLM por defecto agrega “Aquí está la pregunta reformulada:” antes. Eso rompe el pipeline downstream.

Para single-turn, llamás:

const cleaned = await rewriteQuery("¿y eso cómo se aplica?");
// → "¿Cómo se aplica la regla de dependencia?"
// (con suerte, si el LLM adivina bien el referente)

Para chat multi-turn, pasás historial:

const cleaned = await rewriteQuery(
"¿y la diferencia con la otra?",
{ history: [
"Usuario: ¿Qué propone Clean Architecture?",
"Asistente: Clean Architecture propone separar las dependencias en capas concéntricas...",
]},
);
// → "¿Cuál es la diferencia entre Clean Architecture y otra arquitectura,
// como Hexagonal Architecture?"

El historial le da al LLM la pista que necesita para resolver “la otra”.

El LLM a veces ignora la instrucción y devuelve algo como:

La pregunta reformulada es: "¿Cuál es la regla de dependencia en Clean Architecture?"

El cleanup remueve comillas y prefijos comunes:

rewrite.ts: cleanup
const cleaned = data.response.trim().replace(/^["'`]|["'`]$/g, '');
return cleaned.length > 0 ? cleaned : query;

No es perfecto pero captura los 80% de casos. Si el cleaning resulta en string vacío, fallback a la query original.

Ventana de terminal
pnpm vercel:query:adv "<pregunta>" --rewrite

Cuando rewrite cambia algo, lo imprime al inicio de la salida:

[rewrite] "que es la rega de dpendencia"
→ "¿Qué es la regla de dependencia en Clean Architecture?"

Si la query ya estaba bien, no imprime nada extra.

Probá una query telegráfica con typo:

Ventana de terminal
pnpm vercel:query:adv "que es regla dependencia clean arch" --rewrite

Mirá el bloque [rewrite] ... para ver qué hizo el LLM. Después corré la misma sin --rewrite:

Ventana de terminal
pnpm vercel:query:adv "que es regla dependencia clean arch"

Compará los chunks recuperados (=== Sources ===). Casi seguro la versión con rewrite trae chunks más específicos a Clean Architecture, y la versión sin rewrite trae chunks más mezclados (porque “regla”, “dependencia”, “clean” sin contexto matchean varios temas).

--rewrite corre antes que --multi-query. Tiene sentido: primero resolvés referencias, después descomponés. En orden inverso, descompondrías una pregunta sucia y heredarías el ruido en cada sub-query.

Ventana de terminal
pnpm vercel:query:adv "y la diferencia con la otra" \
--rewrite --multi-query 3 --hybrid --rerank

Pipeline:

  1. Rewrite: “¿Cuál es la diferencia entre Clean Architecture y la otra?” → “¿Cuál es la diferencia entre Clean Architecture y Hexagonal Architecture?” (resuelve “la otra”)
  2. Multi-query: descompone en 3 sub-preguntas.
  3. Hybrid retrieve + RRF para cada una.
  4. Merge + dedupe.
  5. LLM rerank a top-4.
  6. Generate.

5 LLM calls + 3 retrieves. Pesado, pero brutalmente fuerte.

  • Latencia crítica. 200-400ms más por llamada.
  • Single-turn con preguntas siempre bien planteadas. (raro en producción real, común en demos)
  • Costos. Si pagás por tokens (no es el caso del curso, todo local), un LLM call extra duplica costo de query.

Tenés un retriever fino (hybrid + rerank) y un input limpio (rewrite + multi-query). La pregunta final es: ¿el usuario puede confiar en lo que le respondiste?. Para eso necesitás citation explícita — fuentes inline después de cada afirmación. Eso es el último capítulo del Nivel 3.