Haystack, czyli aplikacja z modelem złożona z klocków
Aplikacja odpowiadająca na pytania z własnych dokumentów składa się z kilku kroków: zamień pytanie na wektor, poszukaj fragmentów, ustaw je w kolejności, zbuduj prompt, wywołaj model. Napisana wprost jest funkcją na sto linii, w której zmiana jednego etapu wymaga przeczytania całości.
Haystack rozbija to na komponenty połączone w graf. Każdy krok jest osobną klasą z zadeklarowanymi wejściami i wyjściami, a połączenia między nimi opisujesz jawnie. Efekt jest taki, że wymiana modelu osadzeń albo dołożenie filtra dotyka jednego miejsca, a nie całej funkcji.
Komponent i potok
Komponent to klasa z dekoratorem, deklarująca typy wyjścia i metodą wykonującą pracę.
pip install haystack-ai sentence-transformers-haystackfrom haystack import Pipeline
from haystack_integrations.components.embedders.sentence_transformers import SentenceTransformersTextEmbedder
from haystack.components.builders import ChatPromptBuilder
from haystack.components.generators.chat import OpenAIChatGenerator
potok = Pipeline()
potok.add_component("osadzenia", SentenceTransformersTextEmbedder())
potok.add_component("wyszukiwanie", retriever)
potok.add_component("prompt", ChatPromptBuilder(template=szablon))
potok.add_component("model", OpenAIChatGenerator(model="gpt-5-mini"))
potok.connect("osadzenia.embedding", "wyszukiwanie.query_embedding")
potok.connect("wyszukiwanie.documents", "prompt.documents")
potok.connect("prompt.prompt", "model.messages")
wynik = potok.run({"osadzenia": {"text": "jak pobrac duplikat faktury"}})Jedna rzecz w tym przykładzie zmieniła się wraz z wydaniem trzecim z lipca 2026 roku. Komponenty oparte o sentence-transformers wyprowadzono z rdzenia do osobnego pakietu, razem z trzydziestoma innymi, więc wymagają teraz doinstalowania i innej ścieżki importu. Kod pisany pod wersję drugą kończy się w tym miejscu błędem o brakującym module.
Połączenia są jawne i to jest sedno tego podejścia. Widzisz, co wchodzi do czego, a próba połączenia wyjścia z wejściem o niezgodnym typie kończy się błędem przy budowaniu potoku, a nie przy pierwszym uruchomieniu.
To odróżnia ten framework od bibliotek, w których przepływ danych jest domyślny i wynika z kolejności wywołań. Tam czytelność zależy od dyscypliny piszącego, tutaj wynika ze struktury.
Cena tej jawności jest widoczna od razu: więcej linii na najprostszy przypadek. Przy potoku z trzema krokami klasyczna funkcja byłaby krótsza, a przewaga pojawia się dopiero przy dziesięciu, gdy rozgałęzienia i wymiana elementów zaczynają być codziennością.
Rozgałęzienia i pętle
Druga wersja frameworka dołożyła obsługę grafów z rozgałęzieniami i cyklami, co zmienia zakres rzeczy, które da się wyrazić.
Rozgałęzienie pozwala skierować zapytanie do innej ścieżki w zależności od jego rodzaju. Pytanie o dokumentację idzie do wyszukiwania, pytanie o status zamówienia do interfejsu programistycznego, a pytanie spoza zakresu do odpowiedzi odmownej, zanim uruchomi się cokolwiek kosztownego.
Cykl pozwala powtórzyć krok, dopóki warunek nie zostanie spełniony. Typowe zastosowanie to poprawianie odpowiedzi, która nie przeszła sprawdzenia: model generuje, komponent oceniający sprawdza, a przy niepowodzeniu prompt wraca do modelu z informacją, co było nie tak.
Warto tu uważać na jedną rzecz. Cykl bez limitu iteracji potrafi kręcić się w kółko, zużywając tokeny i nie kończąc pracy. Limit ustawia się przy definicji i nie jest to opcja, tylko warunek zdrowego rozsądku.
Konfiguracja w pliku
Potok da się zserializować do pliku i wczytać z niego, co ma konsekwencje wykraczające poza wygodę.
components:
osadzenia:
type: haystack_integrations.components.embedders.sentence_transformers.sentence_transformers_text_embedder.SentenceTransformersTextEmbedder
init_parameters:
model: intfloat/multilingual-e5-base
model:
type: haystack.components.generators.chat.OpenAIChatGenerator
init_parameters:
model: gpt-5-mini
connections:
- sender: osadzenia.embedding
receiver: wyszukiwanie.query_embeddingTo pozwala zmienić model albo parametry bez wdrożenia nowej wersji aplikacji, co bywa przydatne przy strojeniu. Pozwala też trzymać różne warianty potoku dla różnych środowisk w plikach, a nie w warunkach rozsianych po kodzie.
Pułapka jest ta sama co przy każdej konfiguracji poza kodem. Zachowanie systemu zależy wtedy od pliku, którego zmiana nie przechodzi przez przegląd i nie ma testów. Rozsądny kompromis polega na trzymaniu tych plików w repozytorium i traktowaniu ich zmian tak samo jak zmian w kodzie.
Agenci
Nowsze wersje dokładają komponent agentowy, który mieści się w potoku obok pozostałych. To znaczy, że jeden przepływ może zawierać krok deterministyczny i krok, w którym model sam decyduje o kolejności działań.
Ten układ jest praktyczny, bo odpowiada temu, jak wygląda większość zastosowań. Wyszukiwanie fragmentów, filtrowanie po uprawnieniach i formatowanie odpowiedzi mają wykonać się dokładnie tak samo za każdym razem. Decyzja, czy sięgnąć po dodatkowe narzędzie i które, wymaga oceny treści.
Powierzenie modelowi całości byłoby marnotrawstwem, bo część kroków nie wymaga oceny, a każde wywołanie kosztuje. Napisanie całości jako deterministycznego potoku nie zadziała, bo część decyzji zależy od treści zapytania.
Praktyczna zasada brzmi: kroki, które muszą wykonać się identycznie, zostają komponentami, a agenta wpinasz tam, gdzie potrzebna jest ocena. To ta sama zasada co przy LangGraphie i innych rozwiązaniach opartych o graf.
Indeksowanie dokumentów
Potok odpowiadający na pytania to połowa systemu. Druga to potok przygotowujący dokumenty, i to on decyduje o jakości bardziej niż cokolwiek po stronie odpowiadania.
indeksowanie = Pipeline()
indeksowanie.add_component("konwerter", PyPDFToDocument())
indeksowanie.add_component("czyszczenie", DocumentCleaner())
indeksowanie.add_component("ciecie", DocumentSplitter(split_by="sentence", split_length=5))
indeksowanie.add_component("osadzenia", SentenceTransformersDocumentEmbedder())
indeksowanie.add_component("zapis", DocumentWriter(document_store=magazyn))
indeksowanie.connect("konwerter", "czyszczenie")
indeksowanie.connect("czyszczenie", "ciecie")
indeksowanie.connect("ciecie", "osadzenia")
indeksowanie.connect("osadzenia", "zapis")Krok czyszczenia usuwa powtarzające się nagłówki, stopki i puste linie. To brzmi drobno, a przy dokumentach z numerami stron w każdym fragmencie potrafi zauważalnie obniżyć trafność, bo model osadzeń traktuje ten szum jako część treści.
Krok cięcia jest najważniejszy i wart eksperymentów. Cięcie po zdaniach z zachodzeniem sprawdza się przy tekście ciągłym, a przy dokumentacji technicznej lepiej wypada cięcie po nagłówkach, bo sekcja jest naturalną jednostką odpowiedzi.
Warto zapisać w metadanych tytuł sekcji i adres źródła. Bez tego odpowiedź nie ma jak wskazać, skąd pochodzi, a użytkownik nie ma jak jej sprawdzić.
Ostatnia rzecz to identyfikatory. Zbudowane z adresu źródła i numeru fragmentu pozwalają ponownie zindeksować dokument bez tworzenia duplikatów, co przy dokumentacji zmieniającej się co tydzień jest warunkiem, a nie wygodą.
Ocena jakości
Framework udostępnia komponenty oceniające, które można wpiąć do potoku tak samo jak pozostałe.
Miary są te same, które opisuje tekst o Ragas: czy odpowiedź opiera się na kontekście, czy pobrane fragmenty są istotne, czy odpowiedź dotyczy pytania. Wpięcie ich do potoku pozwala mierzyć te rzeczy na zestawie przypadków po każdej zmianie.
Wartość takiego układu leży w tym, że ocena korzysta z tego samego potoku co produkcja. Przy osobnym narzędziu zawsze istnieje ryzyko, że mierzysz coś innego, niż faktycznie działa, bo konfiguracja się rozjechała.
Odwrotne podejście polega na ocenie z zewnątrz, bez wpinania komponentów w potok. TruLens instrumentuje aplikację i liczy tę samą triadę miar na licencji MIT, co bywa rozstrzygające, bo część narzędzi z tej kategorii ma licencje ograniczające komercyjne udostępnianie. Biblioteka powstała w Truerze, przejętej przez Snowflake, więc sporo przykładów w dokumentacji zakłada usługi tego dostawcy, choć sama działa lokalnie i konta nie wymaga.
Warto natomiast pamiętać o koszcie. Ocena modelem sędziowskim to dodatkowe wywołania, więc pełny zestaw uruchamiany przy każdej zmianie kodu jest nierealny finansowo. Rozsądny podział to mały zestaw często i pełny przed wydaniem.
Własne komponenty
Zestaw wbudowany pokrywa typowe kroki, a rzeczy specyficzne dla Twojej dziedziny piszesz sam. To prostsze, niż wygląda.
from haystack import component
from typing import List
@component
class FiltrUprawnien:
@component.output_types(documents=List[Document])
def run(self, documents: List[Document], user_id: str):
dozwolone = [d for d in documents if d.meta["wlasciciel"] == user_id]
return {"documents": dozwolone}Dekorator i deklaracja typów wyjścia to wszystko, czego potrzeba, żeby komponent dał się wpiąć do potoku jak każdy inny. Sprawdzanie typów przy budowaniu obejmuje go tak samo jak wbudowane.
Ten konkretny przykład warto rozważyć niezależnie od frameworka. Filtrowanie po właścicielu wykonane po wyszukiwaniu, a nie w zapytaniu do bazy, jest wolniejsze i mniej bezpieczne, bo baza zwraca wtedy cudze fragmenty, a odsiewanie następuje później. Filtr w zapytaniu jest właściwym miejscem, a komponent jak wyżej to zabezpieczenie na wypadek, gdyby ktoś ten filtr pominął.
Drugie typowe zastosowanie to przekształcenia specyficzne dla formatu Twoich dokumentów: wydobycie pól ze struktury, normalizacja nazw, doklejenie kontekstu z systemu zewnętrznego. Takie kroki rzadko mają wersję gotową, bo zależą od tego, jak wyglądają Twoje dane.
Trzecie to integracja z usługą, dla której nie ma komponentu. Opakowanie wywołania interfejsu w komponent zajmuje kilkanaście linii, a zysk polega na tym, że dalej pracujesz z nim tak samo jak z resztą potoku.
Haystack kontra alternatywy
| Rozwiązanie | Mocna strona | Słabość | Kiedy wybrać |
|---|---|---|---|
| Haystack | Jawne połączenia, sprawdzanie typów, konfiguracja w pliku | Więcej kodu przy prostych przypadkach | Wyszukiwanie po dokumentach w produkcji |
| LangChain | Największy zbiór integracji, szybki start | Więcej abstrakcji, mniej jawności | Projekt łączący wiele źródeł |
| LlamaIndex | Skupienie na indeksowaniu dokumentów | Mniej rozbudowana warstwa agentowa | Praca z dużymi zbiorami dokumentów |
| Własny kod | Pełna kontrola, zero abstrakcji | Piszesz i utrzymujesz wszystko | Prosty przepływ z dwoma krokami |
Ostatni wiersz bywa niesłusznie pomijany. Przepływ złożony z wywołania osadzeń, zapytania do bazy i wywołania modelu to trzydzieści linii kodu, które każdy zrozumie. Framework zaczyna się opłacać przy rozgałęzieniach, wymianie komponentów i potrzebie oceny.
Wybór między pierwszymi trzema wierszami zależy od tego, co cenisz. Jawność połączeń i sprawdzanie typów przy budowaniu to przewaga pierwszego wiersza, licząca się przy potoku, który ktoś będzie czytał za pół roku. Liczba gotowych integracji to przewaga drugiego, licząca się przy podłączaniu wielu źródeł.
Warto natomiast nazwać rzecz, która ginie w takich porównaniach. O jakości systemu odpowiadającego z dokumentów decyduje sposób cięcia dokumentów, dobór modelu osadzeń i obecność filtrów, a nie wybór frameworka. Te trzy rzeczy przenoszą się między bibliotekami bez zmian, więc praca w nie włożona nie przepada przy ewentualnej migracji, a praca włożona w naukę frameworka owszem.
Praktyczny wniosek jest taki, żeby zacząć od najprostszego rozwiązania i zmierzyć trafność na własnych pytaniach. Dopiero gdy wiesz, gdzie leży problem, sięgaj po narzędzie dające więcej kontroli, bo inaczej dokładasz złożoność, nie wiedząc, czy cokolwiek naprawia.
Wdrożenie produkcyjne
Potok uruchamiany ze skryptu nadaje się do prób. Wdrożenie obsługujące ruch wymaga kilku decyzji.
Pierwsza dotyczy tego, co dzieje się przy starcie. Potok tworzony przy każdym żądaniu ładuje model osadzeń za każdym razem, co przy modelu lokalnym oznacza kilka sekund. Potok zbudowany raz przy uruchomieniu aplikacji i przechowywany usuwa ten koszt.
Druga to praca współbieżna. Komponenty korzystające z modelu lokalnego nie zawsze radzą sobie z wieloma żądaniami naraz, więc przy większym ruchu wyszukiwanie i generowanie warto rozdzielić na osobne usługi, a model uruchomić na serwerze przystosowanym do obsługi wielu żądań.
Trzecia to obsługa błędów. Niedostępna baza wektorowa albo przekroczony limit u dostawcy modelu przerywa potok, więc warto zdecydować, co użytkownik zobaczy: komunikat o chwilowym problemie czy odpowiedź bez kontekstu, wyraźnie oznaczoną jako niepełną.
Czwarta to ślady. Potok złożony z sześciu komponentów jest przezroczysty przy budowaniu i nieprzezroczysty przy diagnozowaniu, jeśli nie zapisujesz, co przeszło przez który krok. Wpięcie narzędzia obserwacyjnego pokroju Langfuse zamienia pytanie „dlaczego ta odpowiedź jest zła" z domysłu w sprawdzenie.
Typowe błędy
Pierwszy to budowanie potoku do zadania, które jest jednym wywołaniem. Trzy komponenty na coś, co mieści się w dziesięciu linijkach, dokładają złożoność bez korzyści.
Drugi to cykl bez limitu iteracji. Pętla poprawiająca odpowiedź do skutku potrafi nie osiągnąć skutku, a zużycie tokenów rośnie liniowo z liczbą prób.
Trzeci to konfiguracja w pliku poza repozytorium. Zachowanie systemu zależy wtedy od czegoś, czego nikt nie przegląda i co nie ma historii zmian.
Czwarty to pomijanie oceny. Zmiana modelu osadzeń albo szablonu promptu zwykle poprawia jedne przypadki i psuje inne, a bez zestawu testowego widać tylko pierwszą połowę.
Piąty to mierzenie wyłącznie jakości odpowiedzi. Bez metryk dotyczących wyszukiwania nie wiadomo, czy poprawiać prompt, czy sposób cięcia dokumentów, a to dwie zupełnie różne prace.
Szósty to traktowanie komponentów wbudowanych jako jedynego wyboru. Własny komponent to klasa z dekoratorem i dwiema metodami, więc dopisanie kroku specyficznego dla Twojej dziedziny jest kwestią kwadransa.
FAQ
Czym Haystack różni się od LangChaina?
Stawia na jawność: połączenia między komponentami deklarujesz wprost, a niezgodność typów wychodzi przy budowaniu potoku. LangChain ma znacznie więcej gotowych integracji i pozwala szybciej zacząć, kosztem większej liczby abstrakcji do rozgryzienia.
Czy Haystack nadaje się tylko do wyszukiwania?
Nie, choć to jego najmocniejsza strona. Nowsze wersje mają komponent agentowy mieszczący się w potoku obok pozostałych, więc jeden przepływ może łączyć kroki deterministyczne z krokami, w których model decyduje sam.
Czy trzeba używać konfiguracji w pliku?
Nie, potok da się zbudować wyłącznie w kodzie. Plik przydaje się przy strojeniu bez wdrożenia i przy różnych wariantach dla środowisk, natomiast warto trzymać go w repozytorium, żeby zmiany przechodziły przez przegląd.
Jak to działa z bazami wektorowymi?
Przez komponenty magazynu dokumentów, dostępne dla popularnych rozwiązań, w tym Qdranta i Chromy. Zmiana bazy sprowadza się wtedy do podmiany komponentu, bez zmian w reszcie potoku.
Kiedy nie warto po to sięgać?
Przy przepływie złożonym z dwóch albo trzech kroków bez rozgałęzień. Trzydzieści linii kodu jest wtedy czytelniejsze niż potok z komponentami, a framework zaczyna się opłacać przy rozgałęzieniach i przy potrzebie wymiany elementów bez ruszania reszty.
Dokumentacja stoi na stronie projektu, a kod źródłowy w repozytorium na GitHubie.