Lección 76 · 10 min · Gratis

Búsqueda híbrida para IA legal con Qdrant y Gemini

Copyright 2026 Google LLC.
# @title Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

Jenny's GitHub avatar

Este notebook fue contribuido por Jenny.

precios para más detalles).

Descripción general

Austronaut judge

En el ámbito legal, la precisión y la corrección factual son inmensamente críticas.

Una startup de IA legal que colaboró con Qdrant ha esbozado un enfoque para asegurar ambas en aplicaciones de IA legal (por ejemplo, basadas en Retrieval Augmented Generation (RAG) o agenticas):

“Convierte todo en un problema de recuperación donde recuperas la verdad fundamental. Si lo enmarcas de esa manera, no tienes que preocuparte por las alucinaciones, ya que todo lo que se le da al usuario está basado en alguna parte de un documento válido.”

Verdaderamente, muchas empresas de IA legal requieren una recuperación de alta calidad en sus aplicaciones. Para lograrlo, necesitas:

  • El conocimiento de las herramientas y técnicas adecuadas que aumentan la relevancia de la búsqueda;
  • Un modelo de embeddings adecuado;
  • ¡Estar listo para experimentar! :)

Este notebook

En este notebook, aprenderás cómo combinar gemini-embedding-001 con las herramientas proporcionadas por el motor de búsqueda vectorial Qdrant para construir un pipeline de recuperación de QA legal.

Aprenderás a:

  • Configurar una búsqueda híbrida (densa + por palabra clave) en Qdrant;
  • Usar Representaciones Matryoshka de embeddings de Gemini para equilibrar calidad y costo.

Configuración

Instalar SDK

  • google-genai para embeddings de gemini-embedding-001;
  • qdrant-client[fastembed] - el cliente Python de Qdrant;
  • HuggingFace datasets - para cargar datasets de Q&A legal de código abierto.
%pip install -q -U "google-genai>=2.9.0" qdrant-client[fastembed] datasets

Configura tus claves API:

  • GEMINI_API_KEY, necesaria para usar embeddings de gemini-embedding-001
    (busca cómo generarla aquí)

  • QDRANT_API_KEY y QDRANT_URL de un clúster de Qdrant Cloud gratuito para siempre
    (se te guiará sobre cómo obtener ambos en la UI de Qdrant Cloud)

Para ejecutar la siguiente celda, tus claves API deben estar almacenadas en una pestaña de Secretos de Colab.

from google.colab import userdata

GEMINI_API_KEY = userdata.get('GEMINI_API_KEY')
QDRANT_API_KEY = userdata.get('QDRANT_API_KEY')
QDRANT_URL = userdata.get('QDRANT_URL')

Paso 1: Descargar el Dataset

Usarás uno de los datasets de Hugging Face de Isaacus, una empresa de investigación de inteligencia artificial legal.

Un caso de uso común en IA legal es un chatbot de Generación Aumentada por Recuperación (RAG). Para evaluar el rendimiento de la recuperación en tales aplicaciones, necesitas un dataset de Pregunta-Respuesta (QA).

Eligiendo un Dataset

  • Open Australian Legal QA parece interesante. Sin embargo, todas sus preguntas generadas por LLM mencionan el nombre exacto del caso legal, que también aparece en la respuesta. El dataset mapea cada pregunta a una respuesta (1:1), lo que hace trivial construir un recuperador perfecto => ni siquiera se acerca a escenarios de la vida real :)

  • En su lugar, consideremos LegalQAEval. Se parece más al tipo de preguntas que un usuario podría hacer a un chatbot legal basado en RAG. Por ejemplo:

    • "¿Cómo se regulan los farmacéuticos en la mayoría de las jurisdicciones?"
    • "¿qué es ncts"

LegalQAEval

Este dataset contiene ~2400 pares de QA e incluye:

  • id: un identificador de cadena único;
  • question: una pregunta en lenguaje natural;
  • text: un fragmento de texto que puede contener la respuesta;
  • answers: una lista de respuestas (y sus posiciones dentro del texto), o null si el text no tiene la respuesta.

Carga el corpus de QA legal; usarás todas las divisiones disponibles.

from datasets import load_dataset, concatenate_datasets

corpus = concatenate_datasets(load_dataset('isaacus/LegalQAEval', split=['val', 'test']))

Deduplicación de fragmentos de texto

Dado que el dataset puede contener fragmentos text con múltiples preguntas relacionadas con ellos, inicialmente deduplica los campos text para no almacenar información idéntica varias veces.

