Kurs Python · Moduł 11: RAG i systemy wieloagentowe
LlamaIndex - Framework RAG
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. Do czego służy LlamaIndex?
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: