Strona główna / Artykuły

Skrócenie zużycia tokenów w RAG o 67% dzięki Multivectorom i izolacji zdań w Qdrant

Pipeline RAG do analizy prawnej, który przy użyciu ColBERT w Qdrant ponownie sortuje dane, izoluje odpowiadające zdanie i poświęca część opóźnienia w celu uzyskania potwierdzonego skrócenia liczby tokenów o 67,1%.

Wydawca: REACTAPP.TOP8 min czytania1952 słów

Źródła: How Qdrant Reduced RAG Token Costs by 67% with Native ColBERT Reranking

Tokenizacja rachunków stała się poważnym problemem dla zespołów używających technologii generacji wzbogaconej o wyszukiwanie informacji. Duże firmy szukają sposobów na zmniejszenie zużycia zasobów, a wiele zespołów odkrywa, że nieoptymalizowany proces wykorzystania modeli LLM może kosztować więcej niż czas inżynieryjny, który miał zostać zaoszczędzony. Przegląd dokumentów prawniczych stanowi doskonałe środowisko do testów, ponieważ dokumenty są długie, odpowiedzi krótkie, a ich ilość duża.

Strona, której nikt nie potrzebował

Wyobraźmy sobie prawnika trzy tygodnie po rozpoczęciu procedury ustalania faktów w sporze kontraktowym, który musi przejrzeć stosy umów o świadczenie usług oraz dokumentów korporacyjnych. Pytanie jest konkretne: jak wysoki może być limit odszkodowania, gdy strona trzecia twierdzi o naruszeniu praw własności intelektualnej? Odpowiedź znajduje się w jednym zdaniu w czterdziestostrońcowym kontrakcie.

Standardowy system RAG zwraca trzy najważniejsze fragmenty, z których każdy zawiera od 500 do 1 000 tokenów; są to głównie opisy, definicje, warunki płatności oraz klauzule dotyczące prawa stosowanego, które nie mają żadnego związku z roszczeniami dotyczącymi własności intelektualnej. Jedyna przydatna zdanie znajduje się pośrodku. To irytuje czytelnika, a na większą skalę jest to kosztowne – przy tysiącach dokumentów w audycji sądowej dostawca modeli językowych jest opłacany za przetwarzanie ogromnych ilości zbędnych, standardowych tekstów.

Pojawiają się dwa pytania: jak duża część pobranego tekstu jest rzeczywiście konieczna? I czy można wyodrębnić dokładne zdanie bez użycia dodatkowej, skomplikowanej usługi do ponownego sortowania?

Żaden aspekt tego problemu z odpadami nie jest specyficzny dla prawa. Każdy zbiór długich, szablonowych dokumentów, takich jak polisy ubezpieczeniowe, umowy z dostawcami czy podręczniki dotyczące przestrzegania przepisów, ma tę samą strukturę: krótka odpowiedź otoczona stronami tekstu, które przy każdym wyszukiwaniu są włączane do zapytania. Koszt rośnie wraz z rozmiarem pobranych fragmentów, a nie z rozmiarem odpowiedzi, więc kluczowym elementem do optymalizacji jest to, ile z każdego fragmentu trafia do modelu.

Dlaczego stosuje się ponowne sortowanie w bazie danych wektorowych?

Typowy schemat dwuetapowy polega na szybkim wyszukiwaniu gęstym, aby zebrać mniej więcej 100 najlepszych kandydatów, a następnie na drugim wywołaniu sieciowym do zewnętrznego narzędzia do ponownego sortowania, takiego jak Cohere, SageMaker lub dostosowany kontener, w celu ich uporządkowania. To rozwiązanie jest skuteczne, ale ma trzy wady.

  • Dwa wywołania sieciowe są kierowane do dwóch różnych dostawców.
  • Jeśli narzędzie do ponownego sortowania przestanie działać, wyszukiwanie również się nie powiedzie.
  • Rachunek obejmuje przechowywanie wektorów oraz ponowną sortację wywołań.

Qdrant oferuje alternatywę. Od wersji 1.10 obsługuje natywne pola wielowektorowe z komparatorem MAX_SIM, który jest podstawą mechanizmu późnej interakcji w stylu ColBERT. Porównanie token po tokenie odbywa się wewnątrz bazy danych w ramach jednego zapytania, dzięki czemu precyzja metody późnej interakcji uzyskiwana jest bez konieczności drugiego kroku. W połączeniu z izolacją zdań takie ustawienie pozwoliło obniżyć koszty tokenów o 67%.

Ustawienia pamięci uzupełniają ten profil.

  • Kwantyzacja binarna w gęstym indeksie. Wektory gęste są skracane do jednego bita na wymiar, a te małe wektory pozostają w pamięci RAM (zawsze_ram=True), co umożliwia bardzo szybkie pobieranie kandydatów przed ich użyciem.
  • Matryce ColBERT na dysku. Ciężkie matryce tokenów przechowywane są na dysku (on_disk=True) i są ładowane tylko dla najlepszych kandydatów, być może 50 do 100 fragmentów, podczas ponownego oceniania, co zapobiega wysokim kosztom pamięci RAM.

Proces przetwarzania

Projekt składa się z sześciu kroków, obejmuje jedno zapytanie do bazy danych i nie wykorzystuje żadnych zewnętrznych API do ponownego sortowania.

  1. Podziel umowy na sekcje, aby żaden fragment nie przecinał na pół klauzuli prawniczej.
  2. Wbuduj każdy fragment dwa razy: raz jako standardowy wektor gęsty do szybkiego przetworzenia, a raz jako matrycę wielowektorową ColBERT do dokładniejszej oceny na poziomie tokenów.
  3. Przedwcześnie załóż dane najlepszych N kandydatów przy użyciu wektorów gęstych skwantyzowanych binarnie.
  4. Ponownie ocenij te kandydaty za pomocą ColBERT MAX_SIM w Qdrant.
  5. Weź najlepsze fragmenty i ocenij ich poszczególne zdania lokalnie w porównaniu z wektorem zapytania ColBERT.
  6. Wyślij do modelu LLM tylko te zdania, które pasują.

Cena użyta w tym punkcie odniesienia wynosi 3,00 dolara za milion tokenów wejściowych, co jest zgodne z ceną modelu komercyjnego średniej półki.

Schemat zbierania danych

Poniższy skrypt konfiguracyjny uruchamia standardowe gęste embeddingi oraz wielowektorowe modele ColBERT obok siebie, przy czym modele FastEmbed są ładowane lokalnie na procesorze CPU.

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

Jedna ustawienie wyróżnia się w parametrach wektorowych ColBERT: konfiguracja HNSW z wartością m ustawioną na 0. Ponieważ ColBERT jest używany wyłącznie do ponownego oceniania danych przefiltrowanych wcześniej, nie jest potrzebny żaden graf HNSW. Pominięcie tego grafu pozwala zaoszczędzić dużo czasu na budowanie indeksu oraz przestrzeni dyskowej.

Jedna podróż do bazy danych dla pobierania danych i ponownego oceniania

Uniwersalna API zapytań Qdrant umożliwia przeprowadzenie pobierania danych w pierwszym etapie oraz ponownego ich oceniania w drugim etapie jako jednej operacji. Nie jest potrzebne żadne zapytanie, analiza ID ani drugi krok do narzędzia do ponownego sortowania – cały proces jest realizowany w ramach jednej podróży do bazy danych.

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

Flaga rescore=False w bloku prefetch zasługuje na uwagę. Przy kwantyzacji binarnej Qdrant zwykle pobiera kandydatów z wektorami skwantyzowanymi, a następnie ładuje pełnowartościowe wektory float32, aby je ponownie ocenić. Ponieważ etap ColBERT i tak wykonuje dokładniejszą ocenę, wyłączenie tego procesu pozwala uniknąć odczytu wektorów pełnej precyzji z dysku i zmniejsza obciążenie bazy danych.