import pandas as pd
import datasets

# Convert the Hugging Face dataset to a pandas DataFrame
df = corpus.to_pandas()

# Group by 'text' and aggregate 'id' into a list
grouped_corpus = df.groupby('text')['id'].apply(list).reset_index().rename(columns={'id': 'ids'})

corpus_deduplicated = datasets.Dataset.from_pandas(grouped_corpus)

Paso 2: Define la configuración del caso de uso

En un escenario típico de chatbot legal, los usuarios hacen una pregunta y un LLM genera una respuesta basada en un fragmento de texto relevante.

Para imitarlo, necesitarás almacenar en Qdrant representaciones numéricas (embeddings) de fragmentos text.
Durante la recuperación, una question se convertirá en una representación numérica en el mismo espacio de embeddings. Luego, se encontrará (aproximadamente) el fragmento text más cercano en el índice vectorial.

El modelo de embeddings de Gemini soporta la recuperación de Q&A estilo RAG (tipo de tarea QUESTION_ANSWERING).

Ahora, para definir completamente nuestra configuración de almacenamiento, consideremos varios factores relevantes para un caso de uso común de RAG en el dominio de la IA legal.

Costo versus precisión: representaciones matryoshka

Los embeddings de Gemini gemini-embedding-001 tienen 3072 dimensiones.
En una configuración RAG con ~1 millón de fragmentos, almacenar tales embeddings en RAM (para una recuperación rápida) requeriría aproximadamente 12 GB.

El modelo de embeddings de Gemini admite un enfoque para equilibrar la precisión y el costo de la recuperación. Está entrenado utilizando Matryoshka Representation Learning (MRL), lo que significa que la información más importante sobre el texto codificado se almacena en las primeras dimensiones del embedding.

Así, puedes, por ejemplo:

  • Usar solo las primeras 768 dimensiones del embedding de Gemini para una recuperación más rápida;
  • Y luego reordenar los resultados recuperados usando los embeddings completos de 3072 dimensiones para una mayor precisión.

Precisión de lo mejor de ambos mundos: búsqueda híbrida

En casos de uso legal, a menudo es beneficioso combinar las fortalezas de:

  • Búsqueda basada en palabras clave (léxica) para un control más directo sobre las coincidencias;
  • Búsqueda basada en embeddings (semántica) para manejar preguntas formuladas de manera conversacional.

En Qdrant, ambos enfoques se pueden combinar en consultas híbridas y de múltiples etapas.

Para la parte basada en palabras clave, Qdrant admite múltiples opciones, desde BM25 tradicional hasta recuperadores neuronales dispersos como SPLADE. Entre las opciones, se encuentra nuestra mejora personalizada de BM25 llamada miniCOIL, que usarás en este notebook.

En Qdrant, la recuperación basada en palabras clave se logra utilizando vectores dispersos.

Configuración de la colección

Configura una colección de Qdrant para el pipeline de recuperación de QA legal.

from qdrant_client import QdrantClient, models

qdrant_client = QdrantClient(  # Initializing Qdrant client.
    url=QDRANT_URL,
    api_key=QDRANT_API_KEY,
)

COLLECTION_NAME = "legal_AI_QA"
GEMINI_EMBEDDING_RETRIEVAL_SIZE = 768
GEMINI_EMBEDDING_FULL_SIZE = 3072

if not qdrant_client.collection_exists(collection_name=COLLECTION_NAME):
    qdrant_client.create_collection(
        collection_name=COLLECTION_NAME,
        vectors_config={
            "gemini_embedding_retrieve": models.VectorParams(
                size=GEMINI_EMBEDDING_RETRIEVAL_SIZE,  # Smaller embeddings for faster retrieval.
                distance=models.Distance.COSINE,
            ),
            "gemini_embedding_rerank": models.VectorParams(
                size=GEMINI_EMBEDDING_FULL_SIZE,  # Full-sized embeddings for precision-boosting reranking.
                distance=models.Distance.COSINE,
                hnsw_config=models.HnswConfigDiff(
                    m=0  # Since these embeddings aren't used for retrieval, you don't need to spend resources on building a vector index.
                ),
                on_disk=True,  # To save on RAM used for retrieval.
            ),
        },
        sparse_vectors_config={
            "miniCOIL": models.SparseVectorParams(
                modifier=models.Modifier.IDF  # Inverse Document Frequency statistic, computed on the Qdrant side.
            )
        },
    )

