Strona główna / Artykuły / Wybór i dostosowywanie modeli embeddingowych dla systemów produkcyjnych RAG

Wybór i dostosowywanie modeli embeddingowych dla systemów produkcyjnych RAG

Dowiedz się, w jaki sposób modele embeddingowe przekształcają tekst w wektory nadające się do wyszukiwania, dlaczego słownictwo danej dziedziny zakłóca wyszukiwanie semantyczne oraz jak wybierać, kompresować i dopasowywać modele do zastosowań produkcyjnych RAG.

6521 słów

To jest piąty odcinek z serii poświęconej budowie systemów generowania wzmocnionego wyszukiwaniem o poziomie produkcyjnym, pokazującej drogę od surowych dokumentów do systemu, który potrafi odpowiadać na rzeczywiste pytania. W wcześniejszych etapach tej serii omówiono czyszczenie i normalizację wydobytego treści, a następnie jej podział na jednostki wyszukiwania poprzez dzielenie na fragmenty. Gdy mamy już te fragmenty, kolejnym krokiem jest przekształcenie ich w coś, co indeks wyszukiwania może faktycznie porównać.

Załóżmy, że chodzi o tego samego menedżera relacji przedstawionego wcześniej, który wciąż boryka się z kwestią linii kredytowej w wysokości 12 milionów euro dla klienta korporacyjnego o wysokim ryzyku. Aby spełnić to żądanie, trzeba znaleźć wymóg pogłębionej weryfikacji ukryty w klauzuli polisy, próg zatwierdzenia znajdujący się w wierszu tabeli oraz sekwencję kontrolną opisaną na diagramie procesu. Etap dzielenia na fragmenty już wyodrębnił te elementy jako odrębne, możliwe do śledzenia części treści.

Mimo to żadna z tych informacji nie może być jeszcze wyszukiwana za pomocą indeksu wektorowego.

Model embedding przekształca każdy fragment tekstu w wektor liczbowy o stałej długości. Ten sam model następnie przekształca wprowadzone zapytanie w wektor, a indeks wybiera te fragmenty, które znajdują się najbliżej niego w tym przestrzeni wektorowej. Nie ma gwarancji, że frazy takie jak „Group Credit Committee approval” w dokumencie polityki i „GCC sign-off threshold” w pytaniu użytkownika faktycznie będą blisko siebie – zależy to wyłącznie od wybranego modelu oraz tego, czego nauczył się podczas treningu.

O tej zależności właśnie jest ta część serii.

Wybór modelu embeddingowego oznacza stawianie na to, jak dużo słownictwa i sformułowań dokumenty te mają wspólnego z zapytaniami użytkowników. Jeśli wybierzesz niewłaściwy model dla swojej dziedziny, stracisz tygodnie na próby rozwiązania problemu, który wygląda jak błąd wyszukiwania, ale w rzeczywistości jest problemem reprezentacji – same wektory są umieszczone w niewłaściwy sposób, więc żadna optymalizacja indeksu tego nie naprawi.

Co tak naprawdę oblicza model embeddingowy

W istocie model embeddingowy przyjmuje sekwencję tokenów i wytwarza jeden gęsty wektor, zazwyczaj o rozmiarze od 384 do 3072 wymiarów, w zależności od konkretnego modelu. Ten wektor ma stanowić skompresowaną reprezentację znaczenia danych wejściowych.

Założeniem leżącym u podstaw tej metody jest to, że dane o podobnym znaczeniu mają wektorów znajdujące się blisko siebie w tym przestrzeni. Bliskość ta najczęściej mierzy się za pomocą podobieństwa kosinowego, który bierze pod uwagę kąt dzielący dwa wektory, a nie ich bezpośrednią odległość — co sprawia, że metoda ta jest niewrażliwa na różnice w długości tekstu.

Rozważmy zasadę zgodności stanowiącą, że klienci korporacyjni sklasyfikowani jako wysokiego ryzyka muszą przejść dodatkowe procedury weryfikacyjne przed tym, jak można złożyć propozycję kredytową do rozpatrzenia. Sprawny model uniwersalny prawdopodobnie umieściłby swój wektor w pobliżu podobnych sformułowań dotyczących zgodności z obowiązującymi regulacjami pochodzących od innych banków, w pobliżu materiałów prawniczych dotyczących obowiązków w zakresie weryfikacji, oraz w pobliżu wytycznych regulacyjnych dotyczących obsługi klientów o wysokim ryzyku.

Menedżer relacji może jednak sformułować tę samą podstawową potrzebę zupełnie inaczej, pytając na przykład o kroki, które muszą zostać podjęte przed złożeniem wniosku o kredyt. To, czy takie potoczne sformułowanie jest bliskie formalnemu językowi polityki, zależy od tego, w jakim stopniu dane treningowe łączyły nieformalne pytania operacyjne z formalnym językiem przestrzegania regulacji. Modele trenowane głównie na szerokim, uniwersalnym tekście internetowym często nie przyswajają takich specyficznych połączeń, gdy dziedzina jest wyspecjalizowana.

Ta luka pomiędzy potocznym sformułowaniem zapytań a językiem specjalistycznych dokumentów nazywana jest niezgodnością słownictwa i stanowi główną przyczynę problemów z jakością wyszukiwania w systemach RAG korporacyjnych.

Tokowizacja i okno kontekstowe

Zanim model embeddingowy cokolwiek obliczy, najpierw dzieli dane wejściowe na tokeny przy użyciu własnego wewnętrznego słownika. Liczba tokenów nie odpowiada wprost liczbie słów ani znaków. Fragment składający się z 500 tokenów w języku angielskim może odpowiadać mniej więcej 350–400 słowom, podczas gdy taka sama liczba tokenów w języku niemieckim – gdzie słowa są często złożone – może reprezentować mniej odrębnych idei.

Każdy model embeddingowy określa maksymalną długość kontekstu, a wszystko, co jest dłuższe, albo zostaje skrócone, albo wymaga specjalnego potraktowania. Modele Sentence-Transformers zazwyczaj mają limit w przedziale od 256 do 512 tokenów. Model text-embedding-3-large firmy OpenAI obsługuje do 8,191 tokenów. BGE-M3 może pracować z aż 8,192 tokenami. Model Jina embeddings v3 również obsługuje maksymalnie 8,192 tokenów.

Praktyczną zasadą przy tworzeniu systemów RAG w produkcji jest to, że granice fragmentów tekstu ustalone wcześniej w procesie muszą mieścić się w limicie kontekstowym narzuconym przez wybrany model embeddingu. Każdy fragment przekraczający ten limit jest cicho odcinany, a powstały wektor odzwierciedla jedynie część oryginalnego tekstu — co stanowi problem, który nie pojawi się w żadnych logach procesu.

Przestrzeń semantyczna i moment jej awarii

Większość obecnych modeli embeddingu to kodery typu transformer, szkolenie których odbywa się przy użyciu celu kontrastywnego: pary tekstów o podobnym znaczeniu są zbliżane w przestrzeni wektorowej, natomiast pary o różnym znaczeniu są oddalane. Po odpowiednim czasie szkolenia model uzyskuje taką strukturę geometryczną, w której bliskość służy jako wskaźnik powinowactwa semantycznego.

