Strona główna / Artykuły

Szukanie wektorowe w Azure AI Search: HNSW, profile oraz Python RAG Retriever

Tworzenie indeksu wektorowego w Azure AI Search za pomocą Pythona: HNSW kontra kompleksowe KNN, profile, embeddingi, zapytania wektorowe oraz prompty RAG, plus wskazówki dotyczące wyszukiwania hybrydowego.

Wydawca: REACTAPP.TOP9 min czytania2084 słów

Źródła: Building a Vector Index in Azure AI Search: HNSW, Profiles, and RAG Retrieval

Retrieval to część systemu RAG, która decyduje, czy ostateczna odpowiedź jest konkretna, czy niejasna. Ten przewodnik opisuje, jak działa wyszukiwanie wektorowe w Azure AI Search, a następnie tworzy niewielki serwis w Pythonie, który przyjmuje pytanie, znajduje najbliższy pod względem znaczenia tekst i przygotowuje prompt dla modelu językowego. Uwaga dotycząca nazwy: serwis ten wcześniej nazywał się Azure Cognitive Search, a wiele starszych postów i przykładów nadal używa tej nazwy. Koncepcje pozostają identyczne.

Dwie części RAG

Retrieval-augmented generation składa się z etapu pobierania informacji oraz generowania odpowiedzi. Gdy przychodzi pytanie, system przeszukuje bazę wiedzy i wybiera najbardziej istotne fragmenty tekstu. Następnie przekazuje te fragmenty wraz z pytaniem modelowi językowemu, dzięki czemu odpowiedź opiera się na rzeczywistym tekście źródłowym.

Jakość generowanego tekstu zależy wyłącznie od tego, co otrzymuje model. Jeśli proces wyszukiwania zwróci błędne fragmenty, model będzie odpowiadać błędnie lub wymijająco, bez względu na swoje możliwości. Dlatego wybór techniki wyszukiwania ma tak wielkie znaczenie.

Dlaczego dopasowywanie słów kluczowych jest niewystarczające

Klasyczne wyszukiwanie dopasowuje dosłowne słowa. Zapytanie dotyczące „samochodu” może pominąć dokument, w którym użyto wyrażenia „automobile”. Wyszukiwanie wektorowe rozwiązuje ten problem, pracując na poziomie znaczeń.

Każdy fragment tekstu jest przekształcany na listę liczb zwanych embeddingiem. Teksty o podobnym znaczeniu znajdują się blisko siebie w przestrzeni wektorowej. Podczas wyszukiwania pytanie również jest zamieniane na embedding, a usługa szuka przechowywanych wektorów najbliższych mu, co stanowi zadanie znane jako wyszukiwanie najbliższych sąsiadów. W Azure AI Search deklaruje się pole wektorowe w indeksie, przechowuje tam embeddingi, a usługa tworzy strukturę umożliwiającą szybkie przeszukiwanie ogromnej liczby wektorów.