Od fragmentów do zdań

MAX_SIM podaje jeden ogólny wynik oceny na każdy fragment i nie ujawnia, które tokeny na niego wpłynęły. Aby wyodrębnić tylko istotny tekst, ostatni krok lokalny dzieli wybrane fragmenty na zdania i oblicza dla każdego z nich wyniki oceny ColBERT late-interaction. Ponieważ proces ten odbywa się tylko na ostatnich kilku fragmentach, w których tekst jest krótki, nie występuje problem skalowania bazy danych, a precyzja na poziomie zdań jest osiągana tanio dzięki procesorowi CPU.

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

Obliczenia lokalne są szybkie, ponieważ dotyczą jedynie trzech lub pięciu najważniejszych fragmentów. Gdy zostaną zidentyfikowane najlepsze zdania, reszta każdego fragmentu jest odrzucana, a następnie składany jest pakiet z promptem.

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

Rozmiar fragmentu i liczba uczciwa

Sama redukcja tokenów to kusząca metryka. Umieszczenie dziesięciu do dwunastu sekcji prawniczych w jednym fragmencie liczącym 2000 słów, a następnie zastosowanie izolacji zdań, teoretycznie może przynieść redukcję powyżej 85%. W rzeczywistym systemie prawniczym taki podejście pogarsza dokładność wyszukiwania. Duży fragment przytłacza sygnał konkretnej klauzuli, np. ograniczenia odpowiedzialności za IP strony trzeciej, otaczającym ją standardowym tekstem w gęstym przestrzeni wektorowej. Zapytania skierowane do tak zamglonych, wieloklauzulowych fragmentów często zwracają niepowiązane sekcje, takie jak gwarancje, ponieważ zaawansowany mechanizm wyszukiwania nie jest w stanie izolować celu.

Rozwiązaniem jest ścisłe ograniczenie polegające na jednej klauzuli prawnej na każdy fragment, przy czym każdy fragment zawiera dokładnie jedną odrębną sekcję oraz ma realistyczną długość klauzul. W ramach tego schematu testy pokazały potwierdzone 67,1% zmniejszenie liczby tokenów w zapytaniach. Ręczne sprawdzenia dotyczące kwestii odszkodowań i własności IP potwierdziły, że znaleziono odpowiednie klauzule, mimo że ogólna wartość spadła z teoretycznych 87,3% do rzeczywistych 67,1%. Czyste granice na poziomie sekcji są ważniejsze niż wyższy procent.

Kompromis pomiędzy opóźnieniem a wydajnością

Quantyzacja binarna sugeruje, że dwuetapowy proces może przewyższyć zwykłą wyszukiwę gęstą pod względem szybkości. Tak jednak nie jest. W ramach powtarzanych prób wyszukiwanie dwuetapowe było konsekwentnie dwa do czterech razy wolniejsze, w zależności od maszyny i stopnia wykorzystania pamięci cache: 39,5 ms w porównaniu z 20,4 ms. Powód jest prosty. Wyszukiwanie wyłącznie gęste polega na jednej próbie wyszukania we wektorze embedding, natomiast wersja dwuetapowa uruchamia oba kodery zapytań – gęsty i ColBERT – oraz tworzy macierz porównania tokenów wewnątrz bazy danych. Odrębna próba wyłącznie wyszukiwania gęstego dała średnio 7,4 ms.

Testy opóźnienia, w których limit pobierania danych z góry zwiększono z 10 kandydatów do 500, ledwo co coś zmieniły, a opóźnienie pozostało na tym samym poziomie. To sama struktura dwuetapowa, a nie rozmiar okna pobierania danych z góry, powoduje dodatkowy czas wykonywania operacji.

