DEV Community

Cover image for Detección de idioma sin APIs externas para un sistema RAG multilingüe
Martin Palopoli
Martin Palopoli

Posted on

Detección de idioma sin APIs externas para un sistema RAG multilingüe

Implementé inteligencia lingüística completa para un motor RAG multi-tenant: detección heurística de idioma (ES/EN/PT) con latencia cero, cadena de prioridad configurable, inyección en prompts del LLM, reglas de contenido (BLOCK/REDIRECT/FILTER) para moderación, y browser_lang desde el widget. Todo sin APIs externas, con ~90 líneas de código.


El problema multilingüe en RAG

La mayoría de tutoriales RAG asumen un solo idioma. En producción te encontrás con:

  • Queries en inglés contra documentos en español (o viceversa)
  • Widgets embebidos en sitios brasileños recibiendo portugués
  • El LLM responde en el idioma del contexto en vez del idioma del usuario

Las "soluciones" comunes son APIs de detección (Google, AWS Comprehend) que agregan latencia y costo, librerías como langdetect/fasttext que son dependencias pesadas para 3 idiomas, o directamente ignorar el problema.

Necesitaba algo con latencia cero, sin dependencias, y configurable por Knowledge Base.


La solución: detección heurística + cadena de prioridad

Enfoque general

En vez de usar un modelo estadístico completo, ataco el problema en capas:

Query del usuario
       │
       ▼
┌──────────────────────┐
│  1. Override fijo?    │──→ Si admin forzó "en": usar "en" siempre
└──────┬───────────────┘
       │ No override
       ▼
┌──────────────────────┐
│  2. Heurística        │──→ Contar palabras clave ES/EN/PT
└──────┬───────────────┘
       │ Score < threshold
       ▼
┌──────────────────────┐
│  3. Browser lang?     │──→ Idioma del navegador (widget)
└──────┬───────────────┘
       │ No disponible
       ▼
    Default: "es"
Enter fullscreen mode Exit fullscreen mode

Las word lists

El corazón de la detección son tres sets de palabras comunes por idioma:

import re

_PUNCT_RE = re.compile(r"[^\w\s]", re.UNICODE)

_EN_WORDS = {
    "how", "what", "where", "when", "why", "who", "which", "can", "could",
    "would", "should", "does", "do", "is", "are", "the", "this", "that",
    "help", "please", "tell", "explain", "show", "need", "want", "have",
    "about", "from", "with", "your", "they", "there", "their", "been",
    "just", "also", "very", "some", "any", "other", "than", "into",
}

_PT_WORDS = {
    "como", "onde", "quando", "porque", "quem", "qual", "pode", "poderia",
    "esta", "são", "isso", "isto", "ajuda", "ajudar", "mostre", "explique",
    "você", "vocês", "obrigado", "obrigada", "preciso", "quero", "tenho",
    "sobre", "para", "com", "seu", "sua", "eles", "também", "muito",
    "algum", "outro", "mais", "ainda", "aqui", "depois", "antes", "entre",
    "posso", "gostaria", "fazer", "dizer", "favor", "bom", "boa", "dia",
    "noite", "olá", "sim", "não", "bem", "tudo",
}

_ES_WORDS = {
    "qué", "cómo", "dónde", "cuándo", "cuál", "quién", "puedo", "podría",
    "necesito", "quiero", "tengo", "también", "aquí", "después", "ahora",
    "entonces", "pero", "sino", "aunque", "desde", "hasta", "hacia",
    "según", "ayuda", "explicar", "mostrar", "buscar", "hola", "gracias",
    "información", "pregunta", "respuesta", "por", "favor",
}
Enter fullscreen mode Exit fullscreen mode

Decisiones clave en las listas: Inglés se enfoca en function words (the, is, are) que casi nunca aparecen en español/portugués. Portugués incluye diferenciadores clave vs ES: você, não, obrigado, tudo. Español usa acentos cuando es posible (qué, cómo, dónde) — un usuario que escribe con acentos es casi seguro que está en español.

La función de detección

