Inicio / Artículos

Qué falló en una tubería RAG correctiva para preguntas y respuestas en YouTube con LangGraph

El fragmentado, los límites de incrustación, el estado de LangGraph, los problemas asíncronos y la velocidad de transcripción: los errores encontrados al crear un bot de preguntas y respuestas para YouTube y cómo se solucionó cada uno.

Editor: REACTAPP.TOP7 min de lectura1520 palabras

Fuentes: Building a Corrective RAG Pipeline for YouTube Q&A: What Broke and Why?

Un sistema RAG correctivo es una configuración de generación ampliada mediante recuperación que cuenta con tres componentes adicionales en comparación con la versión básica:

  1. Un modelo evaluador que determina si cada fragmento recuperado es relevante para la pregunta del usuario.
  2. Un reescritor de consultas que modifica la pregunta para obtener mejores resultados.
  3. Una opción de búsqueda en la web como recurso alternativo cuando nada de lo recuperado es relevante.

El proyecto detrás de estas notas era un bot para responder preguntas sobre videos de YouTube. Se le proporciona una URL del video y una pregunta, y él responde. También maneja videos que no tienen transcripción, lo cual es importante porque el cargador de YouTube de LangChain solo funciona cuando ya existe una transcripción. El flujo es: descargar el video, transcribirlo con un modelo de voz a texto (Whisper), incrustar el texto, almacenar los vectores en ChromaDB y luego responder preguntas a través de un grafo LangGraph.

Dado que la tarea involucraba el seguimiento de estado, las reintentos y soluciones alternativas, LangGraph resultó más adecuado que una cadena de LangChain o un conjunto de ramas if-else. Trabajar con nodos, aristas y un objeto de estado compartido también revela cómo funciona realmente una aplicación RAG. Varios errores en el proceso enseñaron más que los éxitos, y se detallan a continuación.

Fragmentación

