Inicio / Artículos

Búsqueda vectorial en Azure AI Search: HNSW, perfiles y un recuperador RAG en Python

Crear un índice vectorial en Azure AI Search con Python: HNSW frente a KNN exhaustivo, perfiles, embeddings, consultas vectoriales y un prompt RAG, además de consejos para búsquedas híbridas.

Editor: REACTAPP.TOP9 min de lectura2084 palabras

Fuentes: Building a Vector Index in Azure AI Search: HNSW, Profiles, and RAG Retrieval

La recuperación de información es la parte de un sistema RAG que determina si la respuesta final está basada en fuentes concretas o es vaga. Esta explicación aborda cómo funciona la búsqueda vectorial dentro de Azure AI Search y, a continuación, crea un pequeño servicio en Python que recibe una pregunta, encuentra los textos más relevantes según su significado y elabora un prompt para un modelo de lenguaje. Una nota sobre la denominación: el servicio solía llamarse Azure Cognitive Search, y muchos artículos y ejemplos antiguos siguen utilizando ese nombre. Los conceptos son idénticos.

Las dos partes de RAG

La generación mejorada por recuperación de información se divide en recuperación y generación. Cuando llega una pregunta, el sistema busca en una base de conocimientos y extrae los pasajes más relevantes. Luego entrega esos pasajes a un modelo de lenguaje junto con la pregunta, de modo que la respuesta se base en texto original real.

La calidad de la generación depende únicamente de lo que se le proporciona. Si la recuperación devuelve pasajes incorrectos, el modelo responderá erróneamente o de forma evasiva, por más capaz que sea. Por eso la elección de la técnica de búsqueda es tan importante.

Por qué la coincidencia por palabras clave no basta

La búsqueda clásica compara palabras de forma literal. Una consulta para “coche” podría pasar por alto un documento que solo diga “automóvil”. La búsqueda vectorial soluciona esto al trabajar con el significado.

Cada fragmento de texto se convierte en una lista de números llamada embedding. Los textos que significan cosas similares se encuentran cerca uno del otro en el espacio vectorial. En el momento de la consulta, la pregunta también se convierte en un embedding, y el servicio busca los vectores almacenados más cercanos a él, una tarea conocida como búsqueda de vecino más cercano. En Azure AI Search, se declara un campo vectorial en el índice, se almacenan allí los embeddings, y el servicio crea una estructura que permite escanear una gran cantidad de vectores rápidamente.