Usługa oferuje dwa algorytmy wyszukiwania wektorów:

  • HNSW (Hierarchical Navigable Small World) to przybliżona metoda wyszukiwania najbliższych sąsiadów. Jest szybka i zwykle wybierana do użycia w produkcji.
  • Completne KNN porównuje zapytanie z każdym przechowywanym wektorem. Jest dokładne, ale wolniejsze, więc nadaje się do małych zbiorów danych lub do sprawdzenia dokładności metody przybliżonej.
  • Algorytmy nie są bezpośrednio przypisane do pola. Indeks zawiera konfigurację wyszukiwania wektorowego z listą algorytmów i listą profili, przy czym każdy profil odpowiada nazwie konkretnej opcji algorytmu. Pole wektorowe odwołuje się następnie do profilu za pomocą nazwy, dzięki czemu identyczne ustawienia algorytmu mogą być używane w kilku polach.

    Typ pola to Collection(Edm.Single), czyli zbiór liczb zmiennoprzecinkowych o pojedynczej precyzji. Musisz podać liczbę wymiarów, a ta wartość musi być równa rozmiarowi wyników używanego modelu embeddingu.

    Kształt systemu

    Istnieją dwa sposoby działania. Proces pobierania danych odbywa się raz lub za każdym razem, gdy zmieniają się dane, i wypełnia indeks. Proces wyszukiwania uruchamia się przy każdym zapytaniu. Łączy je jedna zasada: oba sposoby muszą wykorzystywać ten sam model embeddingu. Jeśli dokumenty zostaną zaimplementowane za pomocą jednego modelu, a pytania innego, wektory nie będą mogły być porównywane, przez co wyniki stracą sens.

    Dokumenty zazwyczaj przechowywane są w postaci fragmentów, a nie całych plików. Mniejsze fragmenty umożliwiają dokładniejszą wyszukiwanie i łatwiej pasują do zapytania. Każdy fragment staje się oddzielnym dokumentem wyszukiwawczym zawierającym jego tekst, embedding oraz metadane wskazujące na źródło. W tym przykładzie użyto krótkich dokumentów, więc każdy z nich stanowi pojedynczy fragment. W systemie produkcyjnym dodano by krok dzielenia na fragmenty, ale struktura indeksu pozostaje niezmienna.

    Budowa w praktyce

    Wymagana jest subskrypcja Azure, Python 3.9 lub nowsza oraz pewna biegłość w uruchamianiu skryptów. Embeddingi są generowane lokalnie przy użyciu małego modelu otwartego, więc nie jest konieczne używanie rozwiązania Azure OpenAI do dalszej pracy. Przejście na Azure OpenAI zostanie omówione pod koniec.

    Krok 1: Stworzenie usługi wyszukiwania

    W portalu Azure utwórz zasób Azure AI Search. W tym przypadku wystarczy poziom darmowy. Gdy zasób zostanie utworzony, skopiuj dwie wartości: endpoint usługi w formacie https://<twoja-usluga>.search.windows.net oraz klucz administracyjny z sekcji Klucze. Traktuj ten klucz jako tajemnicę. W rzeczywistym projekcie powinien znajdować się w zmiennej środowiskowej lub Azure Key Vault, a nigdy w kodzie źródłowym.

    Krok 2: Zainstalowanie bibliotek

    Potrzebne są dwa pakiety: Azure Search SDK oraz sentence-transformers.

    pip install azure-search-documents sentence-transformers
    

    Krok 3: Określenie indeksu wektorowego

    Następny skrypt tworzy indeks z polem wektorowym, a następnie określa konfigurację wyszukiwania wektorowego wraz z algorytmem i profilem przed utworzeniem indeksu.

    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)
    

    Kilka wyborów w tym skrypcie wymaga wyjaśnienia. Liczba wymiarów wynosi 384, ponieważ tyle generuje wybrany model embeddingu. Pole wektorowe wskazuje na profil o nazwie my-vector-profile, który z kolei wskazuje na algorytm HNSW o nazwie my-hnsw. Miara odległości to kosinus, co jest powszechnym standardem dla embeddingów tekstowych.

    HNSW umożliwia dostosowanie parametrów, a Azure oferuje rozsądne domyślne wartości. Dokumentowane wartości to m równa 4 dla liczby dwukierunkowych powiązań na węzeł, efConstruction równa 400 dla listy kandydatów podczas budowania grafu oraz efSearch równa 500 dla listy kandydatów podczas wyszukiwania. Zwiększenie tych wartości poprawia dokładność wyników, ale kosztem opóźnienia i czasu budowania grafu, więc zacznij od domyślnych ustawień i dostosuj je do swoich celów.

    Krok 4: Wbudowanie i przesłanie

    Ten skrypt ładuje model, tworzy małą bazę wiedzy składającą się z pięciu krótkich dokumentów, oblicza ich embeddingi i przesyła je do indeksu.

    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.")
    

    Prawdziwy projekt odczytywałby dane z plików lub bazy danych, dzielił tekst na fragmenty i wysyłał tysiące lub miliony dokumentów za pomocą tej samej funkcji upload_documents, zwykle w partiach.

    Krok 5: Zapytanie wektorowe

    Aby odzyskać informacje, pytanie użytkownika jest włączane do modelu i następnie wysyłane jako zapytanie wektorowe. Obiekt VectorizedQuery informuje usługę, który wektor należy użyć, ile sąsiadów powinno zostać zwróconych oraz w którym polu należy przeprowadzić wyszukiwanie.

    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"])
    

    Spróbuj zadać pytanie sformułowane jako „odzyskaj moje pieniądze”. Żaden z dokumentów przykładowych nie zawiera tych słów, a mimo to dokument dotyczący zwrotów pieniędzy powinien znaleźć się na pierwszym miejscu, ponieważ porównanie dotyczy znaczenia, a nie słów kluczowych. To dokładnie takie zachowanie jest potrzebne systemowi RAG.

    Krok 6: Skomponuj prompt

    Pobieranie danych zostało zakończone. Ostatni krok polega na wybraniu odpowiednich wyników i stworzeniu promptu. Nie wskazuje się tu konkretnego modelu, ponieważ można użyć Azure OpenAI lub dowolnego innego dostawcy. Skrypt jedynie przygotowuje dane wejściowe.

    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"))
    

    Wynikowy prompt zawiera pytanie wraz z najbardziej relevantnymi fragmentami tekstu. Wysłanie go do modelu zamyka pętlę wzbogacaną o wyniki wyszukiwania, przy czym Azure AI Search pełni rolę magazynu wektorowego i narzędzia do ich wyodrębniania.

    Używanie embeddingów Azure OpenAI

    Wiele zespołów w Azure korzysta z hostowanych modeli embeddingów, takich jak text-embedding-3-small, zamiast tych lokalnych. Zmiana jest niewielka: należy wywołać klienta Azure OpenAI w celu utworzenia każdego embeddingu oraz ustawić liczbę wymiarów indeksu tak, aby odpowiadała modelowi – na przykład 1536 dla text-embedding-ada-002. Wszystko inne w kodzie indeksu i zapytań pozostaje bez zmian.

    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 dodatkowo obsługuje zintegrowaną wektorowizację: usługa sama wywołuje model Azure OpenAI, konwertując tekst na wektory podczas indeksowania oraz ponownie dla każdej zapytania. Dzięki temu unikasz konieczności pisania kodu do tworzenia wektorów i to rozwiązanie nadaje się do większych procesów, ale powyższy podstawowy schemat wystarcza, aby zrozumieć, jak funkcjonuje wyszukiwanie wektorowe.

    Co może pójść nie tak

    Trzy błędy są przyczyną większości rozczarowujących pierwszych wyników. Pierwszym jest niezgodność wymiarów: jeśli indeks wskazuje 384, a model generuje 1536 liczb, przesyłanie danych zawodzi, a rozwiązaniem jest odczytanie wielkości wyjścia modelu i odpowiednie ustawienie pola. Drugim błędem jest łączenie modeli embeddingowych podczas pobierania danych i wyszukiwania, co daje wyniki wyglądające wiarygodnie, ale w rzeczywistości będące losowe. Trzecim błędem jest zmiana modeli później bez ich ponownego budowania, ponieważ każdy przechowywany wektor należy wtedy do przestrzeni starego modelu i musi zostać ponownie wygenerowany oraz przesłany.

    Habitem, który oszczędza czas, jest przechowywanie nazwy modelu oraz liczby wymiarów w jednej wartości konfiguracyjnej, którą odczytują zarówno skrypt pobierania danych, jak i funkcja wyszukiwania, dzięki czemu nie mogą one się rozchodzić.

    Weryfikacja jakości wyszukiwania

    Zanim zaufasz narzędziu do wyszukiwania, napisz około dziesięciu realistycznych pytań i zaznacz, który dokument powinien zostać przywrócony dla każdego z nich. Przepuść je przez funkcję wyszukiwania i sprawdź trzy najlepsze wyniki. Jeśli brakuje właściwego fragmentu, typowymi przyczynami są zbyt duże lub zbyt małe kawałki tekstu, słaby model embeddingu dostosowany do twojej dziedziny lub ustawienia HNSW faworyzujące szybkość kosztem dokładności. Porównaj to z pełną metodą KNN na tych samych danych, aby sprawdzić, o ile dokładność jest utracona w przypadku przybliżonego wyszukiwania, ponieważ KNN dostarcza dokładnej odpowiedzi do porównania.

    Uwagi dotyczące przejścia poza wersję demonstracyjną

    • Model embeddingu. Określa on wymiar wektorów i w dużej mierze wpływa na jakość wyszukiwania, dlatego należy go starannie dobrać.
    • Dzielenie na kawałki. Przed indeksowaniem podziel długie dokumenty na bardziej skoncentrowane fragmenty.
  • Pościg hybrydowy. Aby połączyć dopasowanie słów kluczowych z semantycznym, podaj wartość dla search_text razem z zapytaniem wektorowym. Usługa łączy wyniki oparte na słowach kluczowych BM25 z wynikami wektorowymi.
  • Ranking semantyczny. Opcjonalny mechanizm rankingu semantycznego przestawia najlepsze wyniki za pomocą modelu językowego w celu lepszego uporządkowania.
  • Rozmiar indeksu. Indeksy wektorowe zużywają pamięć, dlatego obserwuj ich rozmiar i rozważ kompresję wektorów lub użycie wektorów binarnych, jeśli problemem staje się przestrzeń przechowywania.
  • Tutaj możesz dodać własne dokumenty, funkcję dzielenia na fragmenty, pościg hybrydowy oraz model według własnego wyboru, aby przekształcić tę demonstrację w pełną aplikację RAG.