Strona główna / Artykuły / Szukaj książek instrukcyjnych, nie wklejaj ich: krótka ścieżka RAG z odnośnikami w LangChain

Szukaj książek instrukcyjnych, nie wklejaj ich: krótka ścieżka RAG z odnośnikami w LangChain

Stwórz dwuetapową ścieżkę wyszukiwania w LangChain: załaduj runbooki, podziel je na fragmenty, przeprowadź wyszukiwanie za pomocą precyzyjnego zapytania, zachowaj metadane, wymagaj identyfikatorów źródeł oraz przetestuj to bez płatnych rozwiązań typu embeddings.

2886 słów

Szukaj w podręcznikach postępowań zamiast wpisywać całe zapytanie

W tym momencie aplikacja obsługi klienta dzieli się na różne ścieżki w zależności od typu zgłoszenia. Zgłoszenie dotyczące bazy danych łączy w sobie katalog usług, najnowsze incydenty oraz ostatnie aktualizacje, natomiast zgłoszenie dotyczące dostępu trafia inną ścieżką. Nadal brakuje szczegółowych instrukcji towarzyszących rekomendacjom.

Mógłbyś wkleić całe podręczniki postępowań do zapytania. Dla trzech krótkich plików to działa, ale przy większej liczbie plików takie rozwiązanie przestaje być skuteczne. Duże zapytania są droższe, działają wolniej i utrudniają określenie, który fragment tekstu posłużył jako podstawa odpowiedzi. Mogą one również przekazać modelowi dokumenty, których obecny użytkownik nigdy nie miał prawa czytać. Rozwiązaniem jest zatem wyszukiwanie.

Sztuka budowania ścieżki składa się tu z dwóch faz. Podczas indeksowania podręczniki postępowań w formacie Markdown przekształcają się w obiekty typu Document zawierające metadane źródłowe, a następnie są one dzielone na mniejsze fragmenty:

indexing time
Markdown runbooks
  -> Document objects with source metadata
  -> smaller chunks
  -> embeddings
  -> vector store

W momencie wysyłki zapytania klasyfikacja biletu generuje precyzyjne zapytanie wyszukiwawcze, które jest kierowane do mechanizmu odzyskiwania danych:

request time
ticket classification
  -> focused search query
  -> retriever
  -> a few runbook passages with source IDs
  -> recommendation chain
  -> source-ID check

Ludzie często nazywają to dwuetapową generacją wzbogaconą o wyniki wyszukiwania, czyli dwuetapowym RAG. Etykieta ma mniejsze znaczenie niż struktura procesu: najpierw następuje wyszukiwanie, a model otrzymuje do generacji tylko te fragmenty, które zostały znalezione. Model nigdy nie decyduje o tym, czy ma przeprowadzić wyszukiwanie, ani nie może wybrać dowolnego źródła danych. Nie jest potrzebny żaden agent.

Bilet używany w całym przykładzie

Przykładowy bilet brzmi następująco: żądania płatności w UE zawodzą po aktualizacji o godzinie 14:05, a logi checkout-api pokazują komunikat „database connection refused”. Jego strukturalna klasyfikacja wygląda tak:

{
  "ticket_id": "INC-2048",
  "summary": "EU checkout fails after a deployment.",
  "category": "database",
  "severity": "SEV2",
  "service": "checkout-api",
  "confidence": 0.91,
  "evidence": ["database connection refused"],
  "missing_information": ["Affected request percentage"]
}

Dobra wyszukiwarka zwraca podręcznik obsługi bazy danych służącej do procesów płatności, a nie przewodnik dotyczący opóźnień w płatnościach ani przewodnik dotyczący dostępu do tożsamości. Powinna również zawierać wystarczająco dużo metadanych, aby można było odpowiedzieć na cztery pytania: z jakiego pliku pochodzi tekst, który serwis go posiada, gdzie w pliku zaczyna się dany fragment oraz jakie identyfikatory źródłowe zostały użyte w zaleceniu. Tekst bez metadanych źródłowych utrudnia jego obsługę.

Sześć elementów procesu wydobywania informacji

Każdy z tych elementów wymaga definicji przed użyciem.

Dokument. Traktuj Dokument jako blok tekstu z dołączonymi etykietami. Tekst może pochodzić z pliku Markdown, PDF, strony internetowej, wiki lub nawet z wiersza w tabeli, a etykiety mogą zawierać identyfikator źródłowy, tytuł, serwis, ścieżkę, właściciela, wersję lub informacje o tym, kto może go przeglądać.

