Byg din første RAG-pipeline med Python og ChromaDB — trin for trin

Lær at bygge en komplet RAG-pipeline med Python, LangChain og ChromaDB — fra dokumentindlæsning og chunking til vektorsøgning og svargenerering med GPT-4o eller Claude.

Hvad er RAG, og hvorfor bør du kende det?

Hvis du har læst vores tidligere guide om at bygge en AI-agent med LangGraph, ved du allerede, hvordan man kan få en LLM til at handle autonomt. Men der er et ret fundamentalt problem med selv de mest avancerede sprogmodeller: de ved kun det, de er trænet på. Prøv at spørge ChatGPT eller Claude om dine interne virksomhedsdokumenter — du får enten en hallucination eller et ærligt "det ved jeg ikke."

Det er præcis her, RAG kommer ind i billedet.

RAG — Retrieval-Augmented Generation — er en arkitektur, der giver din LLM adgang til ekstern viden i realtid. I stedet for at stole udelukkende på modellens træningstidspunkt, henter et RAG-system først relevante dokumenter fra en vidensbase og injicerer dem i prompten, inden modellen genererer sit svar.

Tænk på det som forskellen mellem at tage en eksamen udelukkende fra hukommelsen og at have lov til at slå op i dine noter. Ret stor forskel, ikke?

I 2026 er RAG blevet standard-arkitekturen for stort set enhver seriøs AI-applikation, der skal arbejde med domænespecifik viden — alt fra kundeservice-chatbots til juridiske research-værktøjer og interne videnssystemer. Og det bedste er, at det faktisk ikke er så kompliceret at sætte op, som det måske lyder.

I denne guide bygger vi en komplet RAG-pipeline fra bunden med Python, ChromaDB som vektordatabase og enten OpenAI eller Claude som sprogmodel. Du får kørende kode, du kan bruge med det samme.

Forudsætninger og opsætning

Før vi går i gang, skal du have følgende klar:

  • Python 3.11 eller nyere
  • En API-nøgle fra OpenAI eller Anthropic (Claude)
  • Grundlæggende kendskab til Python

Det var det. Ikke noget fancy setup med Docker eller Kubernetes — vi holder det simpelt.

Installation af pakker

Opret et virtuelt miljø og installér de nødvendige pakker:

python -m venv rag-env
source rag-env/bin/activate  # macOS/Linux
# rag-env\Scripts\activate   # Windows

pip install langchain langchain-chroma langchain-openai langchain-anthropic
pip install langchain-text-splitters chromadb pypdf

Her er hvad hvert bibliotek gør:

  • langchain — det overordnede framework, der binder det hele sammen
  • langchain-chroma — den officielle ChromaDB-integration til LangChain
  • langchain-openai / langchain-anthropic — LLM-integrationer
  • langchain-text-splitters — værktøjer til at opdele tekst intelligent
  • chromadb — vores vektordatabase (version 1.5.x i 2026)
  • pypdf — til indlæsning af PDF-filer

Konfigurér miljøvariabler

# For OpenAI:
export OPENAI_API_KEY="din-openai-noegle"

# For Anthropic (Claude):
export ANTHROPIC_API_KEY="din-anthropic-noegle"

Sådan fungerer en RAG-pipeline (det store billede)

Inden vi dykker ned i koden, lad os lige tage et skridt tilbage og forstå de fem trin, der udgør en RAG-pipeline:

  1. Indlæsning — Hent dine dokumenter (PDF, tekst, web osv.)
  2. Chunking — Opdel dokumenterne i mindre stykker tekst
  3. Embedding — Konvertér hvert tekststykke til en matematisk vektor
  4. Lagring — Gem vektorerne i en vektordatabase (ChromaDB)
  5. Retrieval + Generation — Når en bruger stiller et spørgsmål, find de mest relevante chunks og send dem til LLM'en sammen med spørgsmålet

Hvert trin er kritisk. Kvaliteten af dit endelige svar afhænger direkte af kvaliteten i hvert enkelt led — en kæde er kun så stærk som sit svageste led, som man siger. Så lad os bygge det hele, ét trin ad gangen.

Trin 1: Indlæs dine dokumenter

Vi starter med at indlæse PDF-filer. LangChain har en lang række document loaders, men PyPDFLoader er den simpleste og mest pålidelige til PDF'er:

from langchain_community.document_loaders import PyPDFLoader
from pathlib import Path


def indlaes_dokumenter(mappe_sti: str) -> list:
    """Indlæs alle PDF-filer fra en mappe."""
    dokumenter = []
    mappe = Path(mappe_sti)

    for pdf_fil in mappe.glob("*.pdf"):
        loader = PyPDFLoader(str(pdf_fil))
        dokumenter.extend(loader.load())
        print(f"Indlæst: {pdf_fil.name} ({len(loader.load())} sider)")

    print(f"\nTotal: {len(dokumenter)} sider indlæst")
    return dokumenter


# Brug:
dokumenter = indlaes_dokumenter("./mine_dokumenter")

Hver indlæst side bliver et Document-objekt med to felter: page_content (selve teksten) og metadata (kildefil, sidetal osv.). Metadata er vigtigere end du måske tror — den lader os senere fortælle brugeren præcis, hvor svaret kommer fra. Det er guld værd, når brugerne vil verificere informationen.

Trin 2: Opdel teksten i chunks

Okay, her kommer vi til et af de absolut vigtigste valg i hele din RAG-pipeline: chunking-strategien.

Opdeler du i for store stykker, bliver retrieval upræcist, fordi irrelevant tekst blandes ind. Opdeler du i for små stykker, mister du kontekst, og svaret bliver fragmenteret. Det er en balancegang, og ærligt talt er det her, de fleste RAG-projekter enten lykkes eller fejler.

RecursiveCharacterTextSplitter er den anbefalede standard i 2026. Den forsøger at opdele ved naturlige grænser — først ved dobbelte linjeskift (afsnit), derefter enkelte linjeskift, derefter punktummer, og til sidst ved individuelle tegn:

from langchain_text_splitters import RecursiveCharacterTextSplitter


text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,
    chunk_overlap=200,
    length_function=len,
    separators=["\n\n", "\n", ". ", " ", ""]
)

chunks = text_splitter.split_documents(dokumenter)
print(f"Opdelt i {len(chunks)} chunks")

Hvorfor netop 1000 tegn og 200 overlap?

Der er ingen universel facitliste her, men disse værdier fungerer godt som udgangspunkt:

  • chunk_size=1000 — stor nok til at bevare kontekst, lille nok til præcis retrieval
  • chunk_overlap=200 — sikrer, at information, der spænder over to chunks, ikke går tabt

For teknisk dokumentation kan du eksperimentere med større chunks (1500-2000). For FAQ-agtige dokumenter fungerer mindre chunks (500-800) ofte bedre. Min erfaring er, at det bedste du kan gøre er at teste med dine egne data — der findes ingen magisk one-size-fits-all løsning.

Trin 3: Generér embeddings og gem i ChromaDB

Nu kommer den spændende del. Vi konverterer vores tekst-chunks til vektorer — matematiske repræsentationer, der fanger den semantiske betydning af teksten — og gemmer dem i ChromaDB.

from langchain_chroma import Chroma
from langchain_openai import OpenAIEmbeddings


# Opret embedding-funktion
embeddings = OpenAIEmbeddings(
    model="text-embedding-3-small"
)

# Opret ChromaDB-vektorstore med persistent lagring
vectorstore = Chroma.from_documents(
    documents=chunks,
    embedding=embeddings,
    persist_directory="./chroma_db",
    collection_name="mine_dokumenter"
)

print(f"Gemt {len(chunks)} chunks i ChromaDB")

Et par ting at bemærke her:

  • text-embedding-3-small er OpenAI's seneste embedding-model — den er billig, hurtig og giver solide resultater for de fleste use cases
  • persist_directory gemmer databasen på disk, så du ikke skal re-embedde hver gang du starter dit script (det sparer både tid og penge)
  • ChromaDB kører som standard in-process — ingen separat server nødvendig til udvikling

Indlæs en eksisterende database

Næste gang du kører dit script, kan du bare indlæse den gemte database i stedet for at oprette den forfra:

# Indlæs eksisterende ChromaDB
vectorstore = Chroma(
    persist_directory="./chroma_db",
    embedding_function=embeddings,
    collection_name="mine_dokumenter"
)

Trin 4: Implementér retrieval

Nu har vi en database fuld af vektorer. Lad os bygge retrieval-delen — altså den del, der finder de mest relevante chunks for et givet spørgsmål:

# Opret en retriever
retriever = vectorstore.as_retriever(
    search_type="similarity",
    search_kwargs={"k": 4}
)

# Test retrieval
resultater = retriever.invoke("Hvad er virksomhedens returpolitik?")
for i, doc in enumerate(resultater):
    print(f"\n--- Chunk {i+1} ---")
    print(f"Kilde: {doc.metadata.get('source', 'Ukendt')}")
    print(f"Side: {doc.metadata.get('page', 'N/A')}")
    print(f"Indhold: {doc.page_content[:200]}...")

Parameteren k=4 bestemmer, hvor mange chunks der returneres. Flere chunks giver mere kontekst, men fylder også mere i LLM'ens kontekstvindue (og koster mere). Fire er en fornuftig standard — eksperimentér med 3-6 afhængigt af dine dokumenter.

Avanceret: MMR-retrieval for mere diversitet

Hvis dine dokumenter indeholder mange lignende afsnit, kan du bruge Maximal Marginal Relevance (MMR) i stedet for simpel similarity search. MMR balancerer relevans med diversitet, så du ikke ender med fire chunks, der alle siger det samme — det har jeg selv oplevet mere end én gang.

retriever = vectorstore.as_retriever(
    search_type="mmr",
    search_kwargs={
        "k": 4,
        "fetch_k": 10,
        "lambda_mult": 0.7
    }
)

fetch_k=10 henter først 10 kandidater, og lambda_mult=0.7 vægter relevans (1.0) over diversitet (0.0). En værdi på 0.7 giver en god balance for de fleste scenarier.

Trin 5: Generér svar med en LLM

Nu sætter vi det hele sammen. Vi tager de hentede chunks, kombinerer dem med brugerens spørgsmål i en prompt og sender det hele til en LLM:

from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough


# Vælg din LLM
llm = ChatOpenAI(model="gpt-4o", temperature=0)

# Alternativt: brug Claude
# from langchain_anthropic import ChatAnthropic
# llm = ChatAnthropic(model="claude-sonnet-4-5-20250929", temperature=0)

# Definer RAG-prompten
rag_prompt = ChatPromptTemplate.from_template("""
Du er en hjælpsom assistent, der besvarer spørgsmål baseret på den givne kontekst.
Brug KUN informationen fra konteksten til at svare. Hvis konteksten ikke
indeholder svaret, sig det ærligt.

Kontekst:
{context}

Spørgsmål: {question}

Svar:
""")


# Hjælpefunktion til at formatere chunks
def formater_dokumenter(docs):
    return "\n\n---\n\n".join(doc.page_content for doc in docs)


# Byg RAG-kæden
rag_chain = (
    {"context": retriever | formater_dokumenter, "question": RunnablePassthrough()}
    | rag_prompt
    | llm
    | StrOutputParser()
)

# Stil et spørgsmål
svar = rag_chain.invoke("Hvad er virksomhedens returpolitik?")
print(svar)

Det, der sker her, er faktisk ret elegant: brugerens spørgsmål sendes parallelt til retrieveren (der finder relevante chunks) og direkte ind som question. De hentede chunks formateres og indsættes som context. Hele pakken sendes til LLM'en, der genererer et svar baseret udelukkende på den kontekst, den har fået.

Kort sagt: retrieval sørger for, at modellen har de rigtige fakta, og generation sørger for, at svaret er velformuleret.

Trin 6: Den komplette pipeline samlet

Lad os samle alt i ét komplet, køreklart script. Kopiér det og tilpas det til dit eget projekt:

"""
Komplet RAG-pipeline med Python, LangChain og ChromaDB.
"""
from pathlib import Path
from langchain_community.document_loaders import PyPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_chroma import Chroma
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough


# --- Konfiguration ---
DOCS_DIR = "./mine_dokumenter"
CHROMA_DIR = "./chroma_db"
COLLECTION = "mine_dokumenter"
CHUNK_SIZE = 1000
CHUNK_OVERLAP = 200
TOP_K = 4


def indlaes_og_chunk(mappe: str) -> list:
    """Indlæs PDF-filer og opdel i chunks."""
    dokumenter = []
    for pdf in Path(mappe).glob("*.pdf"):
        dokumenter.extend(PyPDFLoader(str(pdf)).load())

    splitter = RecursiveCharacterTextSplitter(
        chunk_size=CHUNK_SIZE,
        chunk_overlap=CHUNK_OVERLAP
    )
    return splitter.split_documents(dokumenter)


