Inicio / Artículos

Reducir el consumo de tokens en RAG en un 67% mediante Multivectors de Qdrant e isolación de oraciones.

Un pipeline RAG para el descubrimiento legal que vuelve a ordenar los resultados con ColBERT dentro de Qdrant, isola la oración correspondiente y sacrifica algo de latencia a cambio de una reducción verificada del 67.1% en los tokens.

Editor: REACTAPP.TOP8 min de lectura1952 palabras

Fuentes: How Qdrant Reduced RAG Token Costs by 67% with Native ColBERT Reranking

Las facturas por tokens se han convertido en una preocupación principal para los equipos que utilizan la generación mejorada con recuperación de información. Las grandes empresas buscan formas de reducir el consumo, y muchos equipos descubren que un pipeline de LLM no optimizado puede costar más que el tiempo de ingeniería que se suponía que ahorraría. La revisión de documentos legales constituye un buen entorno de prueba, ya que los documentos son extensos, las respuestas son breves y el volumen es alto.

La página que nadie necesitaba

Imagínese a un abogado a tres semanas del inicio de la fase de presentación de pruebas en una disputa contractual, con montones de acuerdos de servicios principales y documentos corporativos que debe analizar. La pregunta es concreta: ¿hasta qué punto puede llegar el límite de indemnización cuando un tercero reclama una infracción de propiedad intelectual? Una sola oración en algún lugar de un contrato de cuarenta páginas responde a ella.

Un sistema RAG estándar devuelve los tres fragmentos principales, cada uno con entre 500 y 1,000 tokens, y en su mayoría se trata de textos explicativos, definiciones, términos de pago y cláusulas legales que no tienen relación con las reclamaciones de propiedad intelectual. La única oración útil se encuentra en el medio. Esto resulta molesto para el lector, y a gran escala es costoso: en miles de documentos presentados durante una auditoría judicial, al proveedor de LLM se le paga por procesar montañas de texto redundante e innecesario.

Surgen dos preguntas: ¿cuánto del texto recuperado es realmente necesario? ¿Y es posible aislar la oración exacta sin necesidad de recurrir a un servicio adicional que se encargue de reordenar los resultados?

Nada de esto relacionado con los residuos es específico de la legislación. Cualquier conjunto de documentos largos y con formato predefinido, como pólizas de seguro, contratos con proveedores o manuales de cumplimiento, tiene la misma estructura: una respuesta breve rodeada de páginas de texto que se incluyen en la solicitud en cada búsqueda. El costo aumenta en función del tamaño de los fragmentos recuperados y no del tamaño de la respuesta, por lo que lo importante es determinar cuánto de cada fragmento llega al modelo.

¿Por qué se realiza el reordenamiento dentro de la base de datos vectorial?

El esquema habitual de dos etapas realiza una búsqueda densa rápida para obtener aproximadamente los 100 candidatos principales y luego realiza una segunda llamada a la red a un reordenador externo como Cohere, SageMaker o un contenedor personalizado para clasificarlos. Funciona, pero tiene tres desventajas.

  • Se realizan dos llamadas a la red a dos proveedores diferentes.
  • Si el reordenador deja de funcionar, la búsqueda también falla.
  • La factura cubre el almacenamiento vectorial y las llamadas de reclasificación.
  • Qdrant ofrece una alternativa. Desde la versión 1.10 admite campos multi-vectoriales nativos con un comparador MAX_SIM, que es el mecanismo detrás de la interacción tardía al estilo de ColBERT. La comparación token a token se realiza dentro de la base de datos en una sola consulta, por lo que se obtiene la precisión de la interacción tardía de un reclasificador independiente sin necesidad de un segundo paso. Combinado con el aislamiento de oraciones, esta configuración redujo los costos por token en un 67%.

    Los ajustes de memoria completan el perfil.

    • Cuantización binaria en el índice denso. Los vectores densos se reducen a un bit por dimensión, y estos vectores pequeños permanecen en RAM (always_ram=True) para una precarga muy rápida de candidatos.
    • Matrices de ColBERT en disco. Las matrices de tokens pesados se almacenan en disco (on_disk=True) y solo se cargan para el conjunto de candidatos principales, quizás de 50 a 100 fragmentos, durante la recalificación, lo que mantiene bajo el consumo de RAM.

    El proceso

    El diseño consta de seis pasos con una sola llamada a la base de datos y sin API externa de reclasificación.

    1. Dividir los acuerdos por sección para que ningún fragmento divida una cláusula legal por la mitad.
    2. Incorporar cada fragmento dos veces: una vez como un vector denso estándar para la evaluación rápida y otra vez como una matriz de múltiples vectores de ColBERT para la recalificación a nivel de token.
    3. Precargar los N candidatos principales utilizando vectores densos cuantizados en binario.
    4. Recalificar esos candidatos con ColBERT MAX_SIM dentro de Qdrant.
    5. Tomar los fragmentos principales y evaluar sus oraciones individuales localmente en comparación con el vector de consulta de ColBERT.
    6. Enviar solo las oraciones que coinciden al LLM.

    El precio del punto de referencia asume 3,00 dólares por millón de tokens de entrada, en línea con un modelo comercial de nivel medio.

    Estructura de la colección

    El script de configuración a continuación establece embeddings densos estándar y vectores múltiples de ColBERT uno al lado del otro, con modelos FastEmbed cargados localmente en la CPU.

    from qdrant_client import QdrantClient, models
    # ADDED: Load FastEmbed models locally on CPU
    from fastembed import TextEmbedding, LateInteractionTextEmbedding
    COLLECTION_NAME = "legal_discovery"
    DENSE_DIM = 384  # BAAI/bge-small-en-v1.5
    COLBERT_DIM = 128  # colbert-ir/colbertv2.0
    # ADDED: Instantiate the vector models
    dense_model = TextEmbedding("BAAI/bge-small-en-v1.5")
    colbert_model = LateInteractionTextEmbedding("colbert-ir/colbertv2.0")
    client = QdrantClient("<http://localhost:6333>")
    client.create_collection(
        collection_name=COLLECTION_NAME,
        vectors_config={
            "dense": models.VectorParams(
                size=DENSE_DIM,
                distance=models.Distance.COSINE,
                quantization_config=models.BinaryQuantization(
                    binary=models.BinaryQuantizationConfig(always_ram=True),
                ),
            ),
            "colbert": models.VectorParams(
                size=COLBERT_DIM,
                distance=models.Distance.COSINE,
                multivector_config=models.MultiVectorConfig(
                    comparator=models.MultiVectorComparator.MAX_SIM
                ),
                on_disk=True,
                hnsw_config=models.HnswConfigDiff(m=0),
            ),
        },
    )
    

    Un parámetro de los vectores de ColBERT destaca: una configuración HNSW con m establecido en 0. Dado que ColBERT se utiliza únicamente para volver a calificar un conjunto de resultados previamente filtrado, no se necesita un grafo HNSW para ello. Omitir el grafo ahorra mucho tiempo en la creación del índice y espacio de almacenamiento.

    Un viaje de ida y vuelta para la carga previa y la recalificación

    La API de consultas universal de Qdrant permite que la carga previa en la primera etapa y la recalificación en la segunda etapa se realicen como una sola operación. No se requiere ninguna consulta, análisis de IDs ni segundo paso hacia un reordenador; todo el proceso se gestiona en un único viaje de ida y vuelta a la base de datos.

    # ADDED: Generate query embeddings (ColBERT uses query_embed to add prefix padding)
    dense_query = next(dense_model.query_embed(query)).tolist()
    colbert_query = next(colbert_model.query_embed(query)).tolist()
    # Run the two-stage query in one network round-trip
    results = client.query_points(
        collection_name=COLLECTION_NAME,
        prefetch=models.Prefetch(
            query=dense_query,
            using="dense",
            limit=prefetch_limit,
            params=models.SearchParams(
                quantization=models.QuantizationSearchParams(rescore=False),
            ),
        ),
        query=colbert_query,
        using="colbert",
        limit=top_k,
        with_payload=True,
    )
    

    La bandera rescore=False dentro del bloque prefetch merece atención. Con cuantización binaria, Qdrant normalmente obtendría candidatos con los vectores cuantizados y luego cargaría los vectores densos de precisión completa float32 para volver a calcular sus puntuaciones. Dado que la etapa de ColBERT realiza un trabajo más preciso de todos modos, desactivar el cálculo de puntuaciones densas evita leer vectores de precisión completa desde el disco y reduce la carga adicional en la base de datos.

    De bloque a oración

    MAX_SIM proporciona una puntuación agregada por bloque y no revela qué tokens la determinaron. Para extraer solo el texto relevante, un paso local final divide los bloques ganadores en oraciones y calcula las puntuaciones de interacción tardía de ColBERT para cada una. Como esto se ejecuta únicamente en los últimos bloques, donde el texto es breve, no surge ningún cuello de botella por escalado de la base de datos, y la precisión a nivel de oración se obtiene fácilmente en la CPU.

    import re
    import numpy as np
    # ADDED: Basic sentence splitter regex
    SENTENCE_SPLIT = re.compile(r"(?<=[.;])\s+(?=[A-Z])")
    def max_sim(query_vecs: np.ndarray, doc_vecs: np.ndarray) -> float:
        # Compute token-to-token similarity matrix
        sims = query_vecs @ doc_vecs.T  # (num_query_tokens, num_doc_tokens)
        # Sum the maximum similarity scores along the document axis
        return float(sims.max(axis=1).sum())
    def isolate_sentences(chunk_text: str, query_vecs: np.ndarray, colbert_model, top_n: int = 1):
        # ADDED: Split chunk text into candidate sentences
        sentences = [s.strip() for s in SENTENCE_SPLIT.split(chunk_text) if len(s.strip()) > 15]
        if not sentences:
            return [(chunk_text, 0.0)]
        # Embed each sentence locally using ColBERT
        sentence_vecs = list(colbert_model.embed(sentences))
        scored = [(sentences[i], max_sim(query_vecs, sentence_vecs[i])) for i in range(len(sentences))]
        scored.sort(key=lambda pair: pair[1], reverse=True)
        return scored[:top_n]
    

    El cálculo local es rápido porque solo procesa los tres o cinco fragmentos principales. Una vez que se identifican las mejores oraciones, se descarta el resto de cada fragmento y se compila el contenido del prompt.

    def build_optimized_prompt(query: str, chunk_texts: list[str], colbert_model) -> str:
        query_vecs = next(colbert_model.query_embed(query))
        context_parts = []
    for i, text in enumerate(chunk_texts):
            top_sentences = isolate_sentences(text, query_vecs, colbert_model, top_n=1)
            isolated_text = " ".join(s for s, _ in top_sentences)
            context_parts.append(f"[Source Chunk {i+1}]: {isolated_text}")
        context_str = "\n\n".join(context_parts)
        return f"Context:\n{context_str}\n\nQuestion: {query}\nAnswer:"
    

    Tamaño del bloque y el número honesto

    La reducción de tokens por sí sola es una métrica tentadora. Al agrupar de diez a doce secciones legales en un bloque de 2,000 palabras y luego aplicar la isolación de oraciones, en teoría se puede lograr una reducción superior al 85%. Sin embargo, en un sistema legal en producción este enfoque perjudica la precisión de la recuperación de información. Un bloque grande ahoga la señal de una cláusula específica, como el límite de indemnización por propiedad intelectual de terceros, entre el texto genérico circundante en el denso espacio vectorial. Las consultas dirigidas a estos bloques confusos y compuestos por múltiples cláusulas tienden a devolver secciones no relacionadas, como las garantías, ya que el sistema de recuperación denso no puede aislar el elemento objetivo.

    La solución consiste en establecer un límite estricto de una cláusula legal por bloque, de modo que cada bloque contenga exactamente una sección distinta y con longitudes de cláusulas realistas. Según este esquema, la prueba de referencia mostró una reducción verificada del 67.1% en los tokens de la instrucción. Las verificaciones manuales en las consultas relacionadas con indemnizaciones y propiedad intelectual confirmaron que se encontraron las cláusulas adecuadas, aunque la cifra general pasó de un 87.3% teórico a un 67.1% en condiciones reales. Los límites claros a nivel de sección son más importantes que un porcentaje más alto.

    El compromiso entre latencia y rendimiento

    La cuantización binaria sugiere que el proceso en dos etapas podría ser más rápido que la búsqueda densa tradicional. Pero no es así. En pruebas repetidas, la búsqueda en dos etapas resultó ser consistentemente de dos a cuatro veces más lenta, dependiendo de la máquina y del nivel de uso de la caché: 39,5 ms frente a 20,4 ms. La razón es sencilla. La búsqueda exclusivamente densa consiste en una sola búsqueda de embeddings, mientras que la versión en dos etapas ejecuta tanto los codificadores de consulta densos como los de ColBERT, además de realizar una matriz de comparación de tokens dentro de la base de datos. Una prueba separada de búsqueda exclusivamente densa arrojó un promedio de 7,4 ms.

    Un análisis de latencia que aumentó el límite de precarga de 10 a 500 candidatos apenas modificó los resultados, y la latencia permaneció en el mismo rango. Es la estructura en dos etapas en sí, y no el tamaño de la ventana de precarga, la que genera el tiempo adicional.

    En general, la latencia en los sistemas RAG de extremo a extremo está determinada por la generación del LLM, y no por las consultas a la base de datos. Al ahorrar más de 400 tokens por consulta, se acorta el procesamiento y la generación posterior en cientos de milisegundos, lo que hace que toda la aplicación sea notablemente más rápida.

    El valor de los ahorros

    Con un costo de 3,00 dólares por millón de tokens de entrada, una reducción del 67,1% en los tokens de la instrucción tiene un gran impacto económico. En un proceso de investigación legal con 500,000 consultas, eso representa más de 600 dólares ahorrados por auditoría de caso. Además, instrucciones más cortas disminuyen el efecto de “pérdida en el medio”, en el que el modelo pasa por alto texto relevante oculto en un contexto largo.

    Qué verificar antes de adoptarlo

    Tres mediciones determinan si el diseño es adecuado para una carga de trabajo específica. La primera es la corrección de las respuestas en un conjunto de consultas verificadas manualmente, ya que el ahorro de tokens tiene poca importancia si falta la cláusula correcta. La segunda es la latencia de toda la solicitud, incluida la generación, ya que el tiempo adicional en la base de datos es insignificante en comparación con la llamada al modelo. La tercera es el conteo de tokens por prompt antes y después del aislamiento de oraciones, medido según la tarifa en uso. Los resultados obtenidos con un tipo de documento pueden no aplicarse a otro, por lo que el benchmark debe repetirse en el corpus objetivo.

    Lecciones para la producción

    • Mantenga el reordenamiento dentro de la base de datos. Las APIs externas de reordenamiento añaden puntos de fallo y viajes de red, mientras que un operador MAX_SIM nativo realiza el trabajo en un solo paso.
    • Desactive HNSW para los campos de puntuación única. Al establecer m=0 en las configuraciones multi-vectoriales se evita un paso de indexación costoso que no aporta beneficio alguno en la recuperación de matrices ColBERT.
    • No confíe en fragmentos muy grandes. Tamaños excesivos pueden mostrar una impresionante reducción de tokens en teoría, pero diluyen la representación semántica y afectan la precisión.
    • Utilice un tokenizador de oraciones real. Una expresión regular básica sirve para una demostración, pero el código de producción debe emplear una biblioteca como NLTK o spaCy que pueda manejar abreviaturas legales como Inc., Sec. y Exhibit A sin dividir incorrectamente las oraciones.

    La comparación multi-vectorial nativa, sumada a la puntuación a nivel de oración local, puede generar un proceso preciso y económico que devuelve la cláusula que necesita el usuario sin elevar la factura mensual de la API.

    Leer más

  • Probando GLM 5.2 dentro de Claude Code con un SDK real por cinco dólares — Un experimento con OpenRouter por cinco dólares: enrutar Claude Code hacia GLM 5.2, auditar y modificar un SDK de React Native, cuánto costó cada tarea y dónde terminan los ahorros.
  • Headroom: Reducir los costos de tokens de agente mediante compresión reversible de contexto — Cómo Headroom comprime registros, fragmentos RAG y resultados de herramientas antes de que lleguen al modelo, con CacheAligner, ContentRouter, CCR, modo proxy y los compromisos necesarios.