Kawałki dokumentów. Dokumenty są dzielone na części, które można znaleźć oddzielnie. Jeden podręcznik może opisywać jednocześnie diagnostykę, przywracanie stanu oraz procedury cofania zmian, jednak pytanie dotyczące haseł do bazy danych nie ma powodu, by za każdym razem zwracać materiały potrzebne do cofania zmian.

Zaawansowane modele tekstowe. Model zaawansowany przekształca tekst w listę liczb, a teksty o podobnym znaczeniu powinny znajdować się blisko siebie według wybranej metody porównywania. Nie jest to weryfikacja faktów – podobny tekst może nadal być błędny, przestarzały lub niedostępny.

Magazyn wektorów. Magazyn wektorów przechowuje wektory dokumentów i znajduje te znajdujące się w ich pobliżu. Niektóre z nich istnieją tylko w pamięci, inne znajdują się w bazie danych lub usłudze chmurowej. Miejsce przechowywania wektorów nie zastępuje kontroli dostępu.

Retriever. Podaj retriewerowi tekst zapytania w dowolnej formie, a on zwróci obiekty Document. Jest to szersza koncepcja niż wyszukiwanie wektorowe: za tą samą interfejsem można umieścić dopasowywanie słów kluczowych, magazyn wektorów, hybrydową usługę wyszukiwania lub własną API do wyszukiwania.

Generowanie. Ostatni etap bierze wybrane fragmenty i tworzy sformułowaną odpowiedź. Bez względu na to, co napisze, nie może udawać, że wyszukiwanie zwróciło coś, czego w rzeczywistości nie było.

Wyszukiwanie to nie pamięć

Zbiór instrukcji stanowi zasób wiedzy, a przeprowadzanie zapytań do niego jest traktowane jako proces wyszukiwania. Historia rozmów to sekwencja wiadomości, zapisane dane użytkownika to zwykłe dane aplikacji, a punkty kontrolne w procesach pracy rejestrują stan wykonywania. Zasady przechowywania i uprawnień do nich są różne, dlatego kolejny etap oddziela je w kodzie.

Struktura projektu i konfiguracja

Zdjęcie stanu na tym etapie ma taką strukturę:

langchain-helpdesk/
├── app.py
├── chains.py
├── embeddings.py
├── retrieval.py
├── schemas.py
├── workflow.py
├── runbooks/
│   ├── checkout-database.md
│   ├── identity-access.md
│   └── payment-latency.md
└── tests/
    └── test_retrieval.py

Zainstaluj paczki:

python -m pip install -U \
  langchain \
  langchain-openai \
  langchain-text-splitters \
  pydantic \
  python-dotenv \
  pytest

Aplikacja wywołuje embeddingi dostawcy. Testy zastępują je małym lokalnym odpowiednikiem, dzięki czemu nie dochodzi do żadnych wywołań dostawcy.

Krok 1: napisanie książek instrukcji, które można zidentyfikować

Książka instrukcji dla procesu checkout zaczyna się od linii z tytułem:

# Checkout database connection failures

Następnie podane są usługa, właściciel i nadwozie:

Service: checkout-api
Owner: commerce-platformUse this runbook when checkout-api reports database connection refused,
pool exhaustion or authentication failures.## First checks1. Compare the active database host, port and database name with the
   previous successful deployment.
2. Confirm that the application secret points to the expected
   credential version.
3. Test network reachability from the application environment without
   printing the credential.
4. Compare connection-pool limits with the database connection limit.

Tytuł, usługa oraz właściciel to tekst prosty, a narzędzie ładowania kopiuje również wybrane wartości do metadanych. W systemie firmowym nigdy nie pozwól, aby nagłówek określał dostęp. Etykiety dostępu muszą pochodzić z zaufanego systemu i być weryfikowane przy każdym wywołaniu operacji pobierania.

Krok 2: załaduj każdy plik do Dokumentu

Umieść load_runbooks w pliku retrieval.py. Najpierw potrzebuje on narzędzia do obsługi ścieżek:

from pathlib import Path

A teraz sam ładowarka:

from langchain_core.documents import Document
def load_runbooks(runbook_dir: Path) -> list[Document]:
    documents: list[Document] = []    for path in sorted(runbook_dir.glob("*.md")):
        text = path.read_text(encoding="utf-8")
        title = text.splitlines()[0].removeprefix("# ").strip()
        service_line = next(
            line for line in text.splitlines()
            if line.startswith("Service:")
        )
        service = service_line.split(":", 1)[1].strip()        documents.append(
            Document(
                page_content=text,
                metadata={
                    "source_id": path.stem,
                    "title": title,
                    "service": service,
                    "path": str(path),
                },
            )
        )    if not documents:
        raise ValueError(f"No Markdown runbooks found in {runbook_dir}")    return documents

Przebieg procesu:

  • glob("*.md") wybiera pliki Markdown z jednego znanego katalogu.
  • sorted(...) zapewnia powtarzalny porządek indeksu lokalnego. Jakość wyszukiwania nie powinna zależeć od porządku w systemie plików, ale powtarzalny wejście ułatwia debugowanie.
  • Plik jest dekodowany jako UTF-8.
  • Wyodrębnianie tytułu i informacji o usłudze jest celowo uproszczone, ponieważ pliki przykładowe mają stałą strukturę. Prawdziwy loader powinien weryfikować metadane i zgłaszać pliki z błędami zamiast zgadywać.
  • page_content zawiera tekst nadający się do wyszukiwania.
  • metadata zawiera pola, które powinny towarzyszyć każdemu kolejnemu fragmencowi.
  • Ostatnia weryfikacja zapobiega temu, by pusty indeks wyglądał na sukces.

Krok 3: podział dokumentów

Należy użyć RecursiveCharacterTextSplitter:

from langchain_text_splitters import RecursiveCharacterTextSplitter
def split_runbooks(documents: list[Document]) -> list[Document]:
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=420,
        chunk_overlap=60,
        add_start_index=True,
    )
    return splitter.split_documents(documents)

chunk_size=420 oznacza, że kawałki będą miały około 420 znaków, co pasuje do tego małego przykładu. chunk_overlap=60 powtarza krótki fragment tekstu pomiędzy sąsiednimi kawałkami, dzięki czemu zdanie pozostaje obok swojego wyjaśnienia, gdy granica przypada w jego środku. Większy nakład nie zawsze jest lepszy – oznacza to większą ilość przechowywanego tekstu, więcej pracy przy generowaniu embeddingów oraz więcej wyników bliskich duplikatów.

Gdy użyje się add_start_index=True, każdy kawałek zapisuje pozycję znaku, w którym się zaczyna, dzięki czemu można wrócić do oryginalnego pliku. Funkcja split_documents zwraca nowe obiekty typu Document, a każdy kawałek odziedzicza metadane swojego rodzica.

Wybór wielkości kawałka

Kopiowanie liczby z tutorialu nie jest decyzją. Najpierw sprawdź, jak są zorganizowane twoje dokumenty i o co faktycznie pytają ludzie. Krok naprawczy może wymagać tytułu, warunków oraz ostrzeżenia w jednym fragmencie, natomiast długa tabela zasad może potrzebować narzędzia do dzielenia na części uwzględniającego strukturę. Następnie przetestuj wyszukiwanie na podstawie oznaczonych pytań.

Krok 4: utworzenie narzędzia do wyszukiwania w vector-store

Aby przykład pozostał prosty, aplikacja wykorzystuje InMemoryVectorStore:

from langchain_core.vectorstores import InMemoryVectorStore
def build_vector_retriever(runbook_dir: Path, embeddings, *, k: int = 3):
    chunks = split_runbooks(load_runbooks(runbook_dir))
    store = InMemoryVectorStore.from_documents(
        chunks,
        embedding=embeddings,
    )
    return store.as_retriever(search_kwargs={"k": k})

from_documents włącza i przechowuje fragmenty, natomiast as_retriever udostępnia standardową interfejs retrevery. Parametr k=3 żąda trzech dokumentów. Nie oznacza to, że wszystkie trzy są istotne, ani nie gwarantuje, że wśród nich znajduje się właściwy fragment.

Aplikacja tworzy embeddingi dostawców:

from langchain_openai import OpenAIEmbeddings
embeddings = OpenAIEmbeddings(
    model=os.environ["EMBEDDING_MODEL"]
)
retriever = build_vector_retriever(
    Path(__file__).parent / "runbooks",
    embeddings,
)

Dostępne modele i ich nazwy zależą od konta twojego dostawcy, dlatego zachowaj nazwę modelu w konfiguracji. Ponadto InMemoryVectorStore traci swój indeks po zakończeniu procesu. To w porządku dla przykładów edukacyjnych i małych testów, ale nie jest to indeks przeznaczony do wspólnego użycia w produkcji.

Krok 5: utworzenie precyzyjnego zapytania na podstawie zaufanych pól

Nie przeszukuj całego stanu aplikacji – wybierz tylko odpowiednie pola służące do klasyfikacji:

def build_query(classification: TicketClassification) -> str:
    values = [
        classification.service or "unknown service",
        classification.category,
        classification.summary,
        *classification.evidence,
    ]
    return " | ".join(values)

Dla biletu ciągłego zapytanie wygląda mniej więcej w ten sposób:

checkout-api | database | EU checkout fails after a deployment. |
database connection refused

Ciąg znaków łączy prawdopodobną usługę, kategorię problemu, krótkie podsumowanie oraz dowody znotowane w ticketu. Sekrety, ukryte instrukcje oraz pełna historia wiadomości nie są włączane do tego ciągu.

Krok 6: przeniesienie metadanych podczas konwersji

To, co zwraca narzędzie do pobierania danych, to obiekty Document, z których każdy powinien stać się opisem o określonym typie:

class RetrievedPassage(BaseModel):
    source_id: str
    title: str
    service: str
    start_index: int
    text: str

Następnie powiąż z tym Document:

def to_passage(document: Document) -> RetrievedPassage:
    return RetrievedPassage(
        source_id=str(document.metadata["source_id"]),
        title=str(document.metadata["title"]),
        service=str(document.metadata["service"]),
        start_index=int(document.metadata.get("start_index", 0)),
        text=document.page_content.strip(),
    )

Pole wymagane są oznaczone kwadratowymi nawiasami, więc fragment bez source_id, title lub service natychmiast powoduje błąd. start_index ma wartość domyślną, ponieważ fragmenty zapisane wcześniej mogą jej nie mieć, a migracja produkcyjna powinna zdecydować, czy taka alternatywa jest do przyjęcia.

Krok 7: utworzenie wąskiego wejścia do rekomendacji

Narzędzie przeznaczone dla modelu otrzymuje następujący kontrakt:

class RecommendationInput(BaseModel):
    ticket_id: str
    ticket_summary: str
    category: Category
    severity: Severity
    service: str | None
    evidence: list[str]
    passages: list[RetrievedPassage] = Field(
        min_length=1,
        max_length=4,
    )

Gdy żadne fragmenty nie wracają, nie ma uzasadnionej rekomendacji:

def prepare_recommendation_input(
    classification: TicketClassification,
    passages: list[RetrievedPassage],
) -> RecommendationInput:
    if not passages:
        raise ValueError("At least one runbook passage is required")
    return RecommendationInput(
        ticket_id=classification.ticket_id,
        ticket_summary=classification.summary,
        category=classification.category,
        severity=classification.severity,
        service=classification.service,
        evidence=classification.evidence,
        passages=passages[:4],
    )

Jeśli wyszukiwanie nic nie znajdzie, model nie powinien być zmuszany do improwizacji na podstawie ogólnych informacji, które posiada. Prześlij zgłoszenie do konkretnej osoby lub użyj alternatywy, która została wcześniej zatwierdzona.

Krok 8: poproś o identyfikatory źródeł w ustrukturyzowanym wyniku

Schemat wyników zawiera odniesienia:

class GroundedRecommendation(BaseModel):
    explanation: str = Field(min_length=20, max_length=500)
    next_checks: list[str] = Field(min_length=1, max_length=4)
    source_ids: list[str] = Field(min_length=1, max_length=4)
    needs_human_review: bool

Tekst instrukcji brzmi:

Use only the supplied ticket fields and runbook passages.
Every recommended check must be supported by at least one supplied passage.
Return the source_id values you used.
Do not claim that a check or production change was performed.

Instrukcje dotyczące promptów są pomocne, ale kontrole na poziomie aplikacji wciąż mają znaczenie.

Krok 9: odrzucić cytaty, które nie zostały zwrócone podczas wyszukiwania

Gdy autor już to przeprowadzi, porównaj jego identyfikatory źródeł z odzyskanymi fragmentami tekstu:

available = {passage.source_id for passage in passages}
unknown = set(recommendation.source_ids) - available
if unknown:
    raise ValueError(
        "Recommendation cited sources that retrieval did not return: "
        + ", ".join(sorted(unknown))
    )

To łapie tylko sfałszowany identyfikator źródła i nic więcej. Model może odwoływać się do dokumentu, który rzeczywiście został zwrócony, a mimo to błędnie interpretować jego treść, dlatego nadal konieczne są ocena oraz sprawdzenie przez człowieka.

Krok 10: połączenie skorygowanego procesu pracy

Kod z czasu złożenia żądania jest prosty:

def run_retrieval_workflow(
    classification: TicketClassification,
    retriever,
    recommendation_chain,
) -> RetrievalResult:
    query = build_query(classification)
    passages = retrieve_passages(retriever, query)
    values = prepare_recommendation_input(classification, passages)
    recommendation = recommendation_chain.invoke(values)
    available = {passage.source_id for passage in passages}
    unknown = set(recommendation.source_ids) - available
    if unknown:
        raise ValueError("Recommendation cited an unavailable source")    return RetrievalResult(
        query=query,
        recommendation=recommendation,
        passages=passages,
    )

Nie ma ukrytego pętli. Wyszukiwanie odbywa się raz, proces generowania tekstu raz, a sprawdzenie źródła również raz.

Jak to wygląda

Wynik wygląda w ten sposób:

{
  "query": "checkout-api | database | EU checkout fails after a deployment. | database connection refused",
  "recommendation": {
    "explanation": "The error matches the checkout database connection runbook.",
    "next_checks": [
      "Compare the active database host, port and database name with the previous successful deployment.",
      "Confirm that the application secret points to the expected credential version."
    ],
    "source_ids": ["checkout-database"],
    "needs_human_review": false
  },
  "passages": [
    {
      "source_id": "checkout-database",
      "title": "Checkout database connection failures",
      "service": "checkout-api",
      "start_index": 0
    }
  ]
}

API może przekształcić każdy identyfikator źródła w link, ale tylko po sprawdzeniu, czy aktualny użytkownik ma uprawnienia do jego otwarcia.

Testowanie pobierania bez płacenia za embeddingi

Towarzyszący snapshot zawiera KeywordEmbeddings, mały zamiennik do testów, który wykorzystuje ustaloną listę słownictwa:

VOCABULARY = (
    "database",
    "connection",
    "checkout",
    "deployment",
    "credential",
    "pool",
    "payment",
    "latency",
    "timeout",
    "identity",
    "access",
    "role",
    "claim",
)

Nie myl go z narzędziem do wgrzewania w produkcję. Jego jedynym celem jest zapewnienie szybkich, offline i deterministycznych testów. Pierwszy test sprawdza bilet w trakcie wykonywania:

passages = retrieve_passages(
    retriever,
    build_query(classification()),
)
assert passages[0].source_id == "checkout-database"
assert passages[0].service == "checkout-api"
assert passages[0].start_index >= 0

Druga miara dotyczy godziny drugiej w trzech zaznaczonych zapytaniach:

score = hit_at_k(
    retriever,
    [
        ("checkout database connection refused", "checkout-database"),
        ("payment provider latency timeout", "payment-latency"),
        ("identity role claim access denied", "identity-access"),
    ],
)
assert score == 1.0

Dwa przykłady rodzą małe pytanie: czy oczekiwany źródło znajduje się wśród dwóch najlepszych wyników? Prawdziwy zestaw testowy wymaga wielu pytań, trudnych przypadków negatywnych, podobnych dokumentów oraz aktualnych zasad dostępu. Doskonała ocena przy trzech przykładach dowodzi jedynie, że te trzy przeszły test.

Trzeci test wprowadza fałszywą rekomendację odnoszącą się do not-returned, a proces musi ją odrzucić. Uruchom wszystko za pomocą:

pytest -q

Oczekiwany wynik:

3 passed

Nazewnictwo sposobów niepowodzeń w wyszukiwaniu

Każdy typ niepowodzenia zasługuje na własną nazwę, aby można było wiedzieć, gdzie szukać.

  • Niepowodzenie indeksowania: plik nie mógł zostać odczytany, brakowało metadanych lub zakończyła się niepowodzeniem prośba o embedding. Nigdy nie publikuj częściowo utworzonego indeksu jako kompletnego.
  • Niepowodzenie dotyczące aktualności: zmieniono zasady, ale wyszukiwarka nadal wyświetlała poprzednie instrukcje. Rejestruj wersje i daty, a także decyduj, w jaki sposób edycje i usunięcia wpływają na indeks.
  • Niepowodzenie związane z uprawnieniami: indeks pokazał fragment, którego dany użytkownik nie ma prawa czytać. Zastosuj filtry dostępu przed tym, jak tekst trafi do modelu, ponieważ ukrywanie linku później jest zbyt późne.
  • Niepowodzenie w rankowaniu: poprawny dokument jest zindeksowany, ale nie pojawia się w najważniejszych wynikach. Zmodyfikuj zapytanie, filtry, podział na fragmenty, embeddingi lub algorytm rankowania i sprawdź ponownie.
  • Błąd generowania: pobranie tekstu się udało, ale narzędzie do pisania pominęło lub błędnie zinterpretowało fragment. Należy ocenić obie fazy osobno, aby ustalić, która spowodowała problem.
  • Błąd cytowania: odpowiedź wskazuje na źródło, którego nie ma, lub takie, które nie potwierdza przedstawionego twierdzenia. Sprawdzanie identyfikatorów w porównaniu z powrotnym zestawem wykrywa tylko pierwszy problem.
  • Pobrany tekst nie jest wiarygodny

    Podręczniki operacyjne zawierają błędy, a czasami zdania brzmiące jak polecenia skierowane do modelu. Traktuj pobrane fragmenty jako cytowane materiały źródłowe, a nigdy jako reguły aplikacji o wyższej priorytecie. Zachowaj stabilne zachowanie w instrukcjach systemu, odgranicz pobrany tekst i ogranicz dostęp do narzędzi w kodzie aplikacji. Żaden podręcznik operacyjny nie powinien mieć możliwości autoryzowania się do uruchomienia narzędzia służącego do pisania.

    Gdy wyszukiwanie wektorowe jest niewłaściwym narzędziem

    Część zapytań nie wymaga wektorów. Dokładny identyfikator biletu można uzyskać za pomocą zwykłej zapytania do bazy danych, a znany nazwa usługi bezpośrednio odpowiada wpisowi w katalogu. Dokładne kody błędów oraz rzadkie identyfikatory lepiej przetwarzać za pomocą wyszukiwania według słów kluczowych. Jeśli firma już korzysta z usługi wyszukiwania, która indeksuje odpowiednie dokumenty i kontroluje uprawnienia, należy z niej skorzystać. Wyszukiwanie wektorowe to tylko jeden z kilku dostępnych narzędzi.

    Czego jeszcze brakuje do użycia w produkcji

    Zanim firma będzie mogła polegać na tym podejściu, brakuje jeszcze kilku elementów:

    • trwałe przechowywanie zamiast pamięci tymczasowej
    • jedna osoba odpowiedzialna za każdy dokument
    • filtry dostępu stosowane podczas wyszukiwania
    • zarządzanie wersjami i usuwaniem dokumentów
    • plan na moment, gdy zmieni się model embeddingu
    • ograniczenie ilości powracającego tekstu
    • przetestowane przypadki użyteczności do weryfikacji wyników
  • testy generowania oparte na pobranych fragmentach tekstu
  • zapisy kosztów i opóźnień
  • żurna audytowe chroniące poufne informacje
  • Framework łączy poszczególne elementy, a aplikacja podejmuje decyzje dotyczące ich funkcjonowania.

    Pozycja aplikacji helpdesk

    Teraz może przeszukiwać kontrolowany zbiór instrukcji i przechowywać swoje źródła:

    classification
      -> focused query
      -> retriever
      -> typed passages with metadata
      -> recommendation
      -> source-ID check
    

    Następnym krokiem jest rozdzielenie historii wiadomości, zapisanych danych użytkownika, aktualnego stanu procesu pracy oraz punktów kontrolnych na odrębne elementy. Wyznaczenie tej granicy pokazuje również, czy wystarczy zwykła łańcuchowa struktura LangChain, czy lepiej sprawdzi się LangGraph.

    Dodatkowa literatura:

    • https://docs.langchain.com/oss/python/langchain/retrieval
    • https://docs.langchain.com/oss/python/langchain/knowledge-base
    • https://docs.langchain.com/oss/python/integrations/splitters/index
    • https://docs.langchain.com/oss/python/integrations/retrievers

    Powiązana literatura