def detect_language(query: str) -> str:
    """Detect query language. Returns 'es', 'en', or 'pt'. Default 'es'."""
    q = _PUNCT_RE.sub("", query.lower().strip())
    words = set(q.split())

    scores = {
        "en": len(words & _EN_WORDS),
        "pt": len(words & _PT_WORDS),
        "es": len(words & _ES_WORDS),
    }
    best = max(scores, key=scores.get)

    if scores[best] >= 2:
        return best
    return "es"
Enter fullscreen mode Exit fullscreen mode

¿Por qué threshold de 2? Con una sola palabra coincidente, el riesgo de falso positivo es alto. "como" existe en español y portugués. "para" también. Pero si encontramos 2+ palabras de un idioma, la probabilidad de acertar sube dramáticamente.

¿Por qué default "es"? Mi caso de uso principal es Latinoamérica. Si no puedo detectar con confianza, español es la apuesta más segura. Esto es configurable — podés cambiar el default según tu mercado.


La cadena de prioridad: resolve_language()

La detección heurística es solo una capa. La función real que decide el idioma es resolve_language():

_LANG_INSTRUCTIONS = {
    "en": "Respond entirely in English.",
    "pt": "Responda inteiramente em português.",
    "es": "Responde completamente en español.",
}


def resolve_language(
    query: str,
    auto_detect: bool = True,
    override: str | None = None,
    browser_lang: str | None = None,
) -> str:
    """
    Resolve the final language for a query.
    Priority: override > heuristic detection > browser fallback > 'es'.
    """
    # 1. Admin override: ignora todo, fuerza idioma
    if override and override in ("es", "en", "pt"):
        return override

    # 2. Heuristic: analiza el texto
    if auto_detect:
        detected = detect_language(query)
        # Si detectó algo que no es el default, confiar
        if detected != "es" or not browser_lang:
            return detected

    # 3. Browser lang: idioma del navegador del usuario
    if browser_lang:
        bl = browser_lang.lower()[:2]
        if bl in ("en", "pt", "es"):
            return bl

    # 4. Default
    return "es"
Enter fullscreen mode Exit fullscreen mode

¿Por qué esta prioridad?

Nivel Fuente Cuándo gana
1 Override del admin Siempre. Si el admin dice "esta KB es en inglés", se respeta
2 Heurística Si detecta EN o PT con confianza (score >= 2)
3 Browser lang Si la heurística no pudo decidir (devolvió "es" por default)
4 Default "es" Último recurso

El truco sutil: si la heurística devuelve "es", podría ser un verdadero español O un "no pude detectar". En ese caso, le damos la oportunidad al browser_lang. Si el navegador del usuario está en portugués y la query es ambigua, probablemente es portugués.

Uso en chat vs widget

# Chat: sin browser_lang
detected_lang = resolve_language(
    data.content,
    auto_detect=rag_config.language_auto_detect,
    override=rag_config.language_override,
)

# Widget: incluye browser_lang como fallback adicional
detected_lang = resolve_language(
    data.message,
    auto_detect=rag_config.language_auto_detect,
    override=rag_config.language_override,
    browser_lang=data.browser_lang,
)
lang_hint = get_language_instruction(detected_lang)
Enter fullscreen mode Exit fullscreen mode

El widget captura el idioma del navegador y lo envía en cada request:

var body = {
  message: text,
  session_token: sessionToken,
  browser_lang: widgetLang,  // navigator.language || "es"
};
Enter fullscreen mode Exit fullscreen mode

Inyectando el idioma en el system prompt del LLM

Detectar el idioma no alcanza — hay que forzar al LLM a responder en ese idioma. Esto se hace inyectando una instrucción explícita al final del system prompt:

def _build_messages(query, sources, ..., language_hint=None):
    system_message = identity_prefix + mode_prefix + _build_system_prompt(context)

    if language_hint:
        system_message += f"\n\nIMPORTANTE: {language_hint}"

    messages = [{"role": "system", "content": system_message}]
    messages.append({"role": "user", "content": query})
    return messages
Enter fullscreen mode Exit fullscreen mode

Donde language_hint es uno de:

_LANG_INSTRUCTIONS = {
    "en": "Respond entirely in English.",
    "pt": "Responda inteiramente em português.",
    "es": "Responde completamente en español.",
}
Enter fullscreen mode Exit fullscreen mode

¿Por qué al final del system prompt? Los LLMs tienden a darle más peso a las instrucciones al principio y al final del prompt (recency bias). Poniendo el idioma al final, maximizamos la probabilidad de que lo respete, incluso cuando el contexto de los documentos está en otro idioma.

¿Por qué "IMPORTANTE:"? Por la misma razón. Los LLMs responden mejor a instrucciones marcadas como importantes. Sin este prefijo, Llama 3.3 a veces ignoraba la instrucción de idioma cuando todo el contexto estaba en otro idioma.


Embeddings multilingües: el héroe silencioso

Todo esto funciona gracias a un modelo de embeddings que entiende múltiples idiomas en el mismo espacio vectorial:

# config.py
embedding_model: str = "paraphrase-multilingual-MiniLM-L12-v2"
Enter fullscreen mode Exit fullscreen mode

paraphrase-multilingual-MiniLM-L12-v2 genera vectores de 384 dimensiones y soporta 50+ idiomas. Esto significa que:

  • "How do I reset my password?" (inglés)
  • "¿Cómo reseteo mi contraseña?" (español)
  • "Como redefinir minha senha?" (portugués)

Producen embeddings cercanos en el espacio vectorial, a pesar de estar en idiomas diferentes.

Sin embeddings multilingües, una query en inglés jamás encontraría documentos en español. Con este modelo, el usuario pregunta en inglés, pgvector encuentra chunks en español (porque el significado es cercano), el cross-encoder confirma la relevancia, y el LLM responde en inglés. Sin traducción intermedia.

El reranker también es multilingüe (cross-encoder/mmarco-mMiniLMv2-L12-H384-v1, entrenado en mMARCO, 14 idiomas). Crucial porque evalúa query + chunk juntos — si están en idiomas diferentes, necesita entender ambos.


BM25: la limitación del tsvector hardcodeado

Hay un elefante en la habitación. Mi búsqueda BM25 usa PostgreSQL tsvector con la configuración 'spanish' hardcodeada:

SELECT c.id, c.content, d.filename as document_name,
       ts_rank_cd(c.search_vector, plainto_tsquery('spanish', :query)) as score
FROM chunks c
JOIN documents d ON d.id = c.document_id
WHERE c.knowledge_base_id IN (:kb_ids)
  AND c.search_vector @@ plainto_tsquery('spanish', :query)
ORDER BY score DESC
LIMIT :top_k
Enter fullscreen mode Exit fullscreen mode

El problema: si el usuario pregunta en inglés, plainto_tsquery('spanish', 'how do I reset') no va a tokenizar correctamente las palabras inglesas. El stemming de español convierte "reset" diferente que el stemming de inglés.

¿Por qué no lo cambié?

  1. La búsqueda vectorial compensa: El BM25 es el 30% de la búsqueda híbrida. Si falla para queries cross-language, la búsqueda vectorial (70%) sigue funcionando perfectamente gracias a los embeddings multilingües.
  2. El fallback OR ayuda: Cuando el AND query no encuentra resultados, un fallback a OR (to_tsquery con |) es más permisivo y rescata matches parciales.
  3. Complejidad vs beneficio: tsvector dinámico por idioma requeriría múltiples search_vector columns o regeneración al vuelo. La mejora marginal no justifica el costo cuando el vector search es el componente principal.

Esto es una limitación conocida y documentada. Si tu caso de uso tiene 80%+ de queries en inglés con documentos en inglés, deberías cambiar 'spanish' por 'english' — o mejor aún, por 'simple' que hace tokenización básica sin stemming idioma-específico.


Content Rules: moderación sin LLM

Además de detectar idioma, implementé un sistema de reglas de contenido que actúa antes del pipeline RAG. Tres tipos:

BLOCK: detener la query