def opret_vectorstore(chunks: list) -> Chroma:
    """Opret eller indlæs ChromaDB-vectorstore."""
    embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

    if Path(CHROMA_DIR).exists():
        print("Indlæser eksisterende database...")
        return Chroma(
            persist_directory=CHROMA_DIR,
            embedding_function=embeddings,
            collection_name=COLLECTION
        )

    print(f"Opretter ny database med {len(chunks)} chunks...")
    return Chroma.from_documents(
        documents=chunks,
        embedding=embeddings,
        persist_directory=CHROMA_DIR,
        collection_name=COLLECTION
    )


def opret_rag_chain(vectorstore: Chroma):
    """Byg den komplette RAG-kæde."""
    retriever = vectorstore.as_retriever(
        search_type="similarity",
        search_kwargs={"k": TOP_K}
    )

    prompt = ChatPromptTemplate.from_template("""
Du er en hjælpsom assistent. Besvar spørgsmålet baseret på konteksten.
Brug KUN information fra konteksten. Hvis du ikke kan finde svaret, sig det.

Kontekst:
{context}

Spørgsmål: {question}
""")

    llm = ChatOpenAI(model="gpt-4o", temperature=0)

    def formater_docs(docs):
        return "\n\n---\n\n".join(d.page_content for d in docs)

    return (
        {"context": retriever | formater_docs, "question": RunnablePassthrough()}
        | prompt
        | llm
        | StrOutputParser()
    )


if __name__ == "__main__":
    # 1. Indlæs og chunk dokumenter
    chunks = indlaes_og_chunk(DOCS_DIR)
    print(f"Fundet {len(chunks)} chunks")

    # 2. Opret vectorstore
    vectorstore = opret_vectorstore(chunks)

    # 3. Byg RAG-kæde
    rag = opret_rag_chain(vectorstore)

    # 4. Interaktiv loop
    print("\nRAG-pipeline klar! Skriv dit spørgsmål (eller 'quit' for at stoppe):\n")
    while True:
        spoergsmaal = input(">> ")
        if spoergsmaal.lower() in ("quit", "exit", "q"):
            break
        svar = rag.invoke(spoergsmaal)
        print(f"\n{svar}\n")

Optimering og best practices

Den pipeline, vi har bygget ovenfor, fungerer fint til udvikling og mindre projekter. Men hvis du vil tage det videre — og det skal du nok på et tidspunkt — er her de vigtigste optimeringer.

Vælg den rigtige chunking-strategi

Chunking er nok den mest undervurderede beslutning i et RAG-system. Her er en oversigt over de vigtigste strategier:

  • RecursiveCharacterTextSplitter — den bedste standard til generel tekst
  • SemanticChunker — bruger embeddings til at finde naturlige semantiske grænser. Giver bedre resultater, men er langsommere og dyrere
  • MarkdownTextSplitter — ideel hvis dine dokumenter er i Markdown-format
  • PythonCodeTextSplitter — til kodedokumentation, da den splitter ved logiske kodeblokke

Brug hybrid søgning

Ren vektorsøgning er god til semantisk lighed, men den kan misse eksakte nøgleord. Det er en af de ting, der overrasker mange, når de først begynder at teste med rigtige data. Hybrid søgning kombinerer vektorsøgning med traditionel keyword-søgning og giver typisk bedre resultater:

from langchain.retrievers import EnsembleRetriever
from langchain_community.retrievers import BM25Retriever

# Keyword-baseret retriever
bm25_retriever = BM25Retriever.from_documents(chunks)
bm25_retriever.k = 4

# Vektor-baseret retriever
vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 4})

# Kombinér begge
hybrid_retriever = EnsembleRetriever(
    retrievers=[bm25_retriever, vector_retriever],
    weights=[0.4, 0.6]
)

Tilføj metadata-filtrering

ChromaDB understøtter metadata-filtrering, så du kan begrænse søgningen til bestemte dokumenter, datoer eller kategorier. Det er især nyttigt, når din vidensbase vokser:

resultater = vectorstore.similarity_search(
    "returpolitik",
    k=4,
    filter={"source": "kundeservice-manual.pdf"}
)

Overvej reranking

En reranker scorer de hentede chunks med en mere avanceret model og sorterer dem, så de mest relevante kommer først. Det tilføjer lidt latens, men forbedrer kvaliteten mærkbart — især for komplekse spørgsmål, hvor den første retrieval ikke altid rammer plet.

Typiske fejl og hvordan du undgår dem

Efter at have bygget (og fejlrettet) en del RAG-systemer, er her de fejl jeg ser igen og igen:

  • For store chunks — Du ender med irrelevant tekst i din kontekst, og LLM'en bliver forvirret. Start med 1000 tegn og justér ned om nødvendigt
  • Ingen overlap — Information, der spænder over to chunks, går simpelthen tabt. Brug altid mindst 10-20% overlap
  • Ingen metadata — Uden metadata kan du ikke fortælle brugeren, hvor svaret kommer fra. Bevar altid kilde og sidetal
  • Forkert prompt-design — Glem ikke at instruere LLM'en om kun at bruge den givne kontekst. Ellers hallucerer den gladelig videre
  • Ingen evaluering — Du kan ikke forbedre det, du ikke måler. Brug et framework som RAGAS til at evaluere retrieval-præcision, faithfulness og svar-relevans

Den sidste fejl er nok den mest udbredte. Mange springer evalueringen over, fordi "det virker jo fint" — indtil det ikke gør.

Hvad er næste skridt?

Du har nu en fungerende RAG-pipeline. Tillykke — det er faktisk en ret solid grundsten at bygge videre på. Her er nogle naturlige næste trin:

  • Agentic RAG — kombinér din RAG-pipeline med en AI-agent (som den vi byggede med LangGraph), så agenten selv kan beslutte, hvornår og hvordan den søger
  • Streaming — brug rag_chain.stream() i stedet for invoke() for at vise svaret ord for ord. Det giver en meget bedre brugeroplevelse
  • Multi-modal RAG — tilføj support for billeder og tabeller med en vision-model
  • Samtalehistorik — tilføj hukommelse, så brugeren kan stille opfølgende spørgsmål uden at gentage konteksten

Ofte stillede spørgsmål

Hvad er forskellen mellem RAG og fine-tuning?

RAG henter ekstern viden ved hvert kald og er ideel til dynamisk data, der ændrer sig ofte. Fine-tuning ændrer selve modellens vægte og er bedst til at ændre modellens adfærd eller stil. I 2026 bruger de mest robuste systemer faktisk begge dele: fine-tuning til format og tone, RAG til aktuel viden.

Har jeg brug for en vektordatabase, eller kan jeg klare mig uden?

Til prototyper og små datasæt (under 10.000 chunks) kan du faktisk klare dig med NumPy eller scikit-learn til similarity search i hukommelsen. Men til produktion med større datasæt er en vektordatabase som ChromaDB, Qdrant eller Weaviate nødvendig for at få ordentlig performance og persistens.

Hvor mange chunks skal jeg hente (k-værdien)?

Start med k=4 og eksperimentér derfra. Færre chunks (2-3) giver mere fokuserede svar, men risikerer at misse relevant information. Flere chunks (6-8) giver bredere kontekst, men kan forvirre modellen med irrelevant tekst. Den optimale værdi afhænger af dine dokumenters struktur og spørgsmålenes kompleksitet.

Kan jeg bruge RAG med lokale modeller uden API-omkostninger?

Ja, absolut. Du kan erstatte OpenAI med en lokal model via Ollama og bruge SentenceTransformers til embeddings. ChromaDB kører allerede lokalt. Hele pipelinen kan køre på din egen hardware uden at sende data til eksterne tjenester — det er ideelt til følsomme data, hvor du ikke vil (eller må) sende noget ud af huset.

Hvordan evaluerer jeg kvaliteten af min RAG-pipeline?

Brug RAGAS-frameworket, som måler fire nøglemetrikker: Context Precision (hentes de rigtige chunks?), Context Recall (hentes alle relevante chunks?), Faithfulness (er svaret baseret på konteksten?) og Answer Relevancy (er svaret relevant for spørgsmålet?). De fire metrikker tilsammen giver dig et godt overblik over din pipelines sundhedstilstand.

Om Forfatteren Editorial Team

Our team of expert writers and editors.