W szerszym kontekście opóźnienie w procesie RAG od początku do końca jest determinowane przede wszystkim przez generowanie przez modele LLM, a nie przez zapytania do bazy danych. Zachowanie ponad 400 tokenów na zapytanie skraca późniejszą obróbkę i generowanie o setki milisekund, dzięki czemu cała aplikacja działa znacznie szybciej.

Jaka jest wartość oszczędności

Przy cenie 3,00 dolara za milion tokenów wejściowych, 67,1% redukcji tokenów w promptach przynosi istotne oszczędności. W przypadku 500 000 zapytań używanych w procesach sądowych oznacza to oszczędności przekraczające 600 dolarów na każdą audytę sprawy. Krótsze prompty zmniejszają również efekt „utraty informacji w środku”, gdy model pomija istotny tekst ukryty w długim kontekście.

Co sprawdzić przed wdrożeniem

Trzy miary decydują o tym, czy projekt nadaje się do określonego obciążenia pracy. Pierwszą z nich jest poprawność odpowiedzi w ręcznie sprawdzonym zbiorze zapytań, ponieważ oszczędności tokenów niewiele znaczą, jeśli brakuje właściwej klauzuli. Drugą jest opóźnienie całego żądania, włączając fazy generowania, gdyż dodatkowy czas potrzebny na operacje w bazie danych jest niewielki w porównaniu z czasem wywołania modelu. Trzecią jest liczba tokenów w każdym promptzie przed i po izolacji zdań, mierzona przy obowiązującej cenie. Wyniki uzyskane dla jednego typu dokumentu mogą nie być przenoszone na inny, dlatego testy należy powtarzać na docelowym korpusie.

Lekcje z doświadczeń z produkcji

  • Zachowaj ponowne sortowanie wewnątrz bazy danych. Zewnętrzne API do ponownego sortowania wprowadzają punkty awarii oraz dodatkowe przejazdy przez sieć, podczas gdy wbudowany operator MAX_SIM realizuje tę czynność w jednym kroku.
  • Wyłącz HNSW dla pól przeznaczonych wyłącznie do ponownego oceniania. Ustawienie m=0 w konfiguracjach wielowektorowych pozwala uniknąć kosztownego kroku indeksowania, który nie przynosi żadnych korzyści przy wyszukiwaniu macierzy ColBERT.
  • Unikaj bardzo dużych fragmentów tekstu. Zbyt duże rozmiary mogą dawać imponującą redukcję liczby tokenów teoretycznie, ale jednocześnie osłabiają reprezentację semantyczną i pogarszają dokładność.
  • Używaj prawdziwego tokenizatora zdań. Prosty regex nadaje się do demonstracji, ale kod produkcyjny powinien wykorzystywać biblioteki takie jak NLTK lub spaCy, które potrafią radzić sobie z skrótami prawniczymi, takimi jak Inc., Sec. i Exhibit A., bez błędnego dzielenia zdań.

Kompatybilne porównanie wielowektorowe w połączeniu z lokalną oceną na poziomie zdań może stworzyć precyzyjny i oszczędny proces, który zwraca potrzebną frazę użytkownika bez zwiększania miesięcznego rachunku za API.

Literatura pokrewna

  • Testowanie GLM 5.2 w Claude Code przy użyciu prawdziwego SDK za pięć dolarów — Eksperyment z OpenRouter za pięć dolarów: kierowanie danych z Claude Code do GLM 5.2, audyt i modyfikacja SDK React Native, koszt każdego zadania oraz moment, w którym oszczędności się kończą.
  • Headroom: Redukcja kosztów tokenów agenta dzięki odwracalnej kompresji kontekstu — Jak Headroom kompresuje logi, fragmenty RAG oraz wyniki narzędzi przed ich dostarczeniem do modelu, z wykorzystaniem CacheAligner, ContentRouter, CCR, trybu proxy oraz koniecznych kompromisów.