Paso 3: incrustar textos e indexar datos en Qdrant

Para acelerar el proceso de conversión de datos, harás lo siguiente:

  1. Incrustar con Gemini todos los fragmentos text en lotes usando la función get_embeddings_batch.
  2. Subir los resultados a Qdrant en lotes.
    El cliente Python de Qdrant proporciona las funciones upload_collection y upload_points. Estas manejan el procesamiento por lotes, los reintentos y la paralelización. Toman generadores como entrada, por lo que crearás una función generadora qdrant_points_stream para este propósito.

Nota: Qdrant normaliza automáticamente los embeddings subidos si la función de distancia en tu colección se configuró como COSINE (similitud de coseno). Esto significa que no necesitas pre-normalizar los embeddings Matryoshka de Gemini truncados, como se recomienda en la documentación de Gemini.

GEMINI_MODEL_ID = "gemini-embedding-001" # @param ["gemini-embedding-001"] {"allow-input":true, isTemplate: true}
from google import genai
from google.genai import types
from google.api_core import retry
import uuid

google_client = genai.Client(api_key=GEMINI_API_KEY)

@retry.Retry(timeout=300)
def get_embeddings_batch(texts, task_type: str = "RETRIEVAL_DOCUMENT"):
    """Generates embeddings for a batch of texts.

    Args:
        texts: A list of strings to embed.
        task_type: The task type for the embedding model.

    Returns:
        A list of embedding vectors.

    Raises:
        Exception: If an error occurs during embedding generation.
    """
    try:
        res = google_client.models.embed_content(
            model=GEMINI_MODEL_ID,
            contents=texts,
            config=types.EmbedContentConfig(task_type=task_type),
        )
        return [e.values for e in res.embeddings]
    except Exception as e:
        print(f"An error occurred while getting embeddings: {e}")
        raise


def qdrant_points_stream(corpus, avg_corpus_text_length, gemini_batch_size: int = 8):
    """Streams Qdrant points with embeddings for a given corpus.

    Args:
        corpus: The dataset to process.
        avg_corpus_text_length: The average text length for miniCOIL (based on BM25 formula).
        gemini_batch_size: The batch size for Gemini embedding requests.

    Yields:
        Qdrant PointStruct objects.
    """
    for start in range(0, len(corpus), gemini_batch_size):  # Iterate over the dataset in batches.
        end = min(start + gemini_batch_size, len(corpus))
        batch = corpus.select(range(start, end))  # Current batch slice.

        gemini_embeddings_full = get_embeddings_batch(
            [row["text"] for row in batch], task_type="RETRIEVAL_DOCUMENT"
            )  # Generate embeddings for this batch.

        for batch_item, gemini_embedding_full in zip(batch, gemini_embeddings_full):
            yield models.PointStruct(
                id=str(uuid.uuid4()),  # Unique ID (string UUID or integer supported by Qdrant).
                payload={  # Metadata stored alongside the vector.
                    "text": batch_item["text"],  # Raw text for users/LLMs.
                    "ids": batch_item["ids"],  # IDs of the QA pairs related to this `text` (for later evaluation).
                },
                vector={  # Embeddings.
                    "gemini_embedding_rerank": gemini_embedding_full,  # Full Gemini embedding for reranking.
                    "gemini_embedding_retrieve": gemini_embedding_full[:768],  # Truncated Gemini embedding for retrieval.
                    "miniCOIL": models.Document(  # Custom Qdrant-optimized BM25 replacement.
                        text=batch_item["text"],
                        model="Qdrant/minicoil-v1",
                        options={"avg_len": avg_corpus_text_length, "k": 0.9, "b": 0.4},  # Corpus avg length, k_1 & b from BM25 formula.
                    ),
                },
            )

Ahora incrustarás los datos y subirás los embeddings.

Intenta experimentar con diferentes tamaños de lote al generar embeddings y subirlos a Qdrant.
La configuración más rápida suele depender de la velocidad de tu red y de la RAM/CPU/GPU, y ten en cuenta que la inferencia de embeddings no es un proceso muy rápido.

Las representaciones utilizadas en Qdrant para la parte de recuperación basada en palabras clave de la búsqueda híbrida son producidas por Qdrant.
En Colab, Qdrant descargará los modelos requeridos la primera vez que los uses (en nuestro caso, Qdrant/minicoil-v1), ya que son necesarios para convertir fragmentos text en representaciones dispersas.

import tqdm

COLLECTION_NAME = "legal_AI_QA"

# Estimating the average length of the texts in the corpus on the subsample of 1000, to use in BM25-inspired keywords-based retrieval.
SUBSET_SIZE = 1000
avg_corpus_text_length = sum(len(text.split()) for text in corpus["text"][:SUBSET_SIZE]) / SUBSET_SIZE

qdrant_client.upload_points(
    collection_name=COLLECTION_NAME,
    points=tqdm.tqdm(
        qdrant_points_stream(corpus_deduplicated,
                            avg_corpus_text_length=avg_corpus_text_length,
                            gemini_batch_size=4),
        desc="Uploading points",
    ),
    batch_size=4,
)

Paso 4: experimentar y evaluar

Lo importante para cada tarea de recuperación es experimentar con diferentes instrumentos y ejecutar evaluaciones basadas en una métrica sensata.

Métrica

En RAG, el objetivo suele ser obtener el resultado correcto dentro de los N resultados recuperados principales, usando un N muy pequeño, ya que eso es lo que el LLM usará para generar una respuesta fundamentada, y querrías ahorrar tamaño de ventana de contexto/reducir costos de tokens.

Usarás la métrica hit@1, lo que significa que el fragmento de texto clasificado en la primera posición es en realidad la respuesta a la pregunta.

Conjunto de evaluación

Para los experimentos, solo debes usar preguntas donde el campo answers no sea null, ya que esto garantiza que este fragmento de texto contiene la respuesta a la pregunta.

questions = corpus.filter(lambda item: len(item['answers']) > 0)

Infiere embeddings de Gemini para todas las preguntas, para que puedas experimentar libremente sin gastar tiempo ni dinero extra.

import tqdm

question_embeddings = {}

question_texts = [q['question'] for q in questions]
question_ids = [q['id'] for q in questions]
all_embeddings = []

BATCH_SIZE = 32

for i in tqdm.tqdm(range(0, len(question_texts), BATCH_SIZE), desc="Embedding questions"):
    batch_texts = question_texts[i:i + BATCH_SIZE]
    embeddings = get_embeddings_batch(batch_texts, task_type="QUESTION_ANSWERING")
    all_embeddings.extend(embeddings)

question_embeddings = {qid: emb for qid, emb in zip(question_ids, all_embeddings)}

Y selecciona aleatoriamente un subconjunto de prueba.

questions = questions.shuffle(seed=42).select(range(500))

Experimento

Hay muchas maneras de mejorar los resultados de búsqueda. Por ejemplo, el reordenamiento por sí solo se puede hacer con embeddings de alta dimensión como Gemini, multivectores como ColBERT o codificadores cruzados.

Para simplificar, nos centraremos en tres enfoques de recuperación simples, tres experimentos que son un buen punto de partida para dominios que exigen alta precisión como el legal:

Experimento 1: Recuperación Vanilla
Usa embeddings de Gemini truncados para la recuperación vanilla.
Esto te da un punto de referencia simple para comparar mejoras.

Experimento 2: Reordenamiento
Reordena el subconjunto recuperado con embeddings de Gemini de tamaño completo.
Los embeddings más grandes capturan detalles semánticos más finos que el recuperador podría pasar por alto.

Experimento 3: Búsqueda Híbrida
Combina la recuperación semántica (captura el significado) y basada en palabras clave (asegura coincidencias exactas) en la Búsqueda Híbrida.
Para este dataset de juguete, la coincidencia de palabras clave puede no añadir mucho, ya que todas las preguntas son de estilo muy "conversacional" con no tantas palabras clave, pero en la recuperación de IA legal de la vida real, marca la diferencia.

La configuración es la siguiente:

  1. Ejecuta dos búsquedas con la misma consulta.
  2. Fusiona los resultados en una sola lista con un algoritmo de fusión. Aquí usarás Reciprocal Rank Fusion (RRF), un método de fusión simple y conocido de "zero-shot".

Compara los resultados de los tres experimentos en nuestro conjunto de evaluación utilizando la métrica elegida.

COLLECTION_NAME = "legal_AI_QA"
N = 1

def hit_at_n(results: list[models.ScoredPoint], question_id: str, N: int) -> int:
    """Calculates if the correct document is within the top N retrieved results.

    Args:
        results: A list of scored points from a Qdrant search.
        question_id: The ID of the question to check for.
        N: The number of top results to check.

    Returns:
        1 if the correct document is found, 0 otherwise.
    """
    for result in results:
        if question_id in result.payload["ids"]:
            return 1
    return 0

hits_baseline = 0
hits_rerank = 0
hits_hybrid = 0

for question in tqdm.tqdm(questions, desc=f"Evaluating hits@{N}"):
    full_gemini_embedding = question_embeddings[question['id']] # Embedding of the question.

    ## Experiment 1: Baseline
    result_baseline = qdrant_client.query_points(
        collection_name=COLLECTION_NAME,
        query=full_gemini_embedding[:768],  # Use the first quarter of the Gemini embedding for retrieval.
        limit=N,
        using="gemini_embedding_retrieve",
        with_payload=True,
    )
    hits_baseline += hit_at_n(result_baseline.points, question_id=question['id'], N=N)

    ## Experiment 2: Reranking
    result_rerank = qdrant_client.query_points(
        collection_name=COLLECTION_NAME,
        prefetch=models.Prefetch( # First, retrieve 50 candidates with the smaller embedding.
            query=full_gemini_embedding[:768],
            using="gemini_embedding_retrieve",
            limit=50
        ),
        query=full_gemini_embedding, # Then rerank those 50 results with the full embedding.
        limit=N,
        using="gemini_embedding_rerank",
        with_payload=True
    )
    hits_rerank += hit_at_n(result_rerank.points, question_id=question['id'], N=N)

    ## Experiment 3: Hybrid search (semantic + keyword)
    result_hybrid = qdrant_client.query_points(
        collection_name=COLLECTION_NAME,
        prefetch=[
            models.Prefetch( # Retrieve 25 results using semantic search (Gemini truncated embeddings).
                query=full_gemini_embedding[:768],
                using="gemini_embedding_retrieve",
                limit=25
            ),
            models.Prefetch( # Retrieve 25 results using miniCOIL (Qdrant’s custom improved version of BM25-based keyword search).
                query=models.Document(
                    text=question['question'],
                    model="Qdrant/minicoil-v1"
                ),
                using="miniCOIL",
                limit=25
            )
        ],
        query=models.FusionQuery(fusion="rrf"), # Fuse the two result sets with Reciprocal Rank Fusion (RRF).
        limit=N,
        with_payload=True
    )
    hits_hybrid += hit_at_n(result_hybrid.points, question_id=question['id'], N=N)


hits_at_N_baseline = hits_baseline / len(questions) # Compute average hits@N for each experiment.
hits_at_N_rerank = hits_rerank / len(questions)
hits_at_N_hybrid = hits_hybrid / len(questions)

print("\n")
print(f"Retrieval Avg Hits@{N}: {hits_at_N_baseline:.3f} ({hits_baseline}/{len(questions)})")
print(f"Rerank Avg Hits@{N}: {hits_at_N_rerank:.3f} ({hits_rerank}/{len(questions)})")
print(f"Hybrid Search Avg Hits@{N}: {hits_at_N_hybrid:.3f} ({hits_hybrid}/{len(questions)})")

Próximos pasos

En este notebook, configuraste un pipeline de recuperación detrás de un chatbot RAG legal típico con el Motor de Búsqueda Vectorial Qdrant y los Embeddings de Gemini.
Probaste varios enfoques de recuperación, aprovechando la capacidad de Gemini para generar representaciones Matryoshka y las herramientas de Qdrant para la recuperación con reordenamiento y búsqueda híbrida.

Por supuesto, las aplicaciones legales requieren mucho más que un simple pipeline de "zero-shot". La calidad de la recuperación siempre depende del dataset y del caso de uso, por lo que no hay una solución mágica más allá de experimentar e iterar.

Hacia dónde ir desde "zero-shot":

  • Analizar consultas fallidas;
  • Ajustar los parámetros del índice vectorial (por ejemplo, ef para búsqueda a escala en Qdrant);
  • Experimentar con diferentes estrategias y parámetros de fusión en la búsqueda híbrida;
  • Probar la expansión de consultas (o la extracción de filtros).
  • ...

¡Usa este notebook como base para construir y experimentar para encontrar lo que mejor funcione para ti!

Austronaut judge 2

Lección del curso «Gemini API Cookbook (examples)» de Google, publicado con licencia Apache 2.0. Traducción y adaptación al español de IA con Clase. IA con Clase no está afiliado a Google. Ver el original · Licencia
Esta lección es gratuita. El resto del curso se abre con la Membresía de IA con Clase, que incluye todos los cursos del catálogo. Ver precios