Budujemy najprostszy RAG z API w Pythonie

Michał Feliksik
4 min read
Budujemy najprostszy RAG z API w Pythonie

Wprowadzenie

Czas złożyć wszystko w całość. Z części pierwszej mamy teorię (chunki, embeddingi, indeks), z drugiej — klocki LlamaIndex (Document, Settings, VectorStoreIndex, query engine). Dziś opakowujemy je w proste API HTTP: jeden endpoint przyjmuje ścieżkę do pliku Markdown na dysku i dodaje go do indeksu w pamięci, drugi przyjmuje pytanie i zwraca odpowiedź modelu wraz ze źródłami. Świadomie bez bazy wektorowej, bez persystencji, bez zaawansowanych strategii — najprostszy RAG, jaki ma sens.

Przygotowanie środowiska

1
2
python -m venv .venv
.venv\Scripts\pip install fastapi uvicorn llama-index-core llama-index-llms-openrouter llama-index-embeddings-huggingface

Klucz do OpenRouter ustawiamy wyłącznie jako zmienną środowiskową — nigdy w kodzie:

1
$env:OPENROUTER_API_KEY = "twój-klucz"

Kompletny kod — main.py

Całość mieści się w jednym pliku:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
import os
from pathlib import Path

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

from llama_index.core import Document, Settings, VectorStoreIndex
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
from llama_index.llms.openrouter import OpenRouter

# --- konfiguracja modeli (część 2 serii) ---
Settings.embed_model = HuggingFaceEmbedding(
    model_name="sentence-transformers/all-MiniLM-L6-v2"
)
Settings.llm = OpenRouter(
    api_key=os.environ["OPENROUTER_API_KEY"],
    model="anthropic/claude-haiku-4.5",
)

app = FastAPI(title="Najprostszy RAG")

# indeks w pamięci — startuje pusty, znika przy restarcie
index = VectorStoreIndex.from_documents([])
zaindeksowane: list[str] = []


class IndexRequest(BaseModel):
    path: str          # ścieżka do pliku .md na lokalnym dysku


class AskRequest(BaseModel):
    question: str
    top_k: int = 3     # ile fragmentów trafi do modelu


@app.post("/index")
def index_file(req: IndexRequest):
    plik = Path(req.path)
    if not plik.is_file():
        raise HTTPException(404, f"Nie znaleziono pliku: {req.path}")
    if plik.suffix.lower() != ".md":
        raise HTTPException(400, "Obsługiwane są tylko pliki Markdown (.md)")

    tekst = plik.read_text(encoding="utf-8")
    dokument = Document(text=tekst, metadata={"plik": plik.name})
    index.insert(dokument)
    zaindeksowane.append(plik.name)
    return {"status": "ok", "plik": plik.name, "znakow": len(tekst)}


@app.get("/documents")
def list_documents():
    return {"dokumenty": zaindeksowane}


@app.post("/ask")
def ask(req: AskRequest):
    if not zaindeksowane:
        raise HTTPException(400, "Indeks jest pusty — najpierw dodaj dokument przez /index")

    query_engine = index.as_query_engine(similarity_top_k=req.top_k)
    wynik = query_engine.query(req.question)

    return {
        "odpowiedz": wynik.response,
        "zrodla": [
            {
                "plik": node.metadata.get("plik"),
                "trafnosc": round(node.score, 3),
                "fragment": node.text[:200],
            }
            for node in wynik.source_nodes
        ],
    }

Uruchomienie:

1
.venv\Scripts\uvicorn main:app --port 8000

Jak to działa — krok po kroku

Wszystko, co tu widać, było już w poprzednich częściach — nowe jest tylko opakowanie w HTTP:

  • /index — wczytuje plik z dysku, opakowuje w Document z nazwą pliku w metadanych i woła index.insert(). LlamaIndex sam tnie tekst na chunki i liczy embeddingi. Walidujemy istnienie pliku i rozszerzenie, zwracając czytelne błędy HTTP.
  • /documents — zwraca listę zaindeksowanych plików; przydatne, żeby sprawdzić, co RAG w ogóle „widzi".
  • /ask — tworzy query engine, zadaje pytanie i zwraca odpowiedź modelu razem ze źródłami: nazwą pliku, wynikiem podobieństwa i początkiem fragmentu. Dzięki temu odpowiedź da się zweryfikować — to jedna z największych zalet RAG nad „gołym" LLM.

Modele żyją w Settings (embeddingi lokalnie, LLM przez OpenRouter), a klucz API wchodzi przez os.environ — kod można spokojnie wrzucić do publicznego repozytorium.

Testujemy

FastAPI daje interaktywną dokumentację pod http://127.0.0.1:8000/docs, ale można też z terminala:

1
2
3
4
5
6
7
curl -X POST http://127.0.0.1:8000/index \
  -H "Content-Type: application/json" \
  -d '{"path": "C:/notatki/procedury.md"}'

curl -X POST http://127.0.0.1:8000/ask \
  -H "Content-Type: application/json" \
  -d '{"question": "Jak zresetować hasło użytkownika?"}'

Pierwsze wywołanie /index może chwilę potrwać — model embeddingów pobiera się przy pierwszym użyciu.

Czego tu celowo nie ma

Żeby całość pozostała zrozumiała, pominęliśmy wszystko, co w produkcyjnym RAG prędzej czy później się pojawia: persystencję indeksu (po restarcie indeksujemy od nowa), bazę wektorową, autoryzację API, obsługę innych formatów niż Markdown czy strojenie parametrów cięcia na chunki. Każda z tych rzeczy to naturalne rozszerzenie tego kodu — ale żadna nie jest potrzebna, żeby zrozumieć, jak działa RAG.

Podsumowanie

Trzy wpisy i mamy działający RAG: teoria (chunki, embeddingi, indeks), klocki LlamaIndex i na koniec ~80 linijek Pythona z dwoma endpointami. To dobry punkt startowy do eksperymentów z własnymi dokumentami — a jeśli myślisz o czymś podobnym w swojej firmie, odezwij się do nas.