El servicio ofrece dos algoritmos de búsqueda para vectores:

  • HNSW (Hierarchical Navigable Small World) es un método aproximado de vecino más cercano. Es rápido y suele ser la opción preferida para entornos de producción.
  • KNN exhaustivo compara la consulta con cada vector almacenado. Es preciso pero más lento, por lo que es adecuado para conjuntos de datos pequeños o para verificar cuán precisa es el método aproximado.
  • Los algoritmos no están asociados directamente a un campo. Un índice contiene una configuración de búsqueda vectorial con una lista de algoritmos y una lista de perfiles, donde cada perfil asigna un nombre a una opción de algoritmo. Un campo vectorial hace referencia entonces a un perfil por su nombre, de modo que los mismos parámetros del algoritmo pueden compartirse entre varios campos.

    El tipo de campo es Collection(Edm.Single), una colección de flotantes de precisión simple. Debe indicarse un número de dimensiones, y esa cifra debe ser igual al tamaño de salida del modelo de embedding que se utilice.

    La estructura del sistema

    Existen dos vías posibles. La vía de ingestión se ejecuta una vez, o cada vez que cambian los datos, y llena el índice. La vía de consulta se ejecuta con cada pregunta. Una regla los une: ambas vías deben utilizar el mismo modelo de embedding. Si se incrustan los documentos con un modelo y las preguntas con otro, los vectores no pueden compararse, por lo que los resultados pierden sentido.

    Por lo general, los documentos se almacenan en fragmentos en lugar de como archivos completos. Los fragmentos más pequeños permiten una recuperación más precisa y se adaptan con mayor facilidad a un prompt. Cada fragmento se convierte en un documento de búsqueda que contiene su texto, su embedding y metadatos que lo relacionan con el origen. La muestra aquí utiliza documentos cortos, por lo que cada uno cuenta como un único fragmento. Un sistema en producción agregaría un paso de fragmentación, pero la estructura del índice permanecería igual.

    Construcción práctica

    Necesita una suscripción a Azure, Python 3.9 o una versión más reciente, además de cierta familiaridad con la ejecución de scripts. Las incrustaciones se generan localmente con un modelo abierto de tamaño reducido, por lo que no es necesario utilizar Azure OpenAI para seguir el proceso. Se aborda la posibilidad de pasar a Azure OpenAI hacia el final.

    Paso 1: Crear el servicio de búsqueda

    Dentro del portal de Azure, cree un recurso de Azure AI Search. La versión gratuita es suficiente en este caso. Una vez creado, copie dos valores: el punto de extremo del servicio, con formato https://<your-service>.search.windows.net, y una clave de administrador de la sección Keys. Trate esa clave como un secreto. En un proyecto real, debería almacenarse en una variable de entorno o en Azure Key Vault, y nunca en el código fuente.

    Paso 2: Instalar las bibliotecas

    Son necesarios dos paquetes: el Azure Search SDK y sentence-transformers.

    pip install azure-search-documents sentence-transformers
    

    Paso 3: Definir el índice vectorial

    El siguiente script crea un índice con un campo vectorial y, a continuación, define la configuración de búsqueda vectorial con el algoritmo y el perfil antes de generar el índice.

    from azure.core.credentials import AzureKeyCredential
    from azure.search.documents.indexes import SearchIndexClient
    from azure.search.documents.indexes.models import (
        SearchIndex,
        SimpleField,
        SearchableField,
        SearchField,
        SearchFieldDataType,
        VectorSearch,
        HnswAlgorithmConfiguration,
        HnswParameters,
        VectorSearchProfile,
    )
    
    endpoint = "https://<your-service>.search.windows.net"
    admin_key = "<your-admin-key>"
    index_name = "rag-docs"
    index_client = SearchIndexClient(
        endpoint=endpoint,
        credential=AzureKeyCredential(admin_key),
    )
    # 1. Define the fields. The embedding field is the vector field.
    fields = [
        SimpleField(name="doc_id", type=SearchFieldDataType.String, key=True, filterable=True),
        SearchableField(name="text", type=SearchFieldDataType.String),
        SimpleField(name="source", type=SearchFieldDataType.String, filterable=True),
        SearchField(
            name="embedding",
            type=SearchFieldDataType.Collection(SearchFieldDataType.Single),
            searchable=True,
            vector_search_dimensions=384,             # must match the embedding model
            vector_search_profile_name="my-vector-profile",
        ),
    ]
    # 2. Define the vector search configuration: one algorithm and one profile.
    vector_search = VectorSearch(
        algorithms=[
            HnswAlgorithmConfiguration(
                name="my-hnsw",
                parameters=HnswParameters(
                    m=4,
                    ef_construction=400,
                    ef_search=500,
                    metric="cosine",
                ),
            )
        ],
        profiles=[
            VectorSearchProfile(
                name="my-vector-profile",
                algorithm_configuration_name="my-hnsw",
            )
        ],
    )
    # 3. Create (or update) the index.
    index = SearchIndex(name=index_name, fields=fields, vector_search=vector_search)
    index_client.create_or_update_index(index)
    print("Index created:", index_name)
    

    Algunas opciones en ese script merecen una explicación. El número de dimensiones es 384 porque ese es el valor que emite el modelo de embedding seleccionado. El campo vectorial apunta a un perfil llamado my-vector-profile, el cual a su vez apunta a un algoritmo HNSW denominado my-hnsw. La métrica de distancia es el coseno, un valor predeterminado común para los embeddings de texto.

    HNSW ofrece parámetros de ajuste, y Azure proporciona valores predeterminados razonables. Los valores documentados son m igual a 4 para la cantidad de enlaces bidireccionales por nodo, efConstruction igual a 400 para la lista de candidatos durante la construcción del grafo, y efSearch igual a 500 para la lista de candidatos durante las consultas. Aumentarlos mejora la precisión a costa de la latencia y del tiempo de construcción, por lo que comience con los valores predeterminados y ajuste según sus propios objetivos.

    Paso 4: Incrustar y cargar

    Este script carga el modelo, crea una pequeña base de conocimientos con cinco documentos cortos, calcula sus embeddings y los sube al índice.

    from azure.search.documents import SearchClient
    from sentence_transformers import SentenceTransformer
    
    search_client = SearchClient(
        endpoint=endpoint,
        index_name=index_name,
        credential=AzureKeyCredential(admin_key),
    )
    # Load the embedding model (produces 384-dimensional vectors)
    model = SentenceTransformer("all-MiniLM-L6-v2")
    # Our small knowledge base
    documents = [
        {"doc_id": "1", "source": "billing-faq",
         "text": "You can update your payment method from the account settings page under Billing."},
        {"doc_id": "2", "source": "billing-faq",
         "text": "Refunds are processed within five to seven business days to the original payment method."},
        {"doc_id": "3", "source": "shipping-faq",
         "text": "Standard delivery takes three to five working days within the country."},
        {"doc_id": "4", "source": "account-faq",
         "text": "To reset your password, click the forgot password link on the login screen."},
        {"doc_id": "5", "source": "shipping-faq",
         "text": "International orders may take up to fourteen working days depending on customs."},
    ]
    # Create embeddings for the text of each document
    texts = [d["text"] for d in documents]
    vectors = model.encode(texts)
    # Attach the embedding to each document and upload
    to_upload = []
    for doc, vector in zip(documents, vectors):
        to_upload.append({
            "doc_id": doc["doc_id"],
            "source": doc["source"],
            "text": doc["text"],
            "embedding": vector.tolist(),
        })
    result = search_client.upload_documents(documents=to_upload)
    print("Uploaded", len(result), "documents.")
    

    Un proyecto real leería desde archivos o una base de datos, dividiría el texto en partes y enviaría miles o millones de documentos mediante la misma llamada upload_documents, normalmente por lotes.

    Paso 5: Búsqueda por vector

    La recuperación de información incrusta la pregunta del usuario con el mismo modelo y luego realiza una búsqueda por vector. Un objeto VectorizedQuery indica al servicio qué vector utilizar, cuántos vecinos devolver y en qué campo buscar.

    from azure.search.documents.models import VectorizedQuery
    
    def search(question, k=3):
        # Embed the question with the same model
        query_vector = model.encode([question])[0]
        vector_query = VectorizedQuery(
            vector=query_vector.tolist(),
            k_nearest_neighbors=k,
            fields="embedding",
        )
        results = search_client.search(
            search_text=None,                 # pure vector search
            vector_queries=[vector_query],
            select=["doc_id", "source", "text"],
        )
        output = []
        for r in results:
            output.append({
                "score": r["@search.score"],
                "source": r["source"],
                "text": r["text"],
            })
        return output
    
    for item in search("how do I get my money back", k=3):
        print(round(item["score"], 4), "|", item["source"], "|", item["text"])
    

    Pruebe con una pregunta formulada como “devuélvame mi dinero”. Ninguno de los documentos de ejemplo contiene esas palabras, sin embargo el documento sobre reembolsos debería aparecer primero, ya que la comparación se basa en el significado y no en las palabras clave. Ese es exactamente el comportamiento que necesita un sistema RAG.

    Paso 6: Compilar la instrucción

    La recuperación de información ya se ha realizado. El último paso consiste en tomar los resultados obtenidos y crear una instrucción para el sistema. Aquí no se especifica ningún modelo en particular, ya que puede utilizar Azure OpenAI o cualquier otro proveedor. El script solo prepara la entrada.

    def build_prompt(question, k=3):
        hits = search(question, k=k)
        context = "\n\n".join(f"- {hit['text']}" for hit in hits)
    
    prompt = (
            "You are a support assistant. Use only the context below to answer "
            "the question. If the answer is not in the context, say that you do "
            "not have enough information.\n\n"
            f"Context:\n{context}\n\n"
            f"Question: {question}\n"
            "Answer:"
        )
        return prompt
    
    print(build_prompt("how do I get my money back"))
    

    La instrucción resultante incluye la pregunta junto con los pasajes más relevantes. Al enviarla a un modelo se completa el ciclo de recuperación mejorada, con Azure AI Search como almacén y recuperador vectorial.

    Sustituyendo por embeddings de Azure OpenAI

    Muchos equipos de Azure utilizan modelos de embeddings alojados como text-embedding-3-small en lugar de uno local. El cambio es sencillo: se llama al cliente de Azure OpenAI para crear cada embedding y se establece el número de dimensiones del índice de manera que coincida con el modelo, por ejemplo 1536 para text-embedding-ada-002. Todo lo demás en el código del índice y la consulta permanece sin cambios.

    from openai import AzureOpenAI
    
    client = AzureOpenAI(
        api_key="<your-azure-openai-key>",
        api_version="2024-10-21",
        azure_endpoint="https://<your-resource>.openai.azure.com/",
    )
    def get_embedding(text):
        response = client.embeddings.create(
            input=text,
            model="text-embedding-3-small",   # your deployment name
        )
        return response.data[0].embedding
    

    Azure AI Search también admite vectorización integrada: el servicio llama por sí mismo a un modelo de Azure OpenAI, convirtiendo el texto en vectores durante el indexado y nuevamente para cada consulta. Esto le ahorra la necesidad de escribir código de incrustación y es adecuado para pipelines más grandes, pero el flujo básico descrito anteriormente es suficiente para comprender cómo funciona la búsqueda vectorial.

    Qué puede salir mal

    Tres errores son la causa de los resultados iniciales más decepcionantes. El primero es una discrepancia en las dimensiones: si el índice indica 384 pero el modelo genera 1536 números, las subidas fallan, y la solución es leer el tamaño de salida del modelo y establecer el campo en consecuencia. El segundo es mezclar modelos de embedding entre la fase de ingestión y las consultas, lo que produce resultados que parecen plausibles pero son en realidad aleatorios. El tercero es cambiar de modelo posteriormente sin reconstruirlo, ya que todos los vectores almacenados pertenecerán entonces al espacio del modelo antiguo y deberán ser regenerados y vuelto a subir.

    Una costumbre que ahorra tiempo es mantener el nombre del modelo y la cantidad de dimensiones en un único valor de configuración que tanto el script de ingestión como la función de consulta lean, para que no se desvíen entre sí.

    Verificación de la calidad de recuperación

    Antes de confiar en el recuperador, escribe unas diez preguntas realistas y anota qué documento debería devolverse para cada una. Pásalas por la función de búsqueda y observa los tres resultados principales. Si falta el pasaje correcto, las causas habituales son fragmentos demasiado grandes o demasiado pequeños, un modelo de embedding débil para tu dominio, o configuraciones HNSW que priorizan la velocidad sobre la precisión en la recuperación. Compara con un método KNN exhaustivo en los mismos datos para ver cuánta precisión pierde la búsqueda aproximada, ya que KNN proporciona la respuesta exacta con la que medir.

    Notas para ir más allá de la demostración

    • Modelo de embedding. Determina la dimensión de tus vectores y afecta en gran medida la calidad de la recuperación, por lo que elige este parámetro con cuidado.
    • Fragmentación. Divide los documentos largos en pasajes específicos antes de indexarlos.
  • Búsqueda híbrida. Para combinar la coincidencia por palabras clave con la semántica, proporcione un valor para search_text junto con la consulta vectorial. El servicio fusiona los resultados por palabras clave de BM25 con los resultados vectoriales.
  • Ranqueo semántico. El clasificador semántico opcional reordena los resultados principales mediante un modelo de lenguaje para obtener un ordenamiento mejor.
  • Tamaño del índice. Los índices vectoriales consumen memoria, por lo que debe vigilar su tamaño y considerar la compresión de vectores o vectores binarios si el almacenamiento se vuelve un problema.
  • A partir de aquí puede agregar sus propios documentos, la división en fragmentos, la búsqueda híbrida y un modelo de su elección para convertir esta demostración en una aplicación RAG completa.