DS DevShelfHub Projects · AI tools
Tutorials / Run LLMs Locally / Local RAG Pipeline
Run LLMs Locally Intermediate · 18 min read Page 9 of 9

How to Build a Local RAG Pipeline with LangChain and Ollama

By DevShelfHub

Build a fully local question-answering system over your documents — no cloud dependencies, no API costs, complete privacy. End-to-end walkthrough with working code.

Series progress9 / 9
Local RAG pipeline tutorial — private document Q&A with Ollama and ChromaDB

What you're building

A command-line chatbot that answers questions about your documents — PDFs, text files, anything. You load documents once, then query them instantly using a local LLM. No internet required, no API keys, complete privacy.

By the end, you'll have two Python scripts: ingest.py (loads and indexes documents) and rag.py (queries them). You'll run ingest once, rag as many times as you want.

Prerequisites

Python

Python 3.10+. Check your version: python --version

Ollama running locally

Download and install from ollama.ai. Then start it with ollama serve in a separate terminal — it runs on port 11434 by default. Leave it running for the entire tutorial.

Hardware

CPU-only: 8GB RAM minimum (16GB recommended). GPU: 4GB VRAM is enough. This tutorial uses nomic-embed-text (686 MB) + llama3.1:8b (4.7 GB), which fit on most machines.

Project setup

1. Create a project directory and install dependencies

Bash
mkdir local-rag && cd local-rag
pip install langchain-ollama langchain-community langchain-text-splitters chromadb pypdf

2. Create the project structure

Text
local-rag/
├── docs/          ← Put your PDFs here
├── ingest.py      ← Run once to index documents
├── rag.py         ← Run repeatedly to query
└── chroma_db/     ← Created automatically by ingest.py

Download the models you'll need

Before running any code, pull the two models that the pipeline uses: an embedding model (for converting text to numbers) and a chat model (for generating answers).

Bash
ollama pull nomic-embed-text    # 686 MB, 1-2 min
ollama pull llama3.1:8b         # 4.7 GB, 5-10 min depending on connection
Make sure Ollama is still running from the ollama serve terminal. If it's not, these commands will fail.

Step 1: Create ingest.py (indexes your documents)

This script reads PDFs from the docs/ folder, splits them into chunks, embeds them with the local model, and saves the vector store to disk for reuse.

Python
from langchain_community.document_loaders import PyPDFLoader, DirectoryLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_ollama import OllamaEmbeddings
from langchain_community.vectorstores import Chroma

DOCS_DIR = "./docs"
CHROMA_DIR = "./chroma_db"
EMBED_MODEL = "nomic-embed-text"

def ingest():
    # Load all PDFs from docs/ directory
    loader = DirectoryLoader(DOCS_DIR, glob="**/*.pdf", loader_cls=PyPDFLoader)
    raw_docs = loader.load()
    print(f"Loaded {len(raw_docs)} pages from {DOCS_DIR}")

    # Split into overlapping chunks (512 tokens = good default)
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=512,
        chunk_overlap=64,
        separators=["\n\n", "\n", ".", " ", ""],
    )
    chunks = splitter.split_documents(raw_docs)
    print(f"Split into {len(chunks)} chunks")

    # Embed with local model
    embeddings = OllamaEmbeddings(model=EMBED_MODEL)

    # Save to disk — reusable across runs
    vectorstore = Chroma.from_documents(
        documents=chunks,
        embedding=embeddings,
        persist_directory=CHROMA_DIR,
    )
    print(f"Stored {len(chunks)} chunks in {CHROMA_DIR}")

if __name__ == "__main__":
    ingest()

How to run:

  1. Create docs/ folder and add one or more PDFs
  2. Run: python ingest.py
  3. Wait for indexing to complete (1–5 min depending on document size)
  4. You'll see: Stored 127 chunks in ./chroma_db

Step 2: Create rag.py (query your documents)

This script loads the vector store and runs a loop that lets you ask questions. It retrieves relevant chunks and sends them to the local LLM, which generates answers.

Python
from langchain_ollama import OllamaEmbeddings, ChatOllama
from langchain_community.vectorstores import Chroma
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough

CHROMA_DIR = "./chroma_db"
EMBED_MODEL = "nomic-embed-text"
CHAT_MODEL = "llama3.1:8b"

# Load the vector store from disk
embeddings = OllamaEmbeddings(model=EMBED_MODEL)
vectorstore = Chroma(persist_directory=CHROMA_DIR, embedding_function=embeddings)
retriever = vectorstore.as_retriever(search_kwargs={"k": 4})

# System prompt — tells the LLM to use only the provided context
prompt = ChatPromptTemplate.from_messages([
    ("system", """You are a helpful assistant. Answer the question using ONLY the
context provided below. If the answer is not in the context, say so clearly.

Context:
{context}"""),
    ("human", "{question}"),
])

llm = ChatOllama(model=CHAT_MODEL, temperature=0)

def format_docs(docs):
    return "\n\n---\n\n".join(doc.page_content for doc in docs)

# RAG chain: retrieve → format → prompt → LLM → output
rag_chain = (
    {"context": retriever | format_docs, "question": RunnablePassthrough()}
    | prompt
    | llm
    | StrOutputParser()
)

if __name__ == "__main__":
    while True:
        q = input("\nQuestion (or 'quit'): ").strip()
        if q.lower() == "quit":
            break
        answer = rag_chain.invoke(q)
        print(f"\nAnswer: {answer}")

How to run:

  1. Ensure you've run ingest.py first
  2. Run: python rag.py
  3. Type a question, press Enter, wait for the answer
  4. Type quit to exit

Expected output and example interaction

Sample ingest.py output:

Text
$ python ingest.py
Loaded 15 pages from ./docs
Split into 127 chunks
Stored 127 chunks in ./chroma_db

Sample rag.py interaction:

Text
$ python rag.py

Question (or 'quit'): What is the main topic of the document?
Answer: The document discusses machine learning fundamentals, including supervised and
unsupervised learning approaches, with emphasis on neural networks.

Question (or 'quit'): How does gradient descent work?
Answer: Gradient descent is an optimization algorithm that iteratively updates model
parameters in the direction of steepest descent to minimize the loss function...

Next: Enhance your pipeline (optional)

The basic setup above works. The sections below add optional improvements — use them only if you need better quality.

Enhancement 1: Optimize chunking for your documents

The default chunk size of 512 tokens is a safe starting point, but different documents benefit from different sizes. The key insight: there's a tradeoff between precision (small chunks) and context (large chunks).

Chunk sizeBest forHow to adjust
128–256 tokensPrecise Q&A, facts, structured contentIn ingest.py, change chunk_size=256
512 tokens (default)General-purpose, mixed documentsUse as-is. This is the safe choice.
1024+ tokensLong-form content, narratives, booksChange chunk_size=1024
Always keep chunk_overlap at 10–15% of chunk_size. For 512-token chunks, use overlap=64. This prevents key sentences from being split and never retrieved together.

Enhancement 2: Hybrid search (keyword + semantic)

Vector search excels at semantic similarity but misses exact keyword matches. Hybrid search runs both in parallel, then merges results with Reciprocal Rank Fusion (RRF). This usually outperforms either method alone and is especially effective for product names, error codes, and proper nouns.

How to enable: Replace only the retriever setup in rag.py. Everything else stays the same.

First, install the BM25 library:

Bash
pip install rank-bm25

Then replace the retriever setup in rag.py with this:

Python
# Import at the top
from langchain_community.retrievers import BM25Retriever
from langchain.retrievers import EnsembleRetriever

# Load the vector store
embeddings = OllamaEmbeddings(model=EMBED_MODEL)
vectorstore = Chroma(persist_directory=CHROMA_DIR, embedding_function=embeddings)

# BM25 needs chunks (re-embed from your documents)
from langchain_community.document_loaders import DirectoryLoader, PyPDFLoader
loader = DirectoryLoader("./docs", glob="**/*.pdf", loader_cls=PyPDFLoader)
raw_docs = loader.load()
splitter = RecursiveCharacterTextSplitter(chunk_size=512, chunk_overlap=64)
chunks = splitter.split_documents(raw_docs)

# Create both retrievers
bm25_retriever = BM25Retriever.from_documents(chunks)
bm25_retriever.k = 4
vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 4})

# Ensemble: 40% keyword, 60% semantic (adjust weights for your use case)
retriever = EnsembleRetriever(
    retrievers=[bm25_retriever, vector_retriever],
    weights=[0.4, 0.6],
)

Enhancement 3: Reranking (improve answer quality)

Retrieval returns top-k candidates — but not all are equally relevant. A reranker uses a smaller, cross-encoder model to re-score results for higher precision. The pattern: retrieve many, rerank to few. This costs minimal overhead and significantly improves quality.

Install and integrate:

Bash
pip install sentence-transformers

Add this to rag.py before the rag_chain definition:

Python
from sentence_transformers import CrossEncoder
from langchain_core.documents import Document

reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")

def rerank(query: str, docs: list[Document], top_n: int = 3) -> list[Document]:
    pairs = [(query, doc.page_content) for doc in docs]
    scores = reranker.predict(pairs)
    ranked = sorted(zip(scores, docs), key=lambda x: x[0], reverse=True)
    return [doc for _, doc in ranked[:top_n]]

# Retrieve 8, rerank to top 3
broad_retriever = vectorstore.as_retriever(search_kwargs={"k": 8})

def retrieve_and_rerank(query):
    docs = broad_retriever.invoke(query)
    return rerank(query, docs, top_n=3)

# Update rag_chain to use reranking
rag_chain = (
    {"context": (lambda q: format_docs(retrieve_and_rerank(q))),
     "question": RunnablePassthrough()}
    | prompt | llm | StrOutputParser()
)
cross-encoder/ms-marco-MiniLM-L-6-v2 is 23 MB and runs on CPU. It's a good default for local pipelines.

Enhancement 4: Return source citations

Always show which document chunks were used. This builds trust and lets users verify answers — especially important for local models that may hallucinate more than cloud models.

Replace the main block of rag.py with this:

Python
from langchain_core.runnables import RunnableParallel

# Add this after the rag_chain definition
rag_with_sources = RunnableParallel(
    {"answer": rag_chain, "source_docs": retriever}
)

if __name__ == "__main__":
    while True:
        q = input("\nQuestion (or 'quit'): ").strip()
        if q.lower() == "quit":
            break
        result = rag_with_sources.invoke(q)

        print(f"\nAnswer: {result['answer']}")
        print("\nSources:")
        for doc in result["source_docs"]:
            src = doc.metadata.get("source", "unknown")
            page = doc.metadata.get("page", "?")
            print(f"  • {src} (page {page})")

Deployment: Docker (when to use)

Use Docker if: You want to share this with team members, run it on a server, or ensure reproducibility. Skip if: You're just testing locally on your laptop.

Docker packages your code + Python + all dependencies into a container. You run docker compose instead of managing Ollama and Python separately.

Create docker-compose.yml in your project root:

Yaml
services:
  ollama:
    image: ollama/ollama:latest
    ports:
      - "11434:11434"
    volumes:
      - ~/.ollama:/root/.ollama   # persist models so you don't re-download
    restart: unless-stopped

  rag-app:
    build: .
    depends_on:
      - ollama
    environment:
      - OLLAMA_BASE_URL=http://ollama:11434
    volumes:
      - ./docs:/app/docs
      - ./chroma_db:/app/chroma_db

Create Dockerfile in your project root:

Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip3 install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "rag.py"]

Create requirements.txt:

Text
langchain-ollama
langchain-community
langchain-text-splitters
chromadb
pypdf

Run the entire stack:

Bash
# Start Ollama and the app
docker compose up -d

# Pull models (run in one of the containers)
docker compose exec ollama ollama pull nomic-embed-text
docker compose exec ollama ollama pull llama3.1:8b

# Run ingestion
docker compose exec rag-app python ingest.py

# Run the chatbot
docker compose exec rag-app python rag.py

Troubleshooting

"Connection refused" or "Cannot connect to Ollama"

Fix: Make sure Ollama is running. Open a terminal and run ollama serve. Leave it running while using the RAG pipeline.

Model not found / "no such model" error

Fix: Pull the models first. Run: ollama pull nomic-embed-text and ollama pull llama3.1:8b

Empty docs/ folder or no PDFs found

Fix: Create a docs/ folder in your project directory and add PDF files to it before running ingest.py. Check that files are actually in the folder: ls docs/

Answers are irrelevant or hallucinated

Diagnosis: The system prompt tells the model to use only context, but local models may ignore this. Fixes: (1) Try stronger language: change "answer using ONLY the context" to "NEVER use knowledge outside the context. If it's not in the context, say 'I don't know'". (2) Check your documents — are they relevant to your questions? (3) Try smaller chunk_size (256 instead of 512).

Slow performance / waiting forever for answers

Causes: llama3.1:8b is large and slow on CPU. Quick fixes: (1) Use a smaller model: ollama pull mistral:7b then change CHAT_MODEL in rag.py. (2) Run on GPU if available. (3) For CPU, reduce context window: change retriever to search_kwargs={"k": 2} instead of 4.

Chroma persists from old data after re-indexing

Fix: Chroma appends to the existing database. If you re-run ingest.py with new documents, you'll have duplicates. Delete chroma_db/ folder before re-ingesting: rm -rf chroma_db/

Production tips that move the needle

Once the default pipeline is running, these are the changes that most consistently improve answer quality and retrieval precision on real document sets. Apply in order — each one is independent.

1. Tune chunk size to your content type

Default 512 tokens fits most docs, but 256-token chunks (with 64-token overlap) sharpen retrieval on dense technical text where each paragraph stands alone. Long-form narrative or legal text often benefits from 1024-token chunks so the model sees enough context. Measure precision@5 on a held-out question set before and after the change.

2. Add a reranker between retrieval and generation

Retrieve top-20 with vector search, then rerank to top-5 with a cross-encoder like bge-reranker-base. Cross-encoders are slower per pair but much more accurate at "is this chunk actually about the query?" — and they run locally too. Expected precision lift: 15–30%.

3. Tag chunks with metadata you can filter on

Add source, section, date, and doc_type to every chunk at ingest time. ChromaDB metadata filters then narrow the search space before the embedding similarity step — cheaper and more precise than relying on embeddings alone.

4. Force citations in the system prompt

Add: "After each claim, cite the source like [doc-name §section]. If the context doesn't contain the answer, respond 'I don't know based on the provided documents.'" This single line turns Llama 3.1 8B from a confident bullshitter into a careful researcher on most factual queries.

5. Cache embeddings, not just answers

Computing embeddings for every chunk on every re-ingest is wasteful. Hash the chunk text and skip embedding any chunk you've already seen. For a 10k-chunk corpus this drops re-ingest from minutes to seconds and is a one-line change with a local SQLite index.

6. Evaluate, don't eyeball

Build a 30–50 question evaluation set with expected answers and source chunks. Re-run it after every config change and track recall@k, precision@k, and answer faithfulness over time. Without this, every "improvement" is a guess.

Local RAG Pipeline FAQ

What is a local RAG pipeline?

A local RAG (Retrieval-Augmented Generation) pipeline indexes your documents into a vector store, retrieves relevant chunks at query time, and sends them to a local LLM for answer generation — all running on your machine with no cloud dependencies.

What hardware do I need for a local RAG pipeline?

8 GB RAM minimum (16 GB recommended) for CPU-only, or 4 GB VRAM for GPU. This tutorial uses nomic-embed-text (686 MB) and llama3.1:8b (4.7 GB), which fit on most modern machines.

What is hybrid search in RAG?

Hybrid search runs both keyword (BM25) and semantic (vector) retrieval in parallel, then merges results with Reciprocal Rank Fusion. It outperforms either method alone, especially for exact keyword matches like product names, error codes, and proper nouns.

Why are my local RAG answers irrelevant or hallucinated?

Local models may ignore the system prompt and answer from training data. Fixes include stronger prompting, smaller chunk sizes (256 instead of 512) for more precise retrieval, and adding a reranker to filter low-relevance chunks before generation.

Can I deploy a local RAG pipeline with Docker?

Yes. Docker Compose can package Ollama and your RAG app into containers, making the setup reproducible and shareable. Models persist in a volume so you don't re-download them on each restart.

This tutorial builds on the LangChain with local models guide — review it for ChatOllama and OllamaEmbeddings fundamentals. If your answers aren't reliable enough, understand why in the limitations of local models tutorial. New to Ollama? Start with the Ollama setup guide.

Quick summary

  • Goal: Build a local Q&A system over your documents
  • Setup: pip install dependencies, create docs/ folder, run ollama serve
  • Two scripts: ingest.py (run once) and rag.py (run anytime to query)
  • Default works: 512-token chunks, vector search, llama3.1:8b LLM
  • Optional enhancements: Chunk size tuning, hybrid search, reranking, source citations
  • Docker: Use if sharing with others or deploying to servers
  • If stuck: Check Ollama is running, models are pulled, and docs/ has files