def evaluate_block_redirect(
    query: str, rules: list[ContentRule]
) -> dict | None:
    q_lower = query.lower()
    for rule in rules:
        if not rule.enabled or rule.type == "filter":
            continue
        for trigger in rule.triggers:
            if trigger.lower() in q_lower:
                return {"type": rule.type, "response": rule.response}
    return None
Enter fullscreen mode Exit fullscreen mode

Si un trigger de tipo BLOCK matchea, el usuario recibe el response configurado y el pipeline RAG nunca se ejecuta. Cero tokens, cero latencia de LLM.

REDIRECT y FILTER

REDIRECT usa el mismo mecanismo que BLOCK pero para redirigir ("Para facturación, contacta soporte@empresa.com"). FILTER es diferente — actúa post-retrieval, eliminando chunks antes de enviarlos al LLM:

def get_filter_terms(rules: list[ContentRule]) -> list[str]:
    terms = []
    for rule in rules:
        if rule.enabled and rule.type == "filter":
            terms.extend(rule.triggers)
    return [t.lower() for t in terms]


def filter_chunks(sources: list[dict], filter_terms: list[str]) -> list[dict]:
    if not filter_terms:
        return sources
    filtered = []
    for source in sources:
        content_lower = source.get("content", "").lower()
        if not any(term in content_lower for term in filter_terms):
            filtered.append(source)
    return filtered
Enter fullscreen mode Exit fullscreen mode

Útil cuando tus documentos tienen secciones que no querés que el LLM use como contexto (precios internos, información confidencial en documentos parcialmente públicos).

El flujo completo

# 1. Content rules check (BLOCK / REDIRECT) — antes de todo
if rag_config.content_rules:
    rule_match = evaluate_block_redirect(data.content, rag_config.content_rules)
    if rule_match:
        yield {"event": "content_blocked", "data": json.dumps({
            "type": rule_match["type"],
            "response": rule_match["response"],
        })}
        return

# 2. Pipeline RAG normal (vector + BM25 + rerank + MMR)
sources = await search_chunks(db, query, kb_ids, rag_config=rag_config)

# 3. Apply FILTER content rules — después del retrieval, antes del LLM
if rag_config.content_rules:
    blocked_terms = get_filter_terms(rag_config.content_rules)
    if blocked_terms:
        sources = filter_blocked_chunks(sources, blocked_terms)

# 4. LLM con language hint
response = stream_chat_response(query, sources, language_hint=lang_hint)
Enter fullscreen mode Exit fullscreen mode

Todo se almacena en el rag_config JSONB de cada Knowledge Base — sin tabla separada, sin migraciones:

class ContentRule(BaseModel):
    type: Literal["block", "redirect", "filter"]
    triggers: list[str] = Field(default_factory=list)
    response: str = Field(default="", max_length=500)
    enabled: bool = True

class RAGConfig(BaseModel):
    # ... retrieval, llm, processing configs ...
    language_auto_detect: bool = Field(default=True)
    language_override: str | None = Field(default=None, pattern=r"^(es|en|pt)$")
    content_rules: list[ContentRule] = Field(default_factory=list)
Enter fullscreen mode Exit fullscreen mode

Cada KB tiene su propia detección, su propio override, y sus propias reglas de contenido.


Frontend y tracking

En el diálogo de configuración RAG, el admin tiene toggle de auto-detección, selector de idioma forzado, y CRUD inline de content rules:

const languageAutoDetect = ref(true)
const languageOverride = ref('')
const contentRules = reactive<ContentRule[]>([])

// Al guardar
payload.language_auto_detect = languageAutoDetect.value
payload.language_override = languageOverride.value || null
payload.content_rules = contentRules.map(r => ({ ...toRaw(r) }))
Enter fullscreen mode Exit fullscreen mode

Si el override está activo, el toggle de auto-detección se deshabilita visualmente — no tiene sentido detectar si ya forzaste un idioma.

Cada query se loguea con el idioma detectado (migration 027: detected_language VARCHAR(5) en usage_logs), lo que permite analizar distribución de idiomas por KB y detectar patrones de fallo.


Edge cases y limitaciones

1. Queries muy cortas

"Reset" — ¿es inglés o español (se usa como anglicismo)? Con una sola palabra, no hay suficiente contexto. El threshold de 2 palabras mínimas protege contra esto, pero significa que queries de 1-2 palabras siempre devuelven el default.

2. Spanglish y code-switching

"Necesito help con el login" — empate entre ES y EN. Como el score no llega a 2 para inglés, devuelve "es". Correcto, pero no por las razones ideales.

3. Portugués vs español

Muchas palabras son idénticas ("como", "para", "sobre"). La diferenciación depende de palabras exclusivas como você, não, obrigado. Sin ellas, un brasileño puede ser detectado como hispanohablante.

4. Queries técnicas puras

"HTTP 403 POST /api/users" — score 0 en todos los idiomas, devuelve el default. Para queries sin palabras naturales, el idioma importa menos.


Números

Aspecto Valor
Latencia de detección ~0.01ms (set intersection)
Word lists total ~120 palabras (40 EN + 45 PT + 35 ES)
Threshold de confianza 2 palabras mínimo
Idiomas soportados 3 (ES, EN, PT)
Embeddings multilingües 50+ idiomas (384d)
Cross-encoder multilingüe 14 idiomas
Content rules por KB Ilimitadas
Overhead total < 1ms por request

Lecciones aprendidas

1. No necesitás un modelo de ML para detectar 3 idiomas

Para un set acotado de idiomas, word lists + scoring es absurdamente efectivo. Un modelo de ML necesita cargarse en memoria, tiene cold start, y su accuracy para queries cortas (5-10 palabras) no es significativamente mejor que un enfoque heurístico bien calibrado.

2. La cadena de prioridad es más importante que la detección

La función resolve_language() es más valiosa que detect_language(). Poder combinar override del admin + detección automática + señal del navegador en una cadena configurable cubre el 99% de los casos. La detección heurística sola cubriría quizás el 85%.

3. El browser_lang es subestimado

En widgets embebidos, el idioma del navegador es una señal fortísima. Si el navegador está en pt-BR y la query es ambigua, es casi seguro que el usuario es brasileño. Añadir este campo al request del widget fue una línea de código que mejoró significativamente la experiencia para queries cortas o ambiguas.

4. Content rules son más útiles de lo esperado

Empecé implementando content rules como "nice to have" para moderación básica. En la práctica, los admins los usan creativamente: redirigir preguntas de facturación al email correcto, bloquear queries sobre competidores, filtrar secciones internas de documentos. Son una capa de control que no pasa por el LLM.

5. Embeddings multilingües hacen el 80% del trabajo

La verdad incómoda es que si usás paraphrase-multilingual-MiniLM-L12-v2 para embeddings, el cross-language retrieval funciona bastante bien sin hacer nada más. La detección de idioma es principalmente para controlar el idioma de la respuesta del LLM, no del retrieval.

6. Loguear el idioma detectado es esencial

Sin el campo detected_language en usage_logs, estaría adivinando si la detección funciona bien. Con los datos, puedo ver patrones: "el 15% de queries al widget en Brasil se detectan como español" me dice que necesito expandir las word lists de portugués.


Lo que sigue

  • Detección por n-gramas de caracteres: Más robusto para queries cortas que word lists
  • tsvector dinámico: Elegir el diccionario según idioma detectado ('english', 'portuguese', 'simple')
  • Más idiomas: Francés y alemán solo requieren nuevas word lists
  • Stop words dinámicas: Hoy son solo español — deberían adaptarse al idioma

Conclusión

El soporte multilingüe en RAG no necesita ser complicado ni caro. Embeddings multilingües como base, detección heurística de ~90 líneas, y una cadena de prioridad bien diseñada cubren el 99% de los casos para ES/EN/PT.

Lo importante no es la detección en sí — es la arquitectura: configurable por KB, con fallbacks razonables, que loguea sus decisiones para poder mejorar, y que las content rules den control al admin sin pasar por el LLM. A veces la solución más simple es la correcta.


Si trabajás con RAG multilingüe y encontraste otros edge cases, dejá un comentario. Y si este artículo te fue útil, un like ayuda a que llegue a más personas.

Top comments (0)