Taki układ funkcjonuje dobrze, dopóki zapytania i dokumenty używają tej samej terminologii, stylu pisania oraz koncepcji co to, na czym szkolono model. W przypadku RAG dla przedsiębiorstw zazwyczaj zawodzi na kilka przewidywalnych sposobów:

  • Terminologia specyficzna dla danej dziedziny powoduje błędy, które łatwo przeoczyć. Analityk pytający o „progi składania raportów SAR w celu strukturyzacji” może nie otrzymać żadnego wyniku przy porównaniu z fragmentem polityki sformułowanym jako „Kryteria składania raportów o podejrzanej działalności w celu strukturyzacji transakcji”, jeśli model nigdy nie nauczył się, że te dwa sformułowania oznaczają to samo.
  • Skróty nie zachowują się spójnie. Terminy takie jak „GCC” (Group Credit Committee), „EDD” (Enhanced Due Diligence) oraz „RFI” (Request for Information) mogą być przez model uniwersalny zinterpretowane według ich częściej spotykanych znaczeń w innych kontekstach — Gulf Cooperation Council, Electronic Document Delivery, Radio Frequency Interference.
  • Identyfikatory regulacyjne nie mają żadnego wrodzonego znaczenia dla modeli ogólnego przeznaczenia. Kod taki jak CRD-EU-047, przetwarzany przez model uniwersalny, jest traktowany jako dowolna sekwencja znaków. Model wyszkolony specjalnie na tekście regulacyjnym umieściłby go natomiast obok innych identyfikatorów regulacji kredytowych UE należących do tej samej grupy pojęciowej.
  • Progi liczbowe są jedynie częściowo dobrze radzone przez modele ogólne. Wyrażenie „10 milionów EUR” samo w sobie jest umieszczane w pobliżu innych danych finansowych. Gdy pojawia się ono obok informacji o uprawnieniach do zatwierdzeń, model dostosowany specjalnie do tego obszaru może wykryć związek pomiędzy tą konkretną kwotą a kontrolą zarządzania, którą uruchamia — co model ogólny rzadziej potrafi poprawnie zinterpretować.
  • Rozpoznanie dokładnego momentu, w którym modele ogólne przestają być skuteczne, ma takie samo znaczenie jak znajomość modelu, który zajmuje czoło listy rankingowej publicznych testów.

    Wybór modelu embedding w 2025 roku

    Ogólna liczba modeli embedding znacznie się zmniejszyła. Poniższe porównanie obejmuje modele najważniejsze dla systemów RAG w bankowości i usługach finansowych na początku 2025 roku.

    Nie istnieje uniwersalny zwycięzca we wszystkich scenariuszach biznesowych. Twoja decyzja zależy od zestawu języków, które musisz obsłużyć, budżetu opóźnień oraz dostępnej infrastruktury, od tego, czy lokalna inferencja w ogóle jest dla ciebie możliwa, oraz od wielkości luki terminologicznej pomiędzy twoją dziedziną a danymi, na których zostały wytrenowane modele ogólnego zastosowania — to właśnie ta luka decyduje o tym, czy dopracowanie modelu jest warte wysiłku.

    Asymetryczne odzyskiwanie danych i informowanie modelu o rodzaju wprowadzanego wejścia

    Jedną z różnic, której często nie dostrzegają zespoły, jest asymetryczne odzyskiwanie danych. Podczas odzyskiwania fragmentów tekstowych zapytanie i fragment, z którym jest ono porównywane, są strukturalnie bardzo różne. Zapytania zazwyczaj są krótkie, sformułowane jako pytania i często pozbawione dużej części słownictwa występującego we właściwej odpowiedzi. Fragmenty tekstowe natomiast są dłuższe, przedstawiane jako fakty i bogate w terminy specjalistyczne.

    Pewne modele są zaprojektowane tak, aby bezpośrednio rozpoznawać tę asymetrię. Modele E5 dodają przedrostek „query:” lub „passage:” do tekstu wejściowego, dzięki czemu model wie, jaką rolę ma pełnić. Narzędzie Embed v3 od Cohere umożliwia to za pomocą parametru input_type, którego dopuszczalne wartości to „search_query”, „search_document”, „classification” oraz „clustering”.

    Użycie niewłaściwego typu danych wejściowych podczas indeksowania lub wyszukiwania w tajemnicy pogarsza wyniki pomiaru podobieństwa w sposób trudny do wyśledzenia aż do źródła problemu. Jeśli umieścisz dokument jako zapytanie, otrzymasz wektor dostosowany do geometrii zapytania, a nie tekstu. Proces wydobywania informacji nie kończy się całkowitą porażką – po prostu traci precyzję, co łatwo przeoczyć podczas rutynowych testów.

    import cohere
    from typing import List
    
    co = cohere.Client(api_key="your_api_key")
    
    def embed_documents(chunks: List[str]) -> List[List[float]]:
        """Embed document chunks for indexing with explicit document input type."""
        response = co.embed(
            texts=chunks,
            model="embed-english-v3.0",
            input_type="search_document",
            embedding_types=["float"]
        )
        return response.embeddings.float
    
    def embed_query(query: str) -> List[float]:
        """Embed a search query with explicit query input type."""
        response = co.embed(
            texts=[query],
            model="embed-english-v3.0",
            input_type="search_query",
            embedding_types=["float"]
        )
        return response.embeddings.float[0]
    

    Wektory rzadkie: Gdzie dopasowanie słów kluczowych przewyższa wyszukiwanie semantyczne

    Embeddingi gęste oddają znaczenie. Z kolei reprezentacje rzadkie pokazują, które terminy są obecne oraz jak silnie powinny być uwzględniane. W przypadku znacznej części typów zapytań w systemach RAG korporacyjnych, wyszukiwanie rzadkie przewyższa wyraźnie wyszukiwanie gęste, a w większości systemów produkcyjnych połączenie obu metod daje lepsze wyniki niż użycie tylko jednej z nich.

    BM25 jako niezawodna baza odniesienia

    BM25 pozostaje standardowym podejściem do wyszukiwania opartego na słowach kluczowych. Ocenia trafność używając częstotliwości występowania terminu w dokumencie, rzadkości tego terminu we całym korpusie oraz współczynnika normalizacji uwzględniającego długość dokumentu. Nie wymaga trenowania modelu, nie ma potrzeby GPU ani żadnych wywołań API do embeddingów.

    Rozważmy zapytanie typu „CRD-EU-047 approval authority threshold”. BM25 wysoko sklasyfikuje każdy fragment zawierający te dokładne terminy. Z kolei model gęsty może nie pokazać takiego fragmentu, chyba że jego korpus treningowy przypadkowo nawiązał silną związek pomiędzy tym konkretnym kodem polityki a pojęciem uprawnień do zatwierdzania.

    from rank_bm25 import BM25Okapi
    import re
    from typing import List, Tuple
    
    def tokenise(text: str) -> List[str]:
        """Simple whitespace and punctuation tokeniser for BM25."""
        return re.findall(r'\b\w+\b', text.lower())
    
    class BM25Index:
        def __init__(self, documents: List[str]):
            self.documents = documents
            tokenised = [tokenise(doc) for doc in documents]
            self.bm25 = BM25Okapi(tokenised)
    
        def search(self, query: str, top_k: int = 10) -> List[Tuple[int, float]]:
            """Return (doc_index, score) pairs for the top_k results."""
            tokens = tokenise(query)
            scores = self.bm25.get_scores(tokens)
            ranked = sorted(enumerate(scores), key=lambda x: x[1], reverse=True)
            return ranked[:top_k]
    

    Dense retrieval dobrze radzi sobie z wykrywaniem podobieństwa koncepcyjnego, natomiast sparse retrieval skutecznie znajduje dokładne dopasowania terminów i identyfikatorów. Połączenie obu metod — hybrydowy retrieval — przynosi szczególne korzyści w przypadku korpusów bankowych, gdzie język regulacyjny jest precyzyjny i bogaty w identyfikatory.

    W korpusach opartych na stabilnym, dokładnie zdefiniowanym języku regulacji, sam BM25 często osiąga podobny poziom dokładności do dense retrieval przy wąskich zapytaniach faktograficznych, przy jednoczesnym znacznie mniejszym obciążeniu infrastruktury. Jego główną słabością jest problem synonimii: zapytanie zawierające „EDD requirements” nie odnajdzie fragmentu tekstu, w którym występuje tylko „Enhanced Due Diligence requirements”, chyba że dokładna fraza się w nim powtórzy.

    SPLADE: Rzadkie wektory, które uczą się rozszerzania słownictwa

    SPLADE (Sparse Lexical and Expansion Model) stanowi kompromis pomiędzy prostym dopasowywaniem słów kluczowych a pełnym, gęstym wyszukiwaniem. Podczas indeksowania wykorzystywany jest zasłonięty model językowy w celu wzbogacenia zarówno dokumentów, jak i zapytań o semantycznie powiązany słownictwo, które niekoniecznie występuje w oryginalnej formułacji. Rezultatem jest wektor rzadki, którego wymiary odpowiadają poszczególnym tokenom słownictwa, przypisane im wagi zależą od tego, jak ważny jest dany token dla wprowadzonego tekstu.

    Zatem fragment zakodowany metodą SPLADE, omawiający wymagania EDD, może mieć większą wagę dla takich terminów jak „weryfikacja klienta”, „ocena ryzyka” i „potwierdzenie tożsamości”, nawet jeśli żadna z tych dokładnych fraz nie występuje w tekście źródłowym. Takie rozszerzenie poprawia wyniki wyszukiwania w zapytaniach opartych na synonimach, zachowując jednocześnie efektywność i zrozumiałość charakterystyczne dla reprezentacji rzadkich, przyjaznych indeksom odwróconym.

    Kompromisem jest zwiększony koszt inferencji podczas tworzenia indeksu oraz większe zapotrzebowanie na przestrzeń przechowywania w porównaniu z zwykłym algorytmem BM25. Jednak w korpusach usług finansowych, gdzie ten sam pojęcie polityki jest opisywane inaczej w różnych jurysdykcjach i przy różnych wersjach dokumentów, takie rozszerzenie może znacząco poszerzyć zakres dostępnych wyników wyszukiwania.

    Matryoshka Embeddings: regulowana wielkość wektorów dla kontroli kosztów

    Nauka reprezentacji typu Matryoshka (MRL) tworzy wektory embeddingowe, w których pierwsze N wymiarów już stanowi kompletową, samodzielną reprezentację danych wejściowych — dodatkowe wymiary dodają coraz bardziej szczegółowe informacje, zamiast zastępować to, co było wcześniej.

    Ta technika zawdzięcza swoją nazwę rosyjskim lalekom-matrioszkom: wektor Matryoshki o 1536 wymiarach zawiera w swoich pierwszych 256 pozycjach w pełni funkcjonalną reprezentację o 256 wymiarach, w pierwszych 512 pozycjach — reprezentację o 512 wymiarach i tak dalej.

    Familia modeli text-embedding-3 firmy OpenAI obsługuje to bezpośrednio za pomocą parametru wymiarów.

    from openai import OpenAI
    from typing import List
    
    client = OpenAI()
    
    def embed_with_matryoshka(
        texts: List[str],
        dimensions: int = 256,
        model: str = "text-embedding-3-large"
    ) -> List[List[float]]:
        """
        Embed texts at a specified sub-dimension.
        Lower dimensions reduce storage and index cost.
        Measure retrieval quality drop before committing to a dimension.
        """
        response = client.embeddings.create(
            input=texts,
            model=model,
            dimensions=dimensions
        )
        return [item.embedding for item in response.data]
    

    Wektory embeddingowe typu Matryoshka zawierają w sobie coraz bardziej szczegółowe reprezentacje: pierwsze 256 wymiarów już dostarcza funkcjonalnej reprezentacji przydatnej do wyszukiwania, a każdy kolejny poziom zwiększa precyzję semantyczną przy proporcjonalnym wzroście zużycia pamięci.

    Prawdziwą korzyścią w produkcji jest możliwość dostosowywania kompromisu pomiędzy przestrzenią przechowywania a jakością według potrzeb, bez konieczności ponownego szkolenia modelu czy budowania indeksu od zera. W przypadku korpusu polityk bankowych można przeprowadzić testy wydajności przy 256, 512, 1024 i 3072 wymiarach i stwierdzić, że 512 wymiarów zapewnia 97% pełnego odzyskiwania danych, przy użyciu jedynie 17% przestrzeni przechowywania potrzebnej do przechowywania pełnych wektorów.

    W praktyce ten kompromis rzadko jest tak klarowny, jak wygląda na wykresach porównawczych. Subtelne różnice w danej dziedzinie – szczególnie pomiędzy blisko spokrewnionymi koncepcjami regulacyjnymi – często znajdują się właśnie w wyżcym wymiarze wektora. Przed zatwierdzeniem zmniejszonej liczby wymiarów do użycia w produkcji przeprowadź testy na własnym korpusie i rzeczywistych wzorcach zapytań.

    Kompresowanie wektorów bez znacznego straty dokładności

    Standardowe embeddingi przechowują każdą wymiar jako liczbę zmiennoprzecinkową 32-bitową. Gdy zwiększymy to do miliona fragmentów dokumentów, z po 1536 wymiarami każdy, otrzymamy około 6 GB surowych wektorów, zanim zostaną dodane koszty indeksowania. Na poziomie przedsiębiorstw taka ilość pamięci i związane z nią koszty przestają być błędem zaokrąglenia.

    Kwantyzacja rozwiązuje ten problem poprzez zmniejszenie liczby bitów używanych do reprezentacji każdej wymiaru. W praktyce dominują trzy techniki: kwantyzacja skalarna (przekształcanie float32 na int8), kwantyzacja binarna (przekształcanie float32 na jeden bit) oraz kwantyzacja iloczynowa (skompresowanie każdego wektora do krótszego kodu).

    Kwantyzacja skalarna: int8

    Kwantyzacja skalarna mapuje ciągły zakres float32 na 256 dyskretnych wartości całkowitych. Każda wymiar zmniejsza się z 4 bajtów do 1, co redukuje zużycie pamięci o 75%. Ponieważ modele embeddingu wysokowymiarowe rozpraszają informacje równomiernie między wieloma wymiarami, żaden pojedynczy wymiar sam w sobie nie ma dużego znaczenia, więc dokładność utracona przez to zaokrąglenie jest zazwyczaj niewielka.

    import numpy as np
    from typing import Tuple
    
    def quantise_to_int8(
        embeddings: np.ndarray
    ) -> Tuple[np.ndarray, float, float]:
        """
        Scalar quantisation to int8.
        Returns quantised array plus the scale and zero_point needed for dequantisation.
        """
        min_val = embeddings.min()
        max_val = embeddings.max()
        scale = (max_val - min_val) / 255.0
        zero_point = -round(min_val / scale)
        quantised = np.clip(
            np.round(embeddings / scale) + zero_point,
            0, 255
        ).astype(np.uint8)
        return quantised, scale, zero_point
    
    def dequantise_from_int8(
        quantised: np.ndarray,
        scale: float,
        zero_point: float
    ) -> np.ndarray:
        """Reconstruct approximate float32 embeddings from int8."""
        return ((quantised.astype(np.float32) - zero_point) * scale)
    

    Kwantyzacja binarna

    Kwantyzacja binarna idzie jeszcze dalej, sprowadzając każdy wymiar do pojedynczego bitu, który po prostu rejestruje, czy oryginalna wartość float była dodatnia, czy ujemna. Dzięki temu zużycie pamięci zmniejsza się o około 97% w porównaniu z float32. Ponieważ reprezentacja nie jest już ciągła, podobieństwo mierzy się odległością Hamminga zamiast podobieństwem kosinowym.

    Ta technika działa najlepiej w modelach, których rozkłady wyników są naturalnie dobrze zrównoważone, tak że dla danego wejścia mniej więcej połowa wymiarów znajduje się po obu stronach zera. Jeśli wymiary modelu są nierównoważone, a nie zrównoważone, kwantyzacja binarna powoduje znacznie większą utratę jakości. Cohere stworzyło Embed v3, mając tę ograniczającą się pod uwagę, a opublikowana ocena tego modelu przez Anthropic wskazuje na spadek jakości wyszukiwania poniżej 1%, przy jednoczesnym zmniejszeniu potrzebnych zasobów przechowywania o 97% w ich zestawach testowych. Traktuj tę wartość jako punkt wyjścia, a nie gwarancję, i zweryfikuj ją na własnym korpusie przed poleganiem na niej.

    import numpy as np
    
    def quantise_to_binary(embeddings: np.ndarray) -> np.ndarray:
        """
        Binary quantisation: positive dimensions become 1, negative become 0.
        Packs 8 dimensions per byte using numpy packbits.
        """
        binary_matrix = (embeddings > 0).astype(np.uint8)
        return np.packbits(binary_matrix, axis=1)
    
    def hamming_similarity(
        query_binary: np.ndarray,
        corpus_binary: np.ndarray
    ) -> np.ndarray:
        """Compute normalised Hamming similarity for binary embeddings."""
        n_bits = corpus_binary.shape[1] * 8
        xor = np.bitwise_xor(
            query_binary,
            corpus_binary
        )
        hamming_distances = np.unpackbits(xor, axis=1).sum(axis=1)
        return 1.0 - (hamming_distances / n_bits)
    

    Dopasowywanie modelu do specyfiki domeny RAG

    Dopasowywanie modelu jest właściwym rozwiązaniem, gdy potwierdzisz, że embeddery uniwersalne rzeczywiście nie radzą sobie z twoimi danymi. Celem jest nauczenie modelu, że słownictwo, skróty oraz powiązania konceptualne specyficzne dla twojej dziedziny znajdują się blisko siebie w przestrzeni semantycznej.

    Dopracowywanie modelu nie zawsze jest konieczne i nie zawsze stanowi właściwe rozwiązanie. Jeśli problemy z wyszukiwaniem wynikają z niewłaściwego dzielenia tekstu na fragmenty, jak omówiono wcześniej w tej serii, dostosowanie modelu embedding nie pomoże. Jeśli przyczyną są ustawienia procesu ponownego sortowania wyników lub sposób tworzenia zapytań, dopracowywanie modelu skupia się zupełnie na niewłaściwej warstwie systemu. Zanim zainwestujesz w to zasoby, przeanalizuj błędy wyszukiwania według typu zapytania, aby ustalić dokładne miejsce problemu.

    Kiedy ogólne modele embedding zawodzą

    W szczególności w bankowości, w kontekście RAG, kilka powtarzających się wzorców błędów uzasadnia inwestycję w dopracowywanie modelu:

    Skróty specyficzne dla danej dziedziny są błędnie interpretowane. Model uniwersalny może łączyć „NPA” z National Parks Association zamiast z pojęciem aktywów niewydających dochodu, a „KYC” może być tylko słabo powiązane z koncepcjami zgodności i procesu onboardingu, które faktycznie dominują w zapytaniach bankowych.

    Znaki powiązań pomiędzy powiązanymi koncepcjami w różnych dokumentach giną. Poszukiwanie „facility restructuring provisions” powinno ujawnić tekst polityki dotyczący „frameworki modyfikacji kredytów”, ale model szkoleny głównie na ogólnym treściach internetowych może nigdy nie napotkać tych sformułowań w wystarczająco bliskim kontekście, by móc nawiązać takie połączenie.

    Kody regulacyjne i identyfikatory nie są wystarczająco doceniane. Numery wersji polityk, kody regulacji oraz oznaczenia jurysdykcji powinny w znaczący sposób wpływać na ranking, jednak modele embeddingowe zazwyczaj traktują je jako tokeny o niskiej wartości, które nie przekazują większych informacji semantycznych.

    Progi liczbowe tracą swój kontekst regulacyjny. Wyrażenie takie jak „10 milionów EUR” umieszczone samodzielnie nie powinno automatycznie odpowiadać zapytaniu dotyczącemu „uprawnień do zatwierdzania dużych ryzyk” – taka relacja powstaje tylko wtedy, gdy model został wyszkolony na danych specyficznych dla danego obszaru, które łączą tę liczbę z jej znaczeniem regulacyjnym.

    Budowanie par treningowych na podstawie danych specyficznych dla danego obszaru

    Dokładna kalibracja modeli sentence-transformers przy użyciu celu kontrastywnego zależy od par pozytywnych: przykładów łączących zapytanie z fragmentem tekstu, który model powinien nauczyć się traktować jako powiązany. Przykłady negatywne mogą być wybrane ręcznie lub automatycznie pobrane z otaczającego korpusu.

    W kontekście RAG w bankowości te pary pozytywne można zebrać z kilku praktycznych źródeł:

    Istniejące zestawy pytań i odpowiedzi już przygotowane przez zespoły ds. zgodności i kredytowania, w których każde pytanie jest powiązane z odpowiadającym mu fragmentem tekstu.

    Prymatyczna struktura dokumentów politycznych, w której nagłówek połączony z paragrafem poniżej tworzy gotowy przykład pozytywny.

    Zapisane zapytania analityków w połączeniu z fragmentami tekstu, które faktycznie zostały pobraane, gdy odpowiedź była poprawna.

    Zapytania wygenerowane maszynowo przez model językowy sztuczny dla każdego fragmentu tekstu, przy czym sam ten fragment służy jako pasaż pozytywny do porównania.

    Wśród tych metod generowanie syntetycznych zapytań jest zazwyczaj najbardziej praktycznym rozwiązaniem, gdy istnieje niewiele już oznaczonych danych.

    from openai import OpenAI
    import json
    from typing import List, Dict
    
    client = OpenAI()
    
    def generate_training_queries(
        chunk: str,
        chunk_metadata: Dict,
        n_queries: int = 3
    ) -> List[Dict]:
        """
        Generate synthetic query-passage pairs for fine-tuning.
        The chunk itself is the positive passage for each generated query.
        """
        prompt = f"""You are generating training data for a banking RAG system.
    Given the following policy passage, generate {n_queries} realistic questions
    that a credit analyst, compliance officer, or relationship manager might ask
    that this passage directly answers. Each question should use natural language
    and may use different terminology than the passage itself.
    
    Passage:
    {chunk}
    
    Return a JSON array of objects with keys "query" and "difficulty".
    Difficulty should be "narrow" (single fact) or "synthesis" (multiple facts).
    Return only the JSON array, no other text."""
    
        response = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": prompt}],
            response_format={"type": "json_object"}
        )
    
        try:
            result = json.loads(response.choices[0].message.content)
            queries = result.get("queries", result) if isinstance(result, dict) else result
            return [
                {
                    "query": q["query"],
                    "passage": chunk,
                    "document_id": chunk_metadata.get("document_id"),
                    "chunk_id": chunk_metadata.get("chunk_id"),
                    "difficulty": q.get("difficulty", "narrow")
                }
                for q in queries
            ]
        except (json.JSONDecodeError, KeyError):
            return []
    

    Szkolenie kontrastywne z stratą typu triplet

    Najskuteczniejszym celem szkolenia modeli embeddingowych skierowanych na wyszukiwanie jest uczenie kontrastywne, wykorzystujące albo negatywy w obrębie partii danych, albo celowo wybrane trudne negatywy. Sentence-transformers obsługuje ten model poprzez MultipleNegativesRankingLoss, który wykorzystuje każdy inny przykład w partii treningowej jako ukryty negatyw dla danej pary anchor-pozytywny.

    from sentence_transformers import SentenceTransformer, InputExample
    from sentence_transformers.losses import MultipleNegativesRankingLoss
    from torch.utils.data import DataLoader
    from typing import List, Dict
    import logging
    
    logging.basicConfig(level=logging.INFO)
    logger = logging.getLogger(__name__)
    
    def build_training_examples(
        pairs: List[Dict]
    ) -> List[InputExample]:
        """
        Convert query-passage pairs into InputExample objects.
        MultipleNegativesRankingLoss expects (anchor, positive) pairs.
        Negatives are sampled automatically from other items in the batch.
        """
        return [
            InputExample(texts=[pair["query"], pair["passage"]])
            for pair in pairs
            if pair.get("query") and pair.get("passage")
        ]
    
    def fine_tune_embedding_model(
        base_model_name: str,
        training_pairs: List[Dict],
        output_path: str,
        epochs: int = 3,
        batch_size: int = 16,
        warmup_steps: int = 100
    ) -> SentenceTransformer:
        """
        Fine-tune a sentence-transformers model on domain-specific query-passage pairs.
    
        base_model_name: HuggingFace model identifier or local path.
        training_pairs: List of dicts with "query" and "passage" keys.
        output_path: Directory to save the fine-tuned model.
        """
        model = SentenceTransformer(base_model_name)
        logger.info(f"Loaded base model: {base_model_name}")
        logger.info(f"Training on {len(training_pairs)} query-passage pairs")
    
        examples = build_training_examples(training_pairs)
        loader = DataLoader(examples, shuffle=True, batch_size=batch_size)
        loss = MultipleNegativesRankingLoss(model)
    
        total_steps = len(loader) * epochs
        logger.info(f"Training for {epochs} epochs, {total_steps} total steps")
    
        model.fit(
            train_objectives=[(loader, loss)],
            epochs=epochs,
            warmup_steps=warmup_steps,
            output_path=output_path,
            show_progress_bar=True,
            checkpoint_path=output_path,
            checkpoint_save_steps=len(loader)
        )
    
        logger.info(f"Fine-tuned model saved to: {output_path}")
        return model
    

    Wyniki benchmarkingu: korpus bankowy przed i po drobnej optymalizacji

    Rozważmy test wydajności przeprowadzony na korpusie polityki kredytowej przedsiębiorstw składającym się z 847 fragmentów pochodzących z dokumentów polityki, matryc zatwierdzeń, udoskonalonych procedur weryfikacji oraz wytycznych dotyczących zwalczania prania pieniędzy. Zbiór do oceny zawiera 120 zapytań należących do czterech kategorii: wąskie wyszukiwania faktograficzne, pytania z progiem dopuszczalnym, pytania wymagające wielu dowodów oraz pytania syntezy.

    Korzyści z doprecyzowania modelu na podstawie specyfiki danej dziedziny są najbardziej widoczne w zapytaniach typu „próg”, takich jak pytania o próg zatwierdzenia obowiązujący wobec klientów korporacyjnych o wysokim ryzyku w UE. To właśnie tutaj skróty bankowe oraz terminologia regulacyjna najbardziej odbiegają od tego, z czym model uniwersalny miał do czynienia podczas szkolenia wstępnego, dlatego uzupełnienie tej luki w słownictwie przynosi największy wzrost dokładności wyników. Zapytania oparte na wielu źródłach informacji oraz zapytania syntezy poprawiają się mniej pod wpływem samego doprecyzowania modelu, ale znacznie bardziej, gdy dodano do nich hybrydowy mechanizm wyszukiwania.

    Traktuj te liczby jako przykłady tego, co może osiągnąć dobrze przeprowadzony projekt dopasowywania na odpowiednio oznaczonym zbiorze danych bankowych, a nie jako obietnicę. Twoja własna struktura korpusu, mieszanka zapytań oraz dokładność oznaczania wpłyną na wyniki. Najważniejsze jest rozbicie jakości wyszukiwania według typu zapytania, zamiast podawania jednego łącznego wyniku, ponieważ każdy typ zapytania ma tendencję do awarii z różnych powodów.

    Kwestie wydajności przy masowym używaniu embeddingów

    Przesyłanie 100 000 fragmentów przez API do generowania embeddingów, po jednym zapytaniu na raz, jest zarówno wolne, jak i kosztowne. Pipeline’y do generowania embeddingów klasy produkcyjnej zamiast tego przetwarzają dane grupowo, dzięki czemu mogą zwiększyć wydajność, przestrzegać ograniczeń szybkości, skutecznie odbudowywać się po awariach i zapewnić deterministyczne generowanie wyników.

    Przetwarzanie grupowe za pomocą API dostawców

    Interfejs embeddingów OpenAI umożliwia przetwarzanie do 2 048 danych w jednej prośbie. Interfejs Embed firmy Cohere obsługuje maksymalnie 96 tekstów na zapytanie, chyba że użyje się ich dedykowanej API do przetwarzania partii większej liczby danych. Uruchamianie procesów inferencji lokalnie za pomocą sentence-transformers zapewnia możliwość konfiguracji wielkości partii, ograniczonej jedynie dostępną pamięcią GPU.

    import time
    import logging
    from typing import List, Optional
    from openai import OpenAI, RateLimitError, APIError
    
    logger = logging.getLogger(__name__)
    client = OpenAI()
    
    def embed_in_batches(
        texts: List[str],
        model: str = "text-embedding-3-large",
        batch_size: int = 512,
        max_retries: int = 3,
        retry_delay: float = 2.0,
        dimensions: Optional[int] = None
    ) -> List[List[float]]:
        """
        Embed a large list of texts using batched API calls with retry logic.
    
        texts: Pre-chunked text strings. Caller is responsible for ensuring
               no text exceeds the model's token limit.
        batch_size: Number of texts per API call. Stay well below the API limit
                    to avoid hitting per-request token limits.
        dimensions: Optional Matryoshka dimension reduction for supported models.
        """
        all_embeddings: List[List[float]] = []
        total_batches = (len(texts) + batch_size - 1) // batch_size
    
        for batch_idx in range(0, len(texts), batch_size):
            batch = texts[batch_idx: batch_idx + batch_size]
            current_batch = batch_idx // batch_size + 1
            logger.info(f"Embedding batch {current_batch}/{total_batches} "
                        f"({len(batch)} texts)")
    
            kwargs = {
                "input": batch,
                "model": model
            }
            if dimensions is not None:
                kwargs["dimensions"] = dimensions
    
            attempt = 0
            while attempt < max_retries:
                try:
                    response = client.embeddings.create(**kwargs)
                    # Preserve input order: API returns items sorted by index
                    sorted_items = sorted(response.data, key=lambda x: x.index)
                    all_embeddings.extend([item.embedding for item in sorted_items])
                    break
    
                except RateLimitError:
                    attempt += 1
                    wait = retry_delay * (2 ** attempt)
                    logger.warning(f"Rate limit hit on batch {current_batch}. "
                                   f"Waiting {wait:.1f}s before retry {attempt}/{max_retries}")
                    time.sleep(wait)
    
                except APIError as e:
                    attempt += 1
                    logger.error(f"API error on batch {current_batch}: {e}. "
                                 f"Retry {attempt}/{max_retries}")
                    if attempt >= max_retries:
                        raise
                    time.sleep(retry_delay)
    
        logger.info(f"Embedding complete. Total vectors: {len(all_embeddings)}")
        return all_embeddings
    

    Uruchamianie procesów inferencji lokalnie za pomocą sentence-transformers

    Część organizacji napotyka ograniczenia związane z lokalizacją danych, które uniemożliwiają wysyłanie dokumentów politycznych do zewnętrznej API. W takich przypadkach rozwiązaniem jest lokalna inferencja przy użyciu sentence-transformers.

    from sentence_transformers import SentenceTransformer
    import numpy as np
    from typing import List, Optional
    import logging
    
    logger = logging.getLogger(__name__)
    
    class LocalEmbeddingPipeline:
        """
        Production-ready local embedding pipeline using sentence-transformers.
        Suitable for data-residency-constrained banking environments.
        """
    
        def __init__(
            self,
            model_name_or_path: str,
            device: str = "cpu",
            batch_size: int = 64,
            normalise: bool = True
        ):
            self.model = SentenceTransformer(model_name_or_path, device=device)
            self.batch_size = batch_size
            self.normalise = normalise
            self.device = device
            logger.info(f"Loaded model: {model_name_or_path} on {device}")
    
        def embed(
            self,
            texts: List[str],
            show_progress: bool = True
        ) -> np.ndarray:
            """
            Embed a list of texts. Returns an (N, D) numpy array.
            Normalises to unit length if normalise=True (required for cosine similarity).
            """
            embeddings = self.model.encode(
                texts,
                batch_size=self.batch_size,
                show_progress_bar=show_progress,
                normalize_embeddings=self.normalise,
                convert_to_numpy=True
            )
            logger.info(f"Embedded {len(texts)} texts. "
                        f"Output shape: {embeddings.shape}")
            return embeddings
    
        def embed_query(self, query: str) -> np.ndarray:
            """Embed a single query. Returns a 1D array."""
            return self.embed([query], show_progress=False)[0]
    

    Sprawdzanie długości tokenów przed ich embedowaniem

    Jeśli fragment tekstowy przekracza limit tokenów modelu, jest on skracany bez żadnego ostrzeżenia. W korpusie zasad takie ciche skracanie może usunąć dokładnie tę klauzulę lub wartość numeryczną, która sprawiła, że fragment ten w ogóle zasługiwał na pobranie. Walidacja liczby tokenów przed embedowaniem pozwala wykryć ten problem, zanim dotrze on do indeksu.

    from transformers import AutoTokenizer
    from typing import List, Tuple
    import logging
    
    logger = logging.getLogger(__name__)
    
    def validate_chunk_lengths(
        chunks: List[str],
        model_name: str,
        max_tokens: int,
        truncation_strategy: str = "warn"
    ) -> Tuple[List[str], List[int]]:
        """
        Validate that all chunks are within the model's token limit.
    
        truncation_strategy:
            "warn"  - Log a warning for oversized chunks and include them (will be truncated by model).
            "skip"  - Remove oversized chunks and return only valid ones.
            "raise" - Raise ValueError on the first oversized chunk.
    
        Returns (validated_chunks, oversized_indices).
        """
        tokeniser = AutoTokenizer.from_pretrained(model_name)
        oversized = []
    
        for idx, chunk in enumerate(chunks):
            token_count = len(tokeniser.encode(chunk, add_special_tokens=True))
            if token_count > max_tokens:
                oversized.append(idx)
                msg = (f"Chunk {idx} has {token_count} tokens, "
                       f"exceeds model limit of {max_tokens}. "
                       f"First 80 chars: {chunk[:80]!r}")
                if truncation_strategy == "raise":
                    raise ValueError(msg)
                else:
                    logger.warning(msg)
    
        if truncation_strategy == "skip" and oversized:
            valid = [c for i, c in enumerate(chunks) if i not in set(oversized)]
            logger.info(f"Removed {len(oversized)} oversized chunks. "
                        f"{len(valid)} chunks remain.")
            return valid, oversized
    
        return chunks, oversized
    

    Dołączanie metadanych i informacji o pochodzeniu do embedów

    Sam wektor embedingu nie wystarcza sam w sobie, aby regulowany system RAG funkcjonował prawidłowo. Każdy wektor wymaga dołączonych strukturalnych metadanych, dzięki którym etapy wyszukiwania, ponownego sortowania i generowania mogą potwierdzić źródło treści, egzekwować uprawnienia dostępu, ograniczać wyniki według jurysdykcji oraz odsyłać do autorytatywnego dokumentu źródłowego.

    Wracając do scenariusza propozycji kredytu w wysokości 12 milionów euro, każdy wpleciony fragment powinien zawierać co najmniej pola pokazane tutaj:

    from dataclasses import dataclass, field
    from typing import Optional, List
    import uuid
    
    @dataclass
    class EmbeddedChunk:
        """
        Production embedding record for a banking policy RAG system.
        The vector enables retrieval. The metadata enables everything else.
        """
        # Vector
        vector: List[float]
        vector_dimensions: int
        embedding_model: str
        embedding_model_version: str
    
        # Content
        text: str
        content_type: str          # "narrative", "table_row", "proposition", "image_description"
    
        # Provenance
        document_id: str
        document_version: str      # e.g. "7.2"
        policy_id: Optional[str]   # e.g. "CRD-EU-047"
        jurisdiction: Optional[str] # e.g. "EU"
        effective_date: Optional[str]
    
        # Chunk structure
        chunk_id: str = field(default_factory=lambda: str(uuid.uuid4()))
        parent_id: Optional[str] = None
        section: Optional[str] = None
        page_number: Optional[int] = None
        source_artifact_path: Optional[str] = None  # path to original image/table
    
        # Access control
        classification: str = "INTERNAL"            # "PUBLIC", "INTERNAL", "CONFIDENTIAL"
        permitted_roles: List[str] = field(default_factory=list)
    
        # Indexing
        indexed_at: Optional[str] = None
        indexing_pipeline_version: Optional[str] = None
    

    Spójne przechowywanie tych metadanych na każdym etapie procesu, od dzielenia na fragmenty, przez wplecanie, aż po indeksowanie wektorowe, to nie tylko dobra praktyka. W regulowanym środowisku bankowym uzyskanie technicznie poprawnej odpowiedzi opartej na przestarzałej wersji polityki stanowi naruszenie wymogów regulacyjnych. Zadaniem wektora jest znalezienie fragmentu; zadaniem metadanych jest potwierdzenie, że fragment pochodzi z właściwej, aktualnej wersji źródła.

    Ocena jakości wplecania

    Tabela rankingów publicznych, takie jak MTEB, przedstawiają wyniki wyszukiwania o zastosowaniu ogólnym w szerokim zakresie zbiorów danych akademickich. Te liczby pomagają wyeliminować modele, które wyraźnie słabo radzą sobie z zadaniem. Są jednak niewystarczające, gdy chodzi o wybór najlepszego modelu do specjalistycznego korpusu, takiego jak wewnętrzna baza polityk bankowych.

    Jedynym miernikiem, który naprawdę ma znaczenie, jest sposób, w jaki model radzi sobie z twoimi własnymi dokumentami przy użyciu twoich zapytań, oceniany na podstawie Twoich własnych kryteriów istotności.

    Budowanie zestawu do oceny wyszukiwania

    Zestaw testowy do wyszukiwania stworzony dla procesu embeddingów w bankowości RAG musi obejmować kilka typów zapytań:

    Szczegółowe wyszukiwania faktograficzne, które odnoszą się do jednego autorytatywnego fragmentu informacji, np. pytanie o to, jak często klienci korporacyjni o wysokim ryzyku muszą przechodzić coroczną ocenę.

    Pytania oparte na progu, które łączą określony warunek liczbowy z przypisaną do niego zasadą zarządzania, na przykład pytanie o to, która władza zatwierdzająca jest wymagana, gdy wartość przedsiębiorstwa w UE przekracza 10 milionów euro.

    Pytania wymagające wielu źródeł informacji, przy których pełna odpowiedź zależy od połączenia więcej niż jednego fragmentu danych, na przykład pytanie o to, jakie kontrole muszą zostać przeprowadzone, zanim można w ogóle złożyć wniosek o kredyt korporacyjny o wysokim ryzyku.

    Pytania syntetyczne, które czerpią treść z kilku sekcji jednocześnie, na przykład pytanie o pełny opis ram kontroli AML regulujących udzielanie kredytów korporacyjnym o wysokim ryzyku.

    Pytania międzydokumentowe, istotne tam, gdzie polityki odnoszą się do siebie w różnych dokumentach.

    import numpy as np
    from typing import List, Dict, Set
    
    def recall_at_k(
        retrieved_ids: List[str],
        relevant_ids: Set[str],
        k: int
    ) -> float:
        """
        Compute Recall@k for a single query.
        relevant_ids is the ground truth set of chunk identifiers.
        retrieved_ids is the ordered list of retrieved chunk identifiers.
        """
        if not relevant_ids:
            return 0.0
        top_k_retrieved = set(retrieved_ids[:k])
        return len(top_k_retrieved & relevant_ids) / len(relevant_ids)
    
    def mean_reciprocal_rank(
        retrieved_ids: List[str],
        relevant_ids: Set[str]
    ) -> float:
        """Compute MRR for a single query."""
        for rank, chunk_id in enumerate(retrieved_ids, start=1):
            if chunk_id in relevant_ids:
                return 1.0 / rank
        return 0.0
    
    def evaluate_embedding_model(
        model_name: str,
        evaluation_queries: List[Dict],
        corpus_chunks: List[Dict],
        k_values: List[int] = [1, 5, 10, 20]
    ) -> Dict:
        """
        Evaluate an embedding model on a labelled retrieval dataset.
    
        evaluation_queries: List of dicts with "query" and "relevant_chunk_ids" keys.
        corpus_chunks: List of dicts with "chunk_id" and "text" keys.
        Returns per-query-type and aggregate retrieval metrics.
        """
        from sentence_transformers import SentenceTransformer
    
        model = SentenceTransformer(model_name)
    
        corpus_texts = [c["text"] for c in corpus_chunks]
        corpus_ids = [c["chunk_id"] for c in corpus_chunks]
        corpus_embeddings = model.encode(corpus_texts, normalize_embeddings=True)
    
        results_by_type: Dict[str, List] = {}
        all_recall: Dict[int, List[float]] = {k: [] for k in k_values}
        all_mrr: List[float] = []
    
        for query_item in evaluation_queries:
            query = query_item["query"]
            relevant = set(query_item["relevant_chunk_ids"])
            query_type = query_item.get("query_type", "unspecified")
    
            query_embedding = model.encode(query, normalize_embeddings=True)
            scores = corpus_embeddings @ query_embedding
            ranked_indices = np.argsort(scores)[::-1]
            retrieved = [corpus_ids[i] for i in ranked_indices]
    
            mrr = mean_reciprocal_rank(retrieved, relevant)
            all_mrr.append(mrr)
    
            for k in k_values:
                r = recall_at_k(retrieved, relevant, k)
                all_recall[k].append(r)
    
            if query_type not in results_by_type:
                results_by_type[query_type] = {"mrr": [], "recall": {k: [] for k in k_values}}
            results_by_type[query_type]["mrr"].append(mrr)
            for k in k_values:
                results_by_type[query_type]["recall"][k].append(
                    recall_at_k(retrieved, relevant, k)
                )
    
        aggregate = {
            "model": model_name,
            "n_queries": len(evaluation_queries),
            "mrr": float(np.mean(all_mrr)),
            "recall": {k: float(np.mean(all_recall[k])) for k in k_values}
        }
    
        per_type = {
            qt: {
                "mrr": float(np.mean(data["mrr"])),
                "recall": {k: float(np.mean(data["recall"][k])) for k in k_values},
                "n_queries": len(data["mrr"])
            }
            for qt, data in results_by_type.items()
        }
    
        return {"aggregate": aggregate, "by_query_type": per_type}
    

    Metryki pobierania danych nie powinny być oceniane w oderwaniu od tego, jak wpływają na jakość ostatecznej odpowiedzi. Załóżmy, że wskaźnik Recall@5 poprawia się o trzy punkty, ale liczba niemal identycznych fragmentów pobieranych rośnie o 30 procent — taka kompromis może w rzeczywistości nie pomóc modelowi językowemu, ponieważ dostarczenie mu trzech niemal identycznych tekstów zamiast jednego przydatnego nie dodaje żadnych rzeczywistych dowodów.

    Prawidłowym podejściem jest ocena całego procesu, od początkowej zapytania aż po ostateczną odpowiedź przedstawianą użytkownikowi. Rola modelu embeddingowego ogranicza się do umieszczenia odpowiednich dowodów w oknie kontekstowym. To, czy te dowody doprowadzą do odpowiedzi zarówno dokładnej, jak i zgodnej z wymogami, zależy od każdego kolejnego etapu.

    Kanał embeddingowy jako infrastruktura

    Gdy fragment skończy przetwarzanie w pipeline embeddingu, musi wyjść po drugiej stronie z dołączonym wektorem, w pełni uzupełnionymi metadanymi oraz stabilnym identyfikatorem, który umożliwi późniejsze odzyskanie, aktualizację lub usunięcie tego fragmentu bez uszkodzenia rekordów znajdujących się obok niego w indeksie.

    To element infrastruktury, a nie jednorazowy skrypt. Pipeline embeddingu klasy produkcyjnej przeznaczone do zastosowań bankowych podlegających regulacjom wymagają następujących elementów:

    • Idempotencja. Jeśli fragment zostanie ponownie włączony do procesu embeddingu z powodu zmiany wersji modelu, powinien nadpisać istniejący rekord, a nie stworzyć duplikat.
  • Śledzenie wersji. Za każdym razem, gdy zmienia się model embeddingowy, musisz albo odbudować indeks od zera, albo dokładnie podzielić go według wersji modelu. Pozwalanie wektorom z różnych modeli na współistnienie w tym samym indeksie daje wyniki podobieństwa, którym nie można ufać.
  • Przenoszenie kontroli dostępu. Jeśli fragment został oznaczony jako CONFIDENTIAL podczas przetwarzania, to oznaczenie musi przetrwać proces embeddingu i dotrzeć do indeksu wektorowego nienaruszone. Warstwa wyszukiwania musi je następnie uwzględnić.
  • Obserwowalność. Powinieneś rejestrować czas opóźnienia embeddingu dla każdej partii, liczbę tokenów, wskaźniki błędów API oraz awarie na poziomie fragmentów, przy czym wszystko to powinno być opatrzone ustrukturyzowanymi metadanymi. Skracanie danych, które w tajemnicy zmienia zachowanie wyszukiwania, to dokładnie ten rodzaj problemu, który powinien zostać wykryty przez system monitoringu, a nie pozostać niewidoczny.
  • Śledzenie kosztów. Włączanie kosztów za pośrednictwem API szybko się kumuluje, gdy działasz na dużą skalę. Rozdziel koszty według typu dokumentu i sesji przetwarzania, abyś mógł rzeczywiście ocenić wybór modelu i redukcję wymiarowości w kontekście rzeczywistych kosztów, a nie na podstawie domysłów.
  • Żadne z tych elementów nie jest opcjonalne w środowisku produkcyjnym. To właśnie one odróżniają pipeline, który funkcjonuje tylko w demonstracji, od takiego, który można obsługiwać, audytować i utrzymywać w czasie w regulowanym środowisku.

    Powrót do propozycji kredytu w wysokości 12 milionów euro

    Pierwotne pytanie menedżera relacji nie uległo zmianie: jaką władzę zatwierdzającą wymaga ta transakcja i które kontrole muszą zostać przeprowadzone, zanim będzie można ją złożyć?

    • Część 4 omawiała, jak projektować jednostki wyszukiwania, które zachowują te odpowiedzi w ich oryginalnej formie: klauzulę polityki EDD, wiersz macierzy zatwierdzeń określający uprawnienia GCC oraz diagram przepływu pracy przedstawiający kontrole przed wysłaniem.
    • W tej części przekształciliśmy te jednostki wyszukiwania w wektory nadające się do wyszukiwania, wykorzystując model sprawdzony w kontekście terminologii specyficznej dla bankowości, dostosowany tak, by rozumieć związek skrótów regulacyjnych z ich kontekstem przestrzegania przepisów, oraz wzbogacony o pełne metadane pochodzenia, które umożliwiają późniejsze potwierdzenie wersji polityki i jurysdykcji podczas wyszukiwania.

    Gdy przychodzi zapytanie, indeks wektorowy znajduje wiersz macierzy zatwierdzeń obejmujący ryzykowne pozycje o wartości powyżej 10 milionów euro, klauzulę EDD oraz diagram przedstawiający sekwencję kontroli — i zwraca je wraz z metadanymi potwierdzającymi, że wszystkie trzy odnoszą się do polisy CRD-EU-047, wersja 7.2, jurysdykcja UE, obowiązująca od 15 stycznia 2026 roku.

    To, co trafia do warstwy generowania, to dane dokładne, kompletne i śledzone.

    To jest różnica pomiędzy pipeline’em do embeddingów, który po prostu ładowa fragmenty do bazy danych wektorowej, a tym, który zachowuje wszystko, co jest potrzebne do uzyskiwania wiarygodnych i sprawdzalnych odpowiedzi.

    Zanim przejdziesz do indeksowania wektorowego: lista kontrolna

    Zanim umieścisz fragmenty w indeksie wektorowym, upewnij się co do następujących kwestii:

    • Czy parametr typu danych został poprawnie ustawiony w modelu embeddingowym zarówno przy wywołaniach do indeksowania, jak i przy wyszukiwaniu? Taka niezgodność powoli obniża dokładność odzyskiwania informacji.
    • Czy długość tokenów każdego fragmentu tekstu została sprawdzona przed jego embedowaniem? Ukryte skracanie zmienia to, co faktycznie reprezentuje tekst embeddedowany, przy czym w całym procesie nie pojawia się żaden błąd.
    • Czy każda rekordowa wartość wektorowa zawiera pełne informacje o pochodzeniu – wersję dokumentu, identyfikator polityki, jurysdykcję oraz datę wejścia w życie?
    • Czy model embeddingowy został rzeczywiście przetestowany na własnym korpusie danych i wzorcach zapytań, a nie wybrany wyłącznie na podstawie wyników publicznych testów?
    • Jeśli dostosowaliście model, czy macie dane dotyczące efektywności odzyskiwania informacji przed i po modyfikacjach, uzyskane na oddzielonym zbiorze zapytań?
  • Czy twoje decyzje dotyczące kwantyfikacji opierają się na wynikach z twojego własnego zestawu do oceny wyszukiwania, a nie na założeniach zaczerpniętych z opublikowanych benchmarków?
  • Czy proces jest idempotentny, uwzględnia wersje i dostępny do monitorowania, zgodnie z wymaganiami standardów obowiązujących w regulowanym systemie produkcyjnym?
  • Jeśli czegoś z tego brakuje, indeks wektorowy nie pomoże ci to wykryć — chętnie przechowuje wszystko, co mu podasz. Nic w bazie danych nie wskaże na wektor utworzony z skróconego fragmentu, zapytanie z niewłaściwym typem danych lub fragment włączony przy użyciu wersji modelu niezgodnej z resztą indeksu. Te braki same się nie ujawniają; pojawiają się później jako problemy z jakością wyszukiwania, które z zewnątrz wyglądają jak problemy z LLM.

    We wcześniejszej części tej serii, w punkcie 4, pokazano, jak przekształcić czyste dokumenty w jednostki wyszukiwania, zachowując przy tym ich strukturę. W tej części omówiono, jak przekształcić te jednostki wyszukiwania w wektory nadające się do wyszukiwania, przy jednoczesnym zachowaniu informacji o ich pochodzeniu.

    Następnie omówiono, w jaki sposób te wektory faktycznie łączą się z indeksem: intensywne wyszukiwanie wektorowe, rzadkie wyszukiwanie, podejścia hybrydowe, algorytmy przybliżonego znajdowania najbliższego sąsiada oraz filtrowanie metadanych, które decyduje, które wektory w ogóle mogą być brane pod uwagę podczas wyszukiwania.

    Literatura pokrewna

  • Małe, specjalistyczne modele cicho przewyższają gigantyczne LLM — Zobacz, jak model logiki z 3 miliardami parametrów pokonuje model o 120 miliardach parametrów w formalnym rozumowaniu na zwykłym sprzęcie, oraz dlaczego dopasowanie do zadania jest ważniejsze niż sama wielkość modelu.