Kurs Python · Moduł 11: RAG i systemy wieloagentowe

LlamaIndex - Framework RAG

6 min czytania
W tej lekcji8

W trzech poprzednich lekcjach sami pisaliśmy chunking, embeddingi, wyszukiwanie i sklejanie promptu. To dobra szkoła tropienia, ale w prawdziwej wyprawie nikt nie szyje sobie namiotu przed wyjazdem. Bierzesz sprawdzony ekwipunek i skupiasz się na trasie.

LlamaIndex to framework do budowania aplikacji RAG. Upraszcza cały proces - od ładowania dokumentów po generowanie odpowiedzi - i daje gotowe klocki na każdy krok, który dotąd pisaliśmy ręcznie.

Podstawy LlamaIndex

Cały pipeline z pierwszej lekcji mieści się w kilku linijkach. Obiekt Settings ustawia globalnie model językowy i model embeddingów, a SimpleDirectoryReader wczytuje wszystkie pliki z katalogu:

1from llama_index.core import VectorStoreIndex, SimpleDirectoryReader, Settings
2from llama_index.llms.openai import OpenAI
3from llama_index.embeddings.openai import OpenAIEmbedding
4
5# Konfiguracja globalna
6Settings.llm = OpenAI(model="gpt-4o-mini", temperature=0)
7Settings.embed_model = OpenAIEmbedding(model="text-embedding-3-small")
8
9# Ładowanie dokumentów
10documents = SimpleDirectoryReader("./docs").load_data()
11print(f"Załadowano {len(documents)} dokumentów")
12
13# Tworzenie indeksu
14index = VectorStoreIndex.from_documents(documents)
15
16# Query engine
17query_engine = index.as_query_engine()
18
19# Zapytanie
20response = query_engine.query("Co to jest RAG?")
21print(response)

Łańcuch jest zawsze ten sam: SimpleDirectoryReader i .load_data() ładują dokumenty, VectorStoreIndex.from_documents dzieli je na fragmenty, liczy embeddingi i buduje indeks, a .as_query_engine() zamienia indeks w silnik pytań. Pod spodem dzieje się dokładnie to, co pisaliśmy sami: retrieve, augment, generate. Zmienił się tylko poziom, na którym pracujesz.

Document Loaders

Dokumenty rzadko leżą w jednym formacie. LlamaIndex ma loadery do tekstu, plików i stron internetowych:

1from llama_index.core import Document
2from llama_index.readers.web import SimpleWebPageReader
3from llama_index.readers.file import PDFReader, DocxReader
4
5# Z tekstu
6doc = Document(text="To jest przykładowy tekst do zaindeksowania.")
7
8# Z plików
9pdf_reader = PDFReader()
10pdf_docs = pdf_reader.load_data(file="document.pdf")
11
12# Ze strony web
13web_reader = SimpleWebPageReader()
14web_docs = web_reader.load_data(urls=["https://example.com"])
15
16# Z wielu źródeł
17from llama_index.core import SimpleDirectoryReader
18
19reader = SimpleDirectoryReader(
20    input_dir="./data",
21    recursive=True,
22    required_exts=[".pdf", ".docx", ".txt", ".md"]
23)
24documents = reader.load_data()

Document to podstawowa jednostka danych: tekst plus metadane. PDFReader i SimpleWebPageReader pochodzą z osobnych pakietów (llama-index-readers-file, llama-index-readers-web), więc trzeba je doinstalować. SimpleDirectoryReader z recursive=True przejdzie też podkatalogi i weźmie tylko rozszerzenia z listy required_exts.

Node Parsing (Chunking)

W LlamaIndex fragment dokumentu nazywa się node. Strategie podziału możesz ułożyć od najprostszej do najbardziej zaawansowanej: cięcie po znakach, jak nasz simple_chunker z pierwszej lekcji, podział po zdaniach, podział semantyczny i parser hierarchiczny. Zaczynamy od zdań:

1from llama_index.core.node_parser import (
2    SentenceSplitter,
3    SemanticSplitterNodeParser,
4)
5
6# Prosty podział po zdaniach
7sentence_parser = SentenceSplitter(
8    chunk_size=512,
9    chunk_overlap=50
10)
11nodes = sentence_parser.get_nodes_from_documents(documents)

SentenceSplitter tnie tekst na fragmenty do 512 tokenów, starając się nie rozrywać zdań, a chunk_overlap to znana już zakładka.

Podział semantyczny idzie dalej: porównuje embeddingi sąsiednich zdań i tnie tam, gdzie znaczenie wyraźnie się zmienia:

1# Semantyczny podział
2semantic_parser = SemanticSplitterNodeParser(
3    buffer_size=1,
4    breakpoint_percentile_threshold=95,
5    embed_model=Settings.embed_model
6)
7semantic_nodes = semantic_parser.get_nodes_from_documents(documents)

breakpoint_percentile_threshold=95 oznacza, że cięcie następuje tylko przy 5% największych skoków znaczenia. To droższe, bo wymaga embeddingu każdego zdania.

Parser hierarchiczny tworzy kilka poziomów fragmentów naraz, od dużych do małych:

1# Hierarchiczny podział
2from llama_index.core.node_parser import HierarchicalNodeParser
3
4hierarchical_parser = HierarchicalNodeParser.from_defaults(
5    chunk_sizes=[2048, 512, 128]
6)
7hierarchical_nodes = hierarchical_parser.get_nodes_from_documents(documents)

Małe fragmenty (128 tokenów) dobrze trafiają w wyszukiwaniu, a duże (2048) dają modelowi szerszy kontekst. Dokumenty wejściowe się nie zmieniają, zmienia się tylko sposób ich pocięcia.

Retriever Modes

Retriever to zwiadowca, który przynosi pasujące fragmenty. Możesz go skonfigurować ręcznie i dodać postprocessing:

1from llama_index.core.retrievers import VectorIndexRetriever
2from llama_index.core.query_engine import RetrieverQueryEngine
3from llama_index.core.postprocessor import SimilarityPostprocessor
4
5# Podstawowy retriever
6retriever = VectorIndexRetriever(
7    index=index,
8    similarity_top_k=5
9)
10
11# Z postprocessingiem
12query_engine = RetrieverQueryEngine(
13    retriever=retriever,
14    node_postprocessors=[
15        SimilarityPostprocessor(similarity_cutoff=0.7)
16    ]
17)

similarity_top_k=5 pobiera pięć fragmentów, a SimilarityPostprocessor odrzuca te z podobieństwem poniżej 0.7, żeby słabe trafienia nie zaśmiecały promptu.

Hybrid search z poprzedniej lekcji też jest tu gotowy. Uwaga: w aktualnych wersjach BM25Retriever mieszka w osobnym pakiecie llama-index-retrievers-bm25, a nie w llama_index.core:

1# Hybrid retriever
2from llama_index.retrievers.bm25 import BM25Retriever  # pip install llama-index-retrievers-bm25
3from llama_index.core.retrievers import QueryFusionRetriever
4
5vector_retriever = index.as_retriever(similarity_top_k=5)
6bm25_retriever = BM25Retriever.from_defaults(nodes=nodes, similarity_top_k=5)
7
8hybrid_retriever = QueryFusionRetriever(
9    retrievers=[vector_retriever, bm25_retriever],
10    similarity_top_k=5,
11    num_queries=1,
12)

QueryFusionRetriever łączy wyniki obu zwiadowców w jedną listę. num_queries=1 oznacza, że używamy tylko oryginalnego pytania, bez dodatkowych wariantów generowanych przez LLM.

Query Transformations

Czasem problem leży w samym pytaniu. Pytanie złożone warto rozbić na mniejsze, a krótkie pytanie przerobić tak, żeby lepiej pasowało do dokumentów:

1from llama_index.core.query_engine import SubQuestionQueryEngine
2from llama_index.core.tools import QueryEngineTool
3
4# Sub-question engine - rozbija pytania na mniejsze
5tools = [
6    QueryEngineTool.from_defaults(
7        query_engine=index.as_query_engine(),
8        name="dokumentacja",
9        description="Zawiera dokumentację projektu"
10    )
11]
12
13sub_question_engine = SubQuestionQueryEngine.from_defaults(
14    query_engine_tools=tools
15)
16
17# HyDE - Hypothetical Document Embeddings
18from llama_index.core.indices.query.query_transform import HyDEQueryTransform
19from llama_index.core.query_engine import TransformQueryEngine
20
21hyde = HyDEQueryTransform(include_original=True)
22hyde_query_engine = TransformQueryEngine(
23    index.as_query_engine(),
24    query_transform=hyde
25)

SubQuestionQueryEngine rozbija pytanie na podpytania i każde kieruje do odpowiedniego narzędzia, tu jednego o nazwie "dokumentacja". HyDE najpierw każe modelowi napisać hipotetyczną odpowiedź i szuka dokumentów podobnych do niej, bo odpowiedź bywa bliższa dokumentom niż samo pytanie. include_original=True zachowuje też oryginalne pytanie.

Chat Engine

Query engine nie pamięta poprzednich pytań. Do rozmowy służy chat engine z pamięcią:

1from llama_index.core.chat_engine import CondenseQuestionChatEngine
2from llama_index.core.memory import ChatMemoryBuffer
3
4# Pamięć czatu
5memory = ChatMemoryBuffer.from_defaults(token_limit=3000)
6
7# Chat engine z historią
8chat_engine = index.as_chat_engine(
9    chat_mode="condense_question",
10    memory=memory,
11    verbose=True
12)
13
14# Konwersacja
15response1 = chat_engine.chat("Co to jest Python?")
16print(response1)
17
18response2 = chat_engine.chat("Jakie ma zastosowania?")
19print(response2)
20
21# Reset pamięci
22chat_engine.reset()

Tryb condense_question przepisuje pytanie uzupełniające, takie jak "Jakie ma zastosowania?", na samodzielne pytanie z użyciem historii, a dopiero potem przeszukuje indeks. reset() czyści pamięć. W najnowszych wersjach ChatMemoryBuffer jest oznaczony jako deprecated na rzecz klasy Memory z tego samego modułu, ale działa dalej.

Integracja z Vector Databases

Domyślnie indeks żyje w pamięci. Żeby przetrwał restart, podłączamy bazę wektorową z poprzedniej lekcji. Tak wygląda Chroma:

1# Chroma
2from llama_index.vector_stores.chroma import ChromaVectorStore
3import chromadb
4
5chroma_client = chromadb.PersistentClient(path="./chroma_db")
6chroma_collection = chroma_client.get_or_create_collection("llama_index")
7
8vector_store = ChromaVectorStore(chroma_collection=chroma_collection)
9index = VectorStoreIndex.from_vector_store(vector_store)

from_vector_store otwiera indeks nad kolekcją, która już ma dane. Żeby zapisać do niej nowe dokumenty, przekaż vector_store przez StorageContext do from_documents.

Qdrant i Pinecone podłącza się identycznie, zmienia się tylko klasa magazynu:

1# Qdrant
2from llama_index.vector_stores.qdrant import QdrantVectorStore
3from qdrant_client import QdrantClient
4
5qdrant_client = QdrantClient(host="localhost", port=6333)
6vector_store = QdrantVectorStore(
7    client=qdrant_client,
8    collection_name="llama_index"
9)
10
11# Pinecone
12from llama_index.vector_stores.pinecone import PineconeVectorStore
13from pinecone import Pinecone
14
15pc = Pinecone(api_key="...")
16pinecone_index = pc.Index("llama-index")
17vector_store = PineconeVectorStore(pinecone_index=pinecone_index)

Reszta kodu, czyli query engine, retrievery i chat engine, zostaje bez zmian, i to jest największa zaleta tej warstwy abstrakcji.

Ewaluacja

Na koniec sprawdzamy, czy odpowiedzi są wiarygodne. LlamaIndex ma evaluatory, które używają modelu językowego jako sędziego:

1from llama_index.core.evaluation import (
2    FaithfulnessEvaluator,
3    RelevancyEvaluator,
4    CorrectnessEvaluator
5)
6
7# Evaluatory
8faithfulness = FaithfulnessEvaluator()
9relevancy = RelevancyEvaluator()
10
11# Ewaluacja odpowiedzi
12query = "Co to jest RAG?"
13response = query_engine.query(query)
14
15faithfulness_result = faithfulness.evaluate_response(response=response)
16print(f"Faithfulness: {faithfulness_result.passing}")
17
18relevancy_result = relevancy.evaluate_response(query=query, response=response)
19print(f"Relevancy: {relevancy_result.passing}")

FaithfulnessEvaluator sprawdza, czy odpowiedź wynika z pobranego kontekstu, czyli łapie halucynacje. RelevancyEvaluator sprawdza, czy odpowiedź i kontekst pasują do pytania. Pole passing przyjmuje wartość True albo False.

Polecam zaczynać od SentenceSplitter i prostego query engine, a po HyDE czy hybrid search sięgać dopiero wtedy, gdy ewaluacja pokaże konkretny problem.

LlamaIndex to kompletny framework do RAG. W następnej lekcji poznasz systemy multi-agentowe - gdy jeden agent to za mało!

Zapamiętaj: LlamaIndex to gotowy ekwipunek wyprawy, dzięki któremu skupiasz się na trasie, a nie na szyciu namiotu.

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Do czego służy LlamaIndex?

  2. 2. Która klasa LlamaIndex służy do ładowania dokumentów z katalogu?

Zadania praktyczne w grze

  • Edytor kodu

    Zaimplementuj podstawowy system Q&A z LlamaIndex

  • Układanie w poziomie

    Ułóż podstawowy pipeline LlamaIndex:

  • Układanie w pionie

    Posortuj strategie chunkingu od najprostszej do najbardziej zaawansowanej:

Przydatne artykuły