En cualquier sistema RAG, la fragmentación es la etapa que falla silenciosamente cuando se realiza incorrectamente. Los enfoques comunes son:

  1. La división recursiva de caracteres recorre una lista de separadores y corta el texto hasta que los fragmentos cumplan con un límite de tamaño.
  2. La fragmentación semántica agrupa oraciones según su significado, por ejemplo, evaluando la similitud entre oraciones con un modelo de tipo BERT y fusionando las más similares.
  3. La división específica para documentos sigue el formato del contenido de origen, y es típica en códigos.
  • El particionamiento jerárquico padre-hijo mantiene un segmento padre amplio y segmentos hijos más pequeños extraídos de él.
  • Al principio, el particionamiento semántico parecía una opción natural. Los videos abarcan todos los temas imaginables, por lo que agruparlos por significado parecía sensato. Luego surgió algo extraño: para transcripciones de menos de diez minutos, el divisor solía devolver un único segmento. Al investigar se comprendió el motivo. Los videos cortos suelen tratar en profundidad un solo tema, y cuando todo el texto trata sobre una sola cosa, el particionamiento semántico no tiene nada que dividir.

    A continuación se utilizó la división recursiva de caracteres, lo que generó un segundo error: dejar chunk_overlap en cero. Los segmentos en sí eran aceptables, pero las respuestas empeoraron. Sin superposición, un segmento recuperado no contenía indicio alguno de lo que venía antes, por lo que el modelo generador carecía de contexto sobre a qué se refería ese segmento y a menudo mezclaba detalles entre sí.

    El enfoque adoptado fue la división recursiva con solapamiento real, sin segmentación semántica para este caso de uso. Funcionó tanto con videos cortos como largos y generó segmentos de transcripción de mejor calidad. Con un pequeño conjunto de prueba de diez consultas con k=5, el proceso alcanzó un rango recíproco promedio de 0.81. Dicha muestra es muy pequeña, por lo que debe considerarse como una indicación aproximada y no como un punto de referencia.

    Elegir el modelo de embedding

    El bot debe poder manejar cualquier video, por lo que los temas abarcan todos los dominios. Un modelo de embedding específico para un dominio puede fallar rápidamente cuando el video está fuera de su área de entrenamiento. Para un uso general, y dada la hardware modesta disponible, se eligió all-MiniLM-L6-v2, un modelo rápido y ligero que funciona de manera aceptable casi en cualquier lugar.

    El problema es un límite de entrada. Este modelo solo acepta unos 256 tokens, y todo lo que sea más largo se trunca, lo que significa que un fragmento largo se incluye utilizando solo sus primeros 256 tokens. La consecuencia es una pérdida silenciosa de datos y embeddings desiguales, lo cual afecta negativamente la calidad de la aplicación.

    La solución es mantener chunk_size por debajo del límite del modelo. Al dividir por caracteres, asegúrate de no superar el límite de tokens siguiendo la regla práctica de que un token equivale aproximadamente a cuatro caracteres. Un divisor establecido en 800 caracteres corresponde a unos 200 tokens, lo cual es suficiente.

    text_splitter = RecursiveCharacterTextSplitter(
        chunk_size=800,      # Notice: 800 characters limit here (~200 tokens)
        chunk_overlap=200,
        length_function=len,
        is_separator_regex=False,
    )
    docs = text_splitter.create_documents([text])
    sentence_transformer = HuggingFaceEmbeddings(
        model_name="sentence-transformers/all-MiniLM-L6-v2"
    )
    vector_store = Chroma(
        collection_name=get_config("vectorDB_collection_name"),
        embedding_function=sentence_transformer,
    )
    

    Una vez resueltos los dos problemas de recuperación más comunes, los siguientes se refieren al propio LangGraph.

    Estado de LangGraph y ChromaDB

    Cada nodo en una aplicación LangGraph comparte datos a través de un objeto de estado. Cada nodo recibe ese estado y lo actualiza. Existen dos formas comunes de definirlo:

    1. TypedDict, un esquema de indicaciones de tipo para diccionarios. Permite la verificación estática pero no la validación en tiempo de ejecución.
    2. Pydantic, que valida los datos en tiempo de ejecución y genera errores ante incoherencias de tipo.

    Cuando el estado es un BaseModel de Pydantic, se valida cada atributo. Si se incluye un objeto complejo como un cliente de ChromaDB, aparece un error de inmediato. Al establecer arbitrary_types_allowed=True en la clase de estado, se permiten tipos personalizados sin problemas.

    Permanece un problema más profundo. Las funcionalidades de LangGraph como los checkpoints, la memoria persistente y la capacidad de observabilidad de LangSmith a veces serializan y copian el estado. Los tipos simples se serializan sin problemas, pero una instancia de ChromaDB puede provocar un error de serialización. La serialización simplemente significa convertir los datos en una forma que pueda almacenarse o enviarse.

    Un diseño más seguro mantiene a ChromaDB completamente fuera del estado. También representa una arquitectura más limpia, ya que un recurso de base de datos no debería mezclarse con datos ordinarios. Los nodos que necesitan la base de datos aún pueden acceder a ella a través del argumento config que se pasa a aupdate_state():

    app.aupdate_state(
        config={
            "configurable": {
                "thread_id": thread_id,
                "recursion_limit": 30,
                "vector_store": vector_store,      # Pass the Vector DB here
            }
        }
    )
    

    Ejecución asíncrona y la trampa de input()

    Una gran ventaja de LangGraph es su ejecución asíncrona. Ofrece versiones asíncronas de las llamadas síncronas: ainvoke() en lugar de invoke() y aupdate_state() en lugar de update_state(). Con ellas se pueden crear nodos asíncronos y una aplicación más ágil.

    La forma más sencilla de arruinar esto es llamando a input() dentro de un nodo. Esto bloquea la ejecución mientras se espera al usuario, por lo que incluso una aplicación completamente asíncrona pierde la ventaja de serlo.

    La solución consiste en recopilar la entrada del usuario fuera de los nodos y enviarla al grafo mediante aupdate_state():

    await app.aupdate_state(
        config={
            "configurable": {"thread_id": thread_id, "recursion_limit": 30}
        },  # adjust as needed
        values={
            "user_query": user_query  # Send the user inputs using the 'values' property
        },
    )
    

    Transcripción local o en la nube

    La transcripción es el punto de partida de todo el proyecto, y se convirtió en un cuello de botella. El primer intento utilizó un modelo local de conversión de voz a texto, whisper-small, con dos problemas:

    1. Exactitud. Es un modelo razonable para configuraciones locales con bajas exigencias de hardware, pero su precisión es solo moderada en comparación con otras opciones.
    2. Latencia. Con recursos limitados, la creación de la transcripción llevaba mucho tiempo. El factor de tiempo en tiempo real en la máquina de pruebas era de aproximadamente 1.03, lo que significa que la transcripción tardaba tanto como duraba el video. En videos de más de cinco minutos, los usuarios tenían que esperar ese tiempo antes de obtener una primera respuesta.

    El factor de tiempo en tiempo real es el tiempo de transcripción dividido por la duración del audio. Dado que las preguntas y respuestas están bloqueadas hasta que existe la transcripción, cualquier valor superior a 1 perjudica la experiencia del usuario.

    Los datos no eran confidenciales, por lo que justificar el paso a un servicio en la nube a través de la API de Groq fue sencillo. Eso resolvió ambos problemas. La precisión mejoró porque Groq utiliza whisper-large-v3-turbo, que supera a whisper-small en la mayoría de los casos. La latencia también mejoró, ya que el hardware más potente en la nube redujo el tiempo de respuesta a aproximadamente 0.15.

    Esa elección dependió de que los datos no fueran sensibles. Para material privado o confidencial, la decisión sería muy diferente.

    Una lista de verificación rápida

    Las lecciones anteriores se resumen en algunas verificaciones que vale la pena realizar antes de confiar en un pipeline como este:

    • Muestre cuántos fragmentos genera un transcripción corta típica. Un conteo de uno es una señal de alerta, independientemente de lo que prometa el divisor.
  • Confirme que el tamaño de los fragmentos del divisor, convertido a tokens, esté por debajo del límite de entrada del modelo de embedding. La truncación nunca genera un error, por lo que debe verificarse intencionadamente.
  • Lea algunos de los fragmentos recuperados por separado. Si uno no tiene sentido sin sus vecinos, la superposición es demasiado pequeña.
  • Mantenga a los clientes y conexiones, como el almacén de vectores, fuera del estado del grafo, y páselos a través de la configuración.
  • Busque en los nodos del grafo llamadas que puedan bloquear, como input() o solicitudes de red síncronas, antes de culpar al framework por la lentitud.
  • Mida el factor de tiempo real de la transcripción en la máquina donde realmente se ejecutará, y decida si los datos permiten utilizar un servicio alojado.
  • Qué se deja fuera

    Estas notas abarcan solo algunos de los problemas que se presentan en una primera aplicación con LangGraph. Otros temas merecen un tratamiento aparte: prompts del sistema, evaluación del sistema y de cada componente, creación de endpoints REST y despliegue en la nube.