Strona główna / Artykuły

Projektowanie RAG dla 100 milionów dokumentów: shardowanie, wyszukiwanie hybrydowe, koszty

Jakie zmiany zachodzą, gdy system RAG rośnie do miliardów fragmentów: asynchroniczne pobieranie danych, kierowanie ruchem, dzielenie na fragmenty, hybrydowe wyszukiwanie, ponowna sortowanie wyników, autoryzacja, buforowanie oraz kontrola kosztów.

Wydawca: REACTAPP.TOP21 min czytania4744 słów

Źródła: Designing RAG for 100 Million Documents

Uruchomienie demonstracji RAG zajmuje popołudnie. Podzielasz plik PDF na fragmenty, wstawiasz je, umieszczasz wektory w bazie danych, wybierasz pięć najbliższych sąsiadów, wklejasz je do promptu i uruchamiasz model.

Taki sposób jest w pełni wystarczający dla kilku tysięcy dokumentów. Gdy zbiór rozrasta się do 100 milionów dokumentów, cała struktura przestaje funkcjonować, a pytanie, które się stawiamy, zmienia się. Już nie brzmi ono „którą bazę wektorową powinniśmy wybrać?”, lecz: jak zmniejszyć miliardy potencjalnych dowodów do 10 lub 20 istotnych fragmentów, nie niszcząc przy tym opóźnień, jakości wyszukiwania, dostępności ani budżetu?

To zupełnie inny problem inżynieryjny. W takim skali RAG stanowi rozproszony system danych i wyszukiwania z modelem językowym połączonym na końcu.

Co musi zrobić system

Aby dyskusja była konkretna, załóżmy następujące wymagania:

  • Ponad 100 milionów dokumentów w formatach takich jak PDF, HTML i DOCX
  • Miliony użytkowników zadających pytania w języku naturalnym
  • Cytaty dołączone do każdej odpowiedzi
  • Dokumenty, które z czasem są aktualizowane i usuwane
  • Szybkie wyszukiwanie – około 1 do 3 sekund przed rozpoczęciem generowania
  • Wysoka dostępność
  • Przetwarzanie dokumentów w trybie asynchronicznym

Wszystko sprowadza się do jednego trudnego wymagania: znalezienie odpowiednich 10 do 50 informacji wśród potencjalnie miliardów fragmentów.

Zacznijmy od arytmetyki

Pierwszym błędem w dużych projektach RAG jest traktowanie dokumentu i wektora jako tego samego. Załóżmy:

Documents              = 100,000,000
Average chunks/document = 30
Embedding dimensions    = 768
Storage/dimension       = 4 bytes (float32)

Liczba wektorów wynosi zatem:

100,000,000 × 30
= 3,000,000,000 vectors

Trzy miliardy wektorów. Surowa pamięć typu float przeznaczona dla nich wynosi około:

3B × 768 × 4 bytes
≈ 9.2 TB

A to są tylko same liczby. Rzeczywiste wdrożenie obejmuje również:

ANN index structures
chunk text
document metadata
ACL metadata
IDs
lexical indexes
replicas
snapshots
WALs
temporary indexes
operational headroom

Mając dwie kopie indeksu wektorowego, już przekraczasz 18 TB surowych wektorów, zanim uwzględniono zostaną struktury wyszukiwania. Jeśli dokumenty składają się średnio z 50 fragmentów, a nie 30:

100M documents × 50 chunks
= 5 billion vectors

Dlatego stwierdzenie „umieścimy wszystkie fragmenty w bazie danych wektorowej i uruchomimy wyszukiwanie podobieństw” nie opisuje architektury na dużą skalę. Opisuje jedynie punkt wyjścia.

Dwa oddzielne poziomy

Najlepszym sposobem na zorganizowanie systemu jest podział go na dwa niezależne poziomy: jeden do przyjmowania danych, a drugi do ich wyszukiwania. Ten podział ma znaczenie – klient przesyłający 20 milionów nowych dokumentów nie powinien powodować wzrostu czasu odpowiedzi z 300 ms do 8 sekund. Proces przyjmowania danych jest optymalizowany pod kątem przepustowości, a proces wyszukiwania pod kątem szybkości reakcji; próba łączenia ich w jednym procesie to niepewny sposób na przyszłe problemy operacyjne.

Baza danych wektorowa nie jest twoim źródłem prawdy

Zachowaj oryginalne dokumenty w tanich usługach przechowywania obiektów, takich jak S3, GCS czy Azure Blob. Baza danych wektorowych to indeks, a indeksy można odbudować. Jeśli za sześć miesięcy przejdziesz z modelu embedding E1 na E2, chcesz zregenerować wektory na podstawie przechowywanych oryginałów, a nie próbować odtworzyć treść bezpośrednio z samej bazy wektorowej.

Tutaj również przydaje się wersjonowanie obiektów, ponieważ aktualizację można zapisać jako nową wersję zamiast niszczyć starszą. Na przykład Amazon S3 może przechowywać kilka wersji obiektu, a zasady cyklu życia mogą przenosić starsze wersje do tańszych poziomów przechowywania lub je wygaszać.

Proces pobierania musi być asynchroniczny

Załóżmy, że klient przesyła 500 000 plików PDF. Nie chcesz żadnego żądania POST /documents, które analizuje pliki, stosuje OCR, je oczyszcza, dzieli na fragmenty, wstawia treść, tworzy indeksy, a dopiero potem zwraca kod 200. Takie żądanie może trwać godzinami. Zamiast tego endpoint powinien wykonywać jak najmniej operacji:

POST /documents
       │
       ▼
Store document
       │
       ▼
Create ingestion event
       │
       ▼
Return 202 Accepted

Wszystko inne dzieje się na boku, kierowane przez wydarzenia. Kafka jest jedną z opcji na podstawę takiego rozwiązania, ponieważ tematy są dzielone na partycje, którymi konsumenty mogą pracować równolegle, a kolejność jest zachowywana w każdej partycji, co jest przydatne, gdy wydarzenia dzielące ten sam dokument lub klucz potrzebują przewidywalnej sekwencji.

Konkretny narzędzie ma mniejsze znaczenie niż właściwość, którą zapewnia: proces pobierania danych wymaga bufora. Jeśli twój serwis do embeddingów obsługuje 30 000 fragmentów na sekundę, a nagle otrzymuje zadania w ilości 100 000 na sekundę, system powinien tworzyć kolejkę zaległych zadań, zamiast przestać funkcjonować.

Uczynienie każdego kroku pobierania danych idempotentnym

W takim skali zadania są wykonywane dwa razy. Komunikaty są dostarczane ponownie. Procesory umierają po zapisaniu danych, ale przed potwierdzeniem ukończenia zadania. Prośby wygasają mimo że strona zdalna zakończyła pracę. Należy zaplanować to wszystko od samego początku, tworząc deterministyczne identyfikatory na podstawie stabilnych danych wejściowych:

document_id
document_version
chunk_index
embedding_model_version

ID fragmentu może być wtedy haszem elementów, które go definiują:

hash(
    tenant_id +
    document_id +
    document_version +
    chunk_index
)

Otrzymanie tego samego zdarzenia indeksowania dwa razy już nie powoduje duplikatu treści, ponieważ

upsert(chunk_id, ...)

Można bezpiecznie spróbować ponownie. Realizacja wykonywania operacji dokładnie raz jest trudna. Celem bardziej realistycznym jest zazwyczaj wykonywanie operacji w sposób idempotentny.

Dzielenie na fragmenty to decyzja infrastrukturalna

W małym korpusie zmiana rozmiaru fragmentów wydaje się nieszkodliwa. Jednak przy miliardach wektorów wiąże się to z realnymi kosztami. Weźmy 100 milionów dokumentów – strategia dająca 20 fragmentów na dokument generuje 2 miliardy wektorów, natomiast ta dająca 60 fragmentów – 6 miliardów. Zmiana, która wydaje się niewinna, właśnie potroiła wszystkie te wartości:

embedding compute
vector storage
indexing work
retrieval fan-out
reindexing time
replication traffic

Dlatego dzielenie na fragmenty to nie tylko element do regulacji w NLP – jest to parametr planowania pojemności.

Każdy fragment powinien również zawierać wystarczająco dużo metadanych, aby można było go prześledzić do jego źródła:

{
  "chunk_id": "c_982...",
  "document_id": "doc_51...",
  "document_version": 8,
  "tenant_id": "tenant_17",
  "page": 43,
  "section": "Risk Factors",
  "language": "en",
  "created_at": "...",
  "acl_groups": ["finance", "executives"],
  "embedding_version": "embed_v4"
}

Sam tekst może być przechowywany gdzie indziej, w zależności od Twojej strategii przechowywania, ale jego ścieżka odniesienia musi przetrwać proces wyświetlania. W przeciwnym razie cytaty prędzej czy później staną się uciążliwe.

Nie przeszukuj całego korpusu, chyba że jest to konieczne

To może być najważniejsza koncepcja w całym tym projekcie. Weźmy pytanie: „Co zmieniło się w naszej europejskiej polityce zwrotów w tym kwartale?”. Proste rozwiązanie polegałoby na przeszukaniu wszystkich 3 000 000 000 wektorów. Jednak samo pytanie już wiele nam mówi:

organization = current tenant
region       = Europe
topic        = refund policy
time         = current quarter
permissions  = documents user may access

Architektura wyszukiwania dźwięków agresywnie wykorzystuje te ograniczenia, zmniejszając miliardy fragmentów do niewielkiej ich części przed rozpoczęciem jakiejkolwiek wyszukiwania podobieństwa. Dokładne liczby zależą od obciążenia, więc należy je traktować jako przykłady. Zasada pozostaje jednak ta sama: najpierw kierowanie zapytaniem, potem pobieranie danych.

Sharding sprawia, że warstwa wektorowa jest rozproszona

W pewnym momencie jeden węzeł nie jest w stanie przechować całego korpusu podlegającego wyszukiwaniu, więc indeks musi zostać podzielony. Rozproszone silniki wektorowe, takie jak Qdrant, skalują się horyzontalnie poprzez rozpraszanie punktów między fragmenty, a replikacja umieszcza kopie tych fragmentów na kilku węzłach w celu zapewnienia dostępności i większej przepustowości odczytu.

Jest jednak jeden haczyk. Jeśli każde zapytanie musi trafić do każdego fragmentu, dodawanie kolejnych fragmentów ostatecznie zwiększa pracę koordynacyjną zamiast ją zmniejszać. Lepszy projekt umożliwia kierowanie jak największej liczbie zapytań. Takie atrybuty mogą wskazać, które fragmenty faktycznie muszą brać udział:

tenant_id
region
document namespace
language
time partition

To przynosi największe korzyści w systemach wielu najemców. Zamiast

Query
 ↓
128 shards

zapytanie może zostać przetworzone

tenant_id = 429
 ↓
Shard group 18
 ↓
4 shards

Obecny rozproszony model Qdrant, jako przykład, umożliwia użytkownikowi definiowanie sposobu dzielenia danych na fragmenty, dzięki czemu operacje mogą być skierowane na wybrane klucze fragmentów i uniknąć modyfikacji całej kolekcji. Ta zasada jest ważna niezależnie od dostawcy – dobre podziałanie danych zmniejsza liczbę zapytań wysyłanych do różnych części systemu.

Replikacja odpowiada na inne pytanie

Ludzie mylą pojęcia dzielenia danych na fragmenty z replikacją, choć rozwiązują one odrębne problemy. Dzielenie danych na fragmenty dotyczy sposobu podziału zbyt dużych zbiorów danych na części dostępne dla pojedynczej maszyny. Replikacja natomiast dotyczy tego, co się dzieje, gdy jedna z tych maszyn przestaje funkcjonować. Wyobraźmy sobie taką strukturę:

Shard A → Node 1
Shard B → Node 2
Shard C → Node 3

Jeśli węzeł 2 przestanie działać, razem z nim znika część korpusu. Przy replikacji:

Shard A → Node 1 + Node 4
Shard B → Node 2 + Node 5
Shard C → Node 3 + Node 6

System nadal odpowiada na zapytania, nawet gdy poszczególne węzły przestają funkcjonować. Redundancja wiąże się z kosztami przechowywania, przepustowością do zapisu oraz wysiłkiem operacyjnym, ale klastr wyszukiwawczy w produkcji bez niej ma kilka kłopotliwych sposobów awarii.

Samo wyszukiwanie wektorowe jest niewystarczające

Załóżmy, że ktoś pyta: „Co mówi polityka HR-48291?”. Wyszukiwanie semantyczne może zwrócić materiały dotyczące ogólnych polityk HR, podczas gdy wyszukiwanie leksykalne doskonale radzi sobie z odnalezieniem dokładnego tokena HR-48291. Teraz spróbujmy inaczej: „Czy pracownicy mogą wziąć dodatkowy urlop po adoptacji dziecka?”. W tym przypadku wyszukiwanie semantyczne okazuje się przydatne. Obie metody się pokrywają, ale obejmują różne obszary, dlatego zwykle wybiera się hybrydową fazę wyszukiwania, która łączy obie metody.

Obecne wytyczne Elastic dotyczące hybrydowego wyszukiwania podążają tą samą drogą, łącząc wyszukiwanie leksykalne z wektorowym oraz proponując Reciprocal Rank Fusion jako sposób na połączenie wyników sortowania. Zaletą RRF jest to, że nie trzeba udawać, iż wynik BM25 oraz podobieństwo kosinusowe to porównywalne wartości – zamiast tego łączy się pozycje w rankingu. W uproszczonej formie:

RRF(d) = Σ 1 / (k + rank_i(d))

gdzie rank_i(d) to pozycja dokumentu d w systemie wyszukiwania i.

Uważaj przy sekwencyjnym prefiltrowaniu

Istnieje kusząca skrótka:

3B documents
   ↓
BM25 returns 10,000
   ↓
vector search only those

Może być skuteczny przy niektórych obciążeniach, ale może również zniszczyć semantyczne odzyskiwanie informacji. Wyobraź sobie użytkownika wpisującego „Jakie zasady obowiązują pracowników, którzy adoptują?”, a dokument zawierający stwierdzenie: „Pracownicy, którzy przyjmują dziecko poprzez adopcję, mają prawo do szesnastu tygodni urlopu rodzicielskiego”. Pokrycie słów kluczowych z sformułowaniem użytkownika jest niewielkie. Jeśli BM25 odrzuci ten dokument, zanim semantyczny mechanizm odzyskiwania go zdąży go przetworzyć, żaden model embeddingu nie będzie mógł mu pomóc.

Dlatego, po uwzględnieniu surowych ograniczeń routingu, lepszym rozwiązaniem jest generowanie kandydatów równolegle za pomocą obu mechanizmów odzyskiwania, a następnie ponowne rankowanie połączonej listy. Same surowe ograniczenia stanowią odrębną kategorię:

tenant
permissions
document status

Zazwyczaj należy to wdrożyć przed rozpoczęciem pobierania.

Uprawnienia są wymagane, zanim LLM cokolwiek zobaczy

Wyobraź sobie korporacyjny korpus zawierający:

Engineering documents
HR documents
Board documents
Legal documents
Executive compensation
Customer contracts

Relevancja semantyczna nie jest upoważnieniem. Dokument dyrektora finansowego może być matematycznie najbliższym odpowiednikiem pytania pracownika, ale to nadal nie daje pracownikowi prawa do jego przeczytania. Błędny projekt wygląda w ten sposób:

Vector Search
     ↓
Retrieve confidential chunk
     ↓
Send chunk to LLM
     ↓
Check permission

Zanim zostanie przeprowadzona weryfikacja uprawnień, tekst poufny został już przeniesiony do komponentu poniżej w łańcuchu przetwarzania. Poprawny tok działań najpierw określa, kim jest użytkownik:

User
  ↓
Identity
  ↓
Groups / roles / ACL
  ↓
Authorized search space
  ↓
Retrieval

To oznacza, że metadane kontrolujące dostęp muszą skutecznie uczestniczyć w procesie wyszukiwania. W bardzo dużych skali schemat ACL, który po wyszukiwaniu musi przeszukać miliony rekordów, staje się zarówno problemem bezpieczeństwa, jak i problemem wydajności.

Wyszukiwanie dostarcza kandydatów, a nie kontekstu

Załóżmy, że hybrydowe wyszukiwanie daje 400 kandydatów. Nie umieszczaj wszystkich 400 elementów w zapytaniu. Najpierw uporządkuj je według ważności:

Vector candidates: 200
Lexical candidates: 200
          │
          ▼
        Fusion
          │
          ▼
    ~250 unique chunks
          │
          ▼
       Reranker
          │
          ▼
       top 20–40

Pierwszy etap wyszukiwania jest zaprojektowany tak, aby szybko znaleźć rzeczy prawdopodobnie istotne. Narzędzie do ponownego rankowania służy do zupełnie innego zadania: na podstawie znacznie mniejszego zbioru dokonuje dokładniejszej oceny istotności. Ten dwuetapowy model umożliwia stosowanie kosztownego modelu istotności do setek elementów zamiast miliardów.

Następnie usuwa się redundancje

Nawet dobre narzędzie do ponownego rankowania może dać coś w rodzaju:

Chunk 1 → page 14
Chunk 2 → page 14
Chunk 3 → page 15
Chunk 4 → page 14
Chunk 5 → page 15

Wszystkie pięć może przekazywać w zasadzie tę samą informację, a wysyłanie ich wszystkich marnuje tokeny kontekstowe. Zanim stworzysz ostateczne polecenie, zastosuj pewną kombinację następujących kroków:

deduplication
document diversity
section diversity
near-duplicate detection
adjacent-chunk merging
token budgeting

Czasami odpowiedź wymaga również swoich sąsiadów. Jeśli fragment 47 zawiera rozwiązanie, ale zaczyna się w środku wyjaśnienia, narzędzie do budowania kontekstu może je pobrać

chunk 46
chunk 47
chunk 48

Zamiast mieć nadzieję, że wyszukiwanie wektorowe samodzielnie odnajdzie każdy ciągły fragment.

Budowa kontekstu wymaga odrębnego warstwy

Zaskakująco powszechna implementacja traktuje kontekst jedynie w ten sposób:

context = "\n\n".join(retrieved_chunks)

Systemy produkcyjne funkcjonują lepiej przy użyciu dedykowanego budowniczego kontekstu. Jego zadaniem jest przyjęcie uporządkowanych dowodów i stworzenie efektywnego ciężaru danych promptu bez utraty informacji o cytatach. Koncepcyjnie:

Retrieved Evidence
        ↓
Deduplicate
        ↓
Merge related chunks
        ↓
Enforce token budget
        ↓
Preserve source IDs
        ↓
Order evidence
        ↓
Generate context

Model może wtedy otrzymać coś o strukturze podobnej do tej:

SOURCE S1
Document: Employee Handbook
Page: 42
Version: 18
Text: ...
SOURCE S2
Document: European Leave Addendum
Page: 7
Version: 4
Text: ...
QUESTION
...

i proszono o podanie źródeł w postaci S1, S2 itd. To o wiele bezpieczniejsze niż proszenie modelu o odtworzenie adresów URL lub identyfikatorów dokumentów z pamięci.

Rozumienie zapytań powinno być szybkie

Część zapytań odnosi korzyści ze standaryzacji przed wyszukiwaniem. Użytkownik wpisuje „O ile wzrosły przychody w Europie w zeszłym roku?”. Biorąc pod uwagę dzisiejszą datę oraz własną terminologię firmy, wyszukiwanie może przynieść lepsze wyniki przy użyciu strukturyzowanych danych wyekstrahowanych z tego zdania:

metric = revenue
region = Europe
year = 2025
intent = financial comparison

W rozmowie taki pytanie następcze jak „A co z Azją?” wymaga przepisania z uwzględnieniem wcześniejszego kontekstu:

How much did revenue grow in Asia in 2025?

Celem projektowania jest to, aby te kroki rzadko wymagały użycia najdroższego modelu analitycznego. Należy użyć najtańszego komponentu, który niezawodnie wykona zadanie. Dojrzała architektura AI zazwyczaj wygląda w ten sposób:

small model
    ↓
classification
small model
    ↓
query rewriting
embedding model
    ↓
retrieval
reranker
    ↓
relevance
large model
    ↓
final reasoning

a nie tak:

largest model everywhere

Rozbicie na części w przypadku pytań wieloetapowych

Część pytań nie może zostać odpowiedziana wiarygodnie na podstawie pojedynczego tekstu. Pytanie takie jak „Który europejski produkt odnotował największy spadek przychodów i jak zarząd to wytłumaczył?” dzieli się na dwa poszukiwania:

Question 1:
Which European product had the largest decline?
Question 2:
What explanation did management provide for that product?

Następnie system może zbierać dowody dla każdego z pytań podrzędnych osobno, a wyniki są łączone przed generowaniem odpowiedzi.

Complex Query
                         │
                         ▼
                 Query Decomposer
                    /         \
                   /           \
                  ▼             ▼
              Subquery A    Subquery B
                  │             │
                  ▼             ▼
              Retrieval     Retrieval
                   \           /
                    \         /
                     ▼       ▼
                  Evidence Join
                       │
                       ▼
                      LLM

W tym momencie RAG na dużą skalę zaczyna przypominać system planowania zapytań raczej niż prosty wyszukiwacz najbliższych sąsiadów.

Szczegółowość wpływa na sposób indeksowania

Załóżmy, że korpus zawiera 100 milionów dokumentów, z których 2 miliony zmieniają się każdego dnia. Przeprowadzanie ponownego indeksowania co noc prawdopodobnie nie jest tym, czego chcesz, dlatego potrzebujesz indeksowania inkrementalnego:

Document updated
       │
       ▼
new document version
       │
       ▼
parse changed document
       │
       ▼
generate new chunks
       │
       ▼
embed
       │
       ▼
write new index entries
       │
       ▼
mark previous version inactive

Logiczne wersjonowanie jest lepsze od natychmiastowej destruktywnej zastąpienia. Na przykład w przypadku jednego dokumentu:

document_id = D17
version 41 → status=inactive
version 42 → status=active

Filtr zapytań według

status = active

Stare wpisy mogą być usuwane w tle za pomocą mechanizmu garbage collection. Z punktu widzenia aplikacji aktualizacja jest przeprowadzana atomowo, bez konieczności, by każdy system przechowywania zmienił się w tym samym momencie.

Wersjonuj również modele

Prędzej czy później zmienisz modele embeddingowe, więc nigdy nie powinieneś przechowywać wektorów, nie wiedząc, co je wygenerowało. Przynajmniej zachowaj informacje umożliwiające ich śledzenie:

embedding_model
embedding_version
embedding_dimensions
chunking_version
parser_version

W przeciwnym razie, po sześciu miesiącach, możesz odkryć, że 15% indeksu zostało utworzone przy użyciu starszego procesu, a nie ma łatwego sposobu, aby określić, które to 15%. Migracja może być przeprowadzana na indeksach równoległych:

Documents
                    │
          ┌─────────┴─────────┐
          ▼                   ▼
   embedding model V3   embedding model V4
          │                   │
          ▼                   ▼
      Index V3            Index V4
                              │
                              ▼
                         shadow traffic
                              │
                              ▼
                           evaluate
                              │
                              ▼
                         switch alias

Nie przesuwaj miliardów wektorów i nie polegaj tylko na tym, że nowe wyszukiwanie będzie lepsze. Zmierz jego efektywność przed zmianą.

Pamięć cache na kilku poziomach

Nie każde zapytanie wymaga całego procesu. Jeśli tysiące pracowników pyta „Ile mamy dni urlopowych?”, uruchamianie

embedding
→ distributed retrieval
→ reranking
→ large LLM

Każdy taki przypadek jest marnotrawstwem. Bardziej sensowne jest użycie wielowarstwowego systemu pamięci podręcznych:

User Query
    │
    ▼
Exact Response Cache
    │ miss
    ▼
Semantic Cache
    │ miss
    ▼
Retrieval Cache
    │ miss
    ▼
Search

Oczywistym towarzyszem jest bufor embeddingów:

hash(normalized_query)
      ↓
query embedding

Sytuacja staje się skomplikowana, gdy treść ulega zmianie. Odpowiedź zlokalizowana w pamięci podręcznej

Policy version 17

może raz pójść nie tak

Policy version 18

Wchodzi w tryb działania. Klucz cache’u lub schemat unieważniania muszą zatem znać granice korpusu i wersji. Przechowywanie wyników modelu bez zasad aktualności jest niebezpieczne.

Planuj awarie, a nie tylko sukcesy

Wielkie systemy awariują stopniowo. Spodziewaj się takich sytuacji jak:

embedding provider unavailable
one vector shard unavailable
search cluster degraded
reranker timeout
LLM rate limited
LLM provider outage
queue backlog growing
parser crashing on malformed PDF
OCR worker running out of memory

Każda awaria wymaga wyraźnej polityki. Być może:

Vector search fails
        ↓
Can lexical search answer?
        ↓
yes
        ↓
return degraded retrieval mode

lub:

Primary LLM unavailable
        ↓
Fallback model
        ↓
Lower quality but service continues

lub:

Document parser fails 5 times
        ↓
Dead-letter queue
        ↓
record failure reason
        ↓
operator / automated remediation

Bezkresne próby ponownego wykonania nie są oznaką odporności. Czasami jest to po prostu nieskończony cykl z dodatkowymi kosztami w rachunku chmurowym.

Backpressure

Załóżmy, że proces pobierania danych odbywa się normalnie

20M chunks/hour

a pojemność wgrzewania może obsłużyć

25M chunks/hour

Wszystko w porządku. Następnie klient przeprowadza migrację i uzyskuje

200M chunks/hour

Bez ciśnienia zwrotnego awaria przebiega w następujący sposób:

embedding service overloaded
        ↓
timeouts
        ↓
retries
        ↓
more load
        ↓
more failures
        ↓
retry storm

Z buforem przed pracownikami:

incoming workload
      ↓
durable queue
      ↓
workers consume at safe rate
      ↓
backlog temporarily grows

Możliwość przetwarzania danych może teraz być skalowana niezależnie od tempa przychodzenia. To kolejny powód, dla którego warstwa przetwarzania danych wymaga własnej architektury.

Obserwowalność musi obejmować jakość pobierania danych

Zwykłe raporty monitoringu pokazują między innymi:

HTTP 200 rate
CPU
memory
disk
p95 latency
error rate
requests/second

Wszystko to jest przydatne, ale usługa typu RAG może zwrócić kod HTTP 200 w ciągu 800 ms i mimo to dostarczyć całkowicie błędną odpowiedź. Potrzebna jest również telemetria specyficzna dla AI. Przydatny zapis śledzenia za każdym żądaniem może wyglądać tak:

request_id: rq_912
query rewrite:
"parental policy?" → "parental leave policy"
vector retrieval:
200 candidates
145 ms
lexical retrieval:
200 candidates
91 ms
fusion:
278 unique candidates
reranking:
278 → 20
212 ms
context:
13 chunks
8,420 tokens
LLM:
input 9,104 tokens
output 681 tokens
1.8 sec
citations:
3 documents
total:
2.4 sec

Ocena offline wymaga zatem wskaźników, które odpowiadają na pytania takie jak: czy odzyskano właściwe dowody, czy miały one wystarczająco wysoką pozycję, czy odpowiedź była poparta tymi dowodami, czy ponowna sortacja wystarczająco poprawiła zapytania, by usprawiedliwić jej opóźnienie, oraz które kategorie zapytań najczęściej zawodzą? To jest bardziej praktyczne niż śledzenie halucynacji jako jednego globalnego procentu.

Oceniaj odzyskiwanie danych oddzielnie od generowania

Gdy ostateczna odpowiedź jest błędna, istnieją co najmniej dwie odrębne kategorie awarii. W przypadku awarii odzyskiwania danych potrzebne dowody nigdy nie trafiają do kontekstu:

Correct document exists
       ↓
retriever misses it
       ↓
LLM never sees it
       ↓
wrong answer

Żadna modyfikacja promptu tego nie naprawi. W przypadku niepowodzenia generacji odpowiednie dowody faktycznie się pojawiły:

correct evidence
       ↓
context
       ↓
LLM
       ↓
incorrect interpretation

Teraz znaczenie może mieć projekt promptu, wybór modelu, organizacja kontekstu lub strategia rozumowania. Jeśli oceniasz tylko ostateczne odpowiedzi, obie wyglądają identycznie, dlatego rzetelny proces oceny musi sprawdzić etapy pośrednie.

Przydziel budżet na opóźnienie

„System musi być szybki” nie jest wymogiem. Zamiast tego przydziel budżet każdemu etapowi. Dla ilustracji załóżmy, że celem jest pierwsza odpowiedź w ciągu około 2,5 sekundy, z takim podziałem:

API + auth                    50 ms
query understanding          100 ms
retrieval                    300 ms
fusion                        20 ms
reranking                    250 ms
context construction          30 ms
LLM time-to-first-token     1,200 ms
network / safety margin      550 ms
-----------------------------------
target                     ~2,500 ms

To nie są uniwersalne liczby. Ten test jest ważny, ponieważ pokazuje, dokąd faktycznie zmierza czas. Jeśli pobieranie danych zajmuje 1,8 sekundy, zmiana dostawcy LLM nie wpłynie na główny wąskie gardło. Jeśli LLM odpowiada za 90% opóźnienia, poświęcenie miesiąca na skrócenie o 40 ms czasu wyszukiwania wektorowego raczej nie będzie najlepszym wykorzystaniem wysiłku.

Koszty wymagają tej samej dyscypliny

Wielki system RAG ma wiele oddzielnych ośrodków kosztowych:

object storage
document parsing
OCR
embedding generation
vector storage
vector replicas
lexical search
metadata database
network transfer
reranking
LLM input tokens
LLM output tokens
observability
backups

Zatem oblicz koszt na jednostkę użyteczną, na przykład:

cost / 1,000 indexed documents
cost / million chunks
cost / query
cost / successful answer
cost / tenant

Ostatnia z tych kwestii ma największe znaczenie w SaaS. Możesz odkryć:

Tenant A
5% of revenue
42% of LLM spend

Bez pomiaru zużycia dla poszczególnych użytkowników widzi się jedynie nieoczekiwanie wysoką miesięczną rachunek za infrastrukturę.

Używaj drogiego przechowywania tylko tam, gdzie jest to konieczne

Przydatnym obrazem do zrozumienia jest gradient od drogiego i szybkiego do taniego i pojemnego:

expensive / fast
                      ▲
                      │
               vector indexes
               search indexes
               Redis caches
                      │
               metadata DB
                      │
               object storage
                      │
                      ▼
                 cheap / large

Pełny plik PDF nie ma powodu, by znajdować się w indeksie wektorowym. Zachowaj drogie miejsce przechowywania tylko dla tych danych, które wymagają wyszukiwania – niech system przechowywania obiektów przechowuje oryginalne dokumenty, a infrastruktura wyszukiwania – ich zapisy nadające się do wyszukiwania. Gdy korpus osiągnie setki terabajtów, ta różnica staje się niezwykle ważna.

Dane gorące i zimne

Nie wszystkie 100 milionów dokumentów jest wyszukiwanych w równym stopniu. Być może:

Last 30 days        → 70% of queries
Last 12 months      → 25%
Older archive       → 5%

Traktowanie wszystkiego tak samo marnuje infrastrukturę. Jedną z opcji jest podział w ten sposób:

Query
  │
  ├────→ Hot index
  │
  └────→ Archive index when necessary

Routing oparty na czasie zapobiega również temu, by pytania dotyczące wydarzeń z tego tygodnia łączyły się z dziesięcioma latami historycznych danych. Duże systemy stają się wydajne poprzez unikanie pracy, a nie tylko poprzez szybsze wykonywanie niepotrzebnych zadań.

Multi-tenancy zmienia wszystko

Załóżmy, że twoja platforma obsługuje

10,000 organizations

Prawie na pewno nie chcesz tego

10,000 completely independent vector clusters

Jednak umieszczanie wszystkich najemców w jednym przestrzeni nazw powoduje problemy z hałaśliwymi sąsiadami oraz trudności z autoryzacją. Skutecznym rozwiązaniem jest grupowanie najemców według ich wielkości:

Small tenants
        ↓
shared shard groups
Medium tenants
        ↓
partitioned shared infrastructure
Very large tenants
        ↓
dedicated shard groups

To zapobiega sytuacji, w której jeden klient importuje 500 milionów fragmentów, przytłaczając tysiące mniejszych, a jednocześnie zapewnia drogę do izolacji dla klientów korporacyjnych, którzy tego potrzebują. Żadna strategia podziału na tenanty nie pasuje wszystkim.

LLM znajduje się pod koniec

Zobacz, ile tematów inżynieryjnych omówiliśmy przed dotarciem do modelu. To celowe. Zapytanie typu RAG w stanie dojrzałym przepływa w ten sposób:

User
 ↓
API Gateway
 ↓
Authentication
 ↓
Authorization
 ↓
Rate Limiter
 ↓
Query Understanding
 ↓
Shard Routing
 ↓
Hybrid Candidate Retrieval
 ↓
Rank Fusion
 ↓
Reranker
 ↓
Deduplication
 ↓
Context Builder
 ↓
LLM
 ↓
Citation Validation
 ↓
Response

LLM jest ważne, ale przy 100 milionach dokumentów stanowi tylko jeden z wielu elementów.

Pełny obraz produkcji

Łącząc to wszystko, oto ścieżka zapytania, następnie ścieżka pobierania danych, a potem systemy, które obsługują obie:

CLIENTS
                                 │
                                 ▼
                         ┌──────────────┐
                         │ API Gateway  │
                         └──────┬───────┘
                                │
                         ┌──────▼───────┐
                         │ Auth / ACL   │
                         │ Rate Limits  │
                         └──────┬───────┘
                                │
                                ▼
                     ┌────────────────────┐
                     │ Query Orchestrator │
                     └─────────┬──────────┘
                               │
                 ┌─────────────┼──────────────┐
                 │             │              │
                 ▼             ▼              ▼
              Cache      Query Rewrite    Metadata
                                           Filters
                 │             │              │
                 └─────────────┼──────────────┘
                               ▼
                        Shard Router
                               │
                  ┌────────────┴────────────┐
                  ▼                         ▼
           Lexical Search              Vector Search
                  │                         │
                  └───────────┬─────────────┘
                              ▼
                         Rank Fusion
                              │
                              ▼
                           Reranker
                              │
                              ▼
                         Deduplicate
                              │
                              ▼
                       Context Builder
                              │
                              ▼
                             LLM
                              │
                              ▼
                     Citation Validation
                              │
                              ▼
                          RESPONSE
================================================================
INGESTION SYSTEM
Documents
                            │
                            ▼
                       Object Store
                            │
                            ▼
                       Event Queue
                            │
               ┌────────────┼────────────┐
               ▼            ▼            ▼
            Parser        Parser       Parser
               │            │            │
               └────────────┼────────────┘
                            ▼
                      Normalization
                            │
                            ▼
                         Chunker
                            │
             ┌──────────────┴──────────────┐
             ▼                             ▼
        Embedding Queue               Text Index Queue
             │                             │
       ┌─────┼─────┐                       ▼
       ▼     ▼     ▼                 Lexical Index
      GPU   GPU   GPU
       │     │     │
       └─────┼─────┘
             ▼
       Vector Index
================================================================
SUPPORTING SYSTEMS
Metadata DB         → document state, versions, ownership
Object Storage      → original source documents
Vector Cluster      → semantic retrieval
Search Cluster      → lexical retrieval
Redis               → caches and rate limiting
Message Queue       → asynchronous ingestion
Observability       → traces, metrics, evaluation
Secrets / IAM       → service authorization
Evaluation System   → retrieval + answer-quality tests

Antypaternale, których należy unikać

Przy analizie takiej architektury następujące elementy wzbudzają natychmiastowe zaniepokojenie:

One giant vector collection with no routing strategy
Synchronous PDF ingestion
Only vector retrieval, no lexical search
ACL filtering after retrieval
No document versioning
No embedding model version
No reranking
Sending top-100 chunks directly to the LLM
Using the LLM for every trivial classification task
No dead-letter queue
No retrieval-level evaluation
No tenant-level cost attribution
Treating the vector DB as permanent document storage
Re-embedding the entire corpus for every small content change
Benchmarking only average latency instead of tail latency

Każdy z nich może stanowić prototyp. Razem, w skali miliardów wektorów, stają się niezwykle uciążliwe.

Zasada podstawowa

Najlepszym sposobem na rozumienie dużych systemów RAG nie jest ten:

question
→ vector search
→ LLM

To jest to:

billions of possible evidence units
              ↓
        routing constraints
              ↓
      relevant partitions
              ↓
      cheap candidate search
              ↓
        hundreds of items
              ↓
      expensive reranking
              ↓
        tens of passages
              ↓
      context optimization
              ↓
             LLM
              ↓
      grounded answer

System funkcjonuje jak lejek. Na każdym etapie inwestujemy więcej zasobów obliczeniowych w coraz mniejszą liczbę kandydatów:

Cheap operation      → huge search space
Moderate operation   → smaller candidate set
Expensive operation  → tiny candidate set
Most expensive model → final context only

To właśnie ta koncepcja utrzymuje architekturę w warunkach ekonomicznej wykonalności.

Co wynieść z tego

System RAG oparty na kilku tysiącach dokumentów to przede wszystkim ćwiczenie z inżynierii aplikacji. Przy 100 milionach dokumentów staje się to infrastrukturą wyszukiwania, systemami rozproszonymi, inżynierią danych, kwestiami bezpieczeństwa i kontrolą kosztów – wszystko to wymaga jednoczesnej uwagi. Baza wektorowa ma znaczenie, podobnie jak model embedding i LLM, ale architektura wokół nich jest ważniejsza, niż przyznają większość poradników.

Model mentalny, który się skaluje: przechowuj źródła tanio, przetwarzaj asynchronicznie, twórz wersje wszystkiego, celowo dziel dane, wcześnie wdrażaj mechanizmy autoryzacji, pobieraj informacje za pomocą kilku sygnałów, trudno oceniaj rankingi, dostarczaj modelowi wyłącznie wysokiej jakości dane i mierz każdy etap oddzielnie. Gdy zbiór danych osiągnie miliardy fragmentów, najlepszą optymalizacją nie jest już przyspieszenie wyszukiwania – chodzi o takie ułożenie elementów, aby większa część systemu nigdy nie miała kontaktu z większą częścią zbioru danych.