Building a RAG Pipeline with Gemma 4 and ChromaDB: Implementation
A complete walkthrough of building a local RAG pipeline using Gemma 4 + Ollama + ChromaDB + LangChain. Includes production-ready Python code with error handling, cost comparison, and practical tips from real-world use.
When I first heard the phrase "local RAG," I was skeptical. My mental image was something like: slow responses, mediocre accuracy, endless configuration pain. I've been building AI-powered apps for a while now, and cloud APIs have always felt like the pragmatic choice.
Then Gemma 4 came out.
Running ollama run gemma4 for the first time and asking it a document-specific question — seeing it pull context from a local PDF and synthesize an accurate answer in under three seconds — genuinely changed my view. This guide is what I wish I'd had when starting out: a complete, working RAG pipeline implementation with all the sharp edges documented.
What RAG Actually Does (And Why It Matters for Apps)
RAG stands for Retrieval-Augmented Generation. The core idea is simple: before sending a query to an LLM, retrieve the most relevant text chunks from your own documents, and include them in the prompt as context.
Without RAG, an LLM can only answer from its training data. With RAG, it can answer from your data — internal documentation, user manuals, a company knowledge base, app review archives, whatever you have.
For indie app developers, this is especially powerful. Think of it as giving your support chatbot actual knowledge of your app, or building a "talk to this PDF" feature that doesn't send user data to a cloud API.
The four-stage pipeline I'll walk you through:
Load — Read documents from disk (PDF, TXT, Markdown)
Chunk — Split documents into overlapping segments
Retrieve — Embed the query and find the closest chunks via vector similarity
Generate — Send query + retrieved chunks to Gemma 4 for the final answer
Environment Setup
You'll need:
# Install Ollama (Mac)brew install ollama# Pull Gemma 4 (8B is fine for most uses; 27B if you have the VRAM)ollama pull gemma4# Python packagespip install langchain langchain-community langchain-ollama \ chromadb pypdf sentence-transformers
Verify everything works:
import ollamaresponse = ollama.chat( model="gemma4", messages=[{"role": "user", "content": "Respond with exactly: Ready."}])print(response["message"]["content"]) # Should print: Ready.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Full Python implementation of a 4-stage RAG pipeline: document loading → chunking → vector search → generation
✦Learn Gemma 4's accuracy ceiling vs. cloud LLMs — and where it genuinely outperforms them
✦Practical tips for latency tuning and chunk strategy that make real-world RAG actually usable
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
LangChain has document loaders for most formats. Here's a flexible loader that handles both plaintext and PDF:
from langchain.document_loaders import TextLoader, PyPDFLoaderfrom langchain.schema import Documentfrom pathlib import Pathfrom typing import Listimport logginglogging.basicConfig(level=logging.INFO)logger = logging.getLogger(__name__)def load_documents(paths: List[str]) -> List[Document]: """ Load documents from disk. Supports .txt, .md, and .pdf. Returns a flat list of LangChain Document objects. Missing or unreadable files are logged and skipped. """ docs: List[Document] = [] for path_str in paths: path = Path(path_str) if not path.exists(): logger.warning(f"File not found, skipping: {path}") continue try: if path.suffix == ".pdf": loader = PyPDFLoader(str(path)) else: loader = TextLoader(str(path), encoding="utf-8") loaded = loader.load() docs.extend(loaded) logger.info(f"Loaded {len(loaded)} page(s) from {path.name}") except Exception as e: logger.error(f"Failed to load {path}: {e}") continue logger.info(f"Total documents loaded: {len(docs)}") return docs
One thing that surprised me: PyPDFLoader extracts text page-by-page, so a 100-page PDF becomes 100 Document objects before chunking. That's fine — chunking handles it in the next step.
Stage 2: Chunking (And Why the Parameters Matter)
This is the step most tutorials gloss over, but it has the biggest impact on RAG quality.
The core tradeoff: smaller chunks = more precise retrieval, but less context per chunk. Larger chunks = richer context, but noisier retrieval.
For most documentation and technical content, I've settled on chunk_size=800 with chunk_overlap=100. This gives Gemma 4 enough context to synthesize an answer without retrieving irrelevant content from adjacent sections.
from langchain.text_splitter import RecursiveCharacterTextSplitterfrom langchain.schema import Documentfrom typing import Listdef split_documents( docs: List[Document], chunk_size: int = 800, chunk_overlap: int = 100) -> List[Document]: """ Split documents into overlapping chunks. chunk_size: target characters per chunk (not tokens — adjust if your model's tokenizer differs significantly) chunk_overlap: characters shared between adjacent chunks, preventing context loss at boundaries """ splitter = RecursiveCharacterTextSplitter( chunk_size=chunk_size, chunk_overlap=chunk_overlap, separators=["\n\n", "\n", ". ", " ", ""], ) chunks = splitter.split_documents(docs) logger.info(f"Split into {len(chunks)} chunks " f"(avg {sum(len(c.page_content) for c in chunks) // len(chunks)} chars)") return chunks
The separators list matters: RecursiveCharacterTextSplitter tries each separator in order, preferring to split at paragraph boundaries over mid-sentence. This dramatically reduces coherence loss at chunk edges.
When to adjust:
FAQ or bullet-point docs → smaller chunks (chunk_size=400) for tighter retrieval
Long-form narrative text → larger chunks (chunk_size=1200) to preserve argument structure
Code documentation → keep chunk_overlap higher (150–200) since code context is easy to lose
Stage 3: Building and Querying the Vector Store
ChromaDB is my go-to for local vector storage. It's persistent, has a straightforward API, and integrates cleanly with LangChain.
from langchain_community.vectorstores import Chromafrom langchain_ollama import OllamaEmbeddingsfrom langchain.schema import Documentfrom typing import List, Optional, Tupleimport osPERSIST_DIR = "./chroma_db"EMBED_MODEL = "nomic-embed-text" # Fast, good quality for RAGdef get_embeddings() -> OllamaEmbeddings: return OllamaEmbeddings(model=EMBED_MODEL)def create_vector_store(chunks: List[Document]) -> Chroma: """ Build ChromaDB from document chunks. Persists to disk automatically. This is slow on first run (embeds all chunks) — subsequent loads are instant. """ if not chunks: raise ValueError("No chunks provided. Load and split documents first.") logger.info(f"Embedding {len(chunks)} chunks with {EMBED_MODEL}...") store = Chroma.from_documents( documents=chunks, embedding=get_embeddings(), persist_directory=PERSIST_DIR, ) logger.info(f"Vector store created and persisted to {PERSIST_DIR}") return storedef load_existing_vector_store() -> Optional[Chroma]: """ Load a previously built vector store from disk. Returns None if no store exists yet. """ if not os.path.exists(PERSIST_DIR): logger.info("No existing vector store found.") return None store = Chroma( persist_directory=PERSIST_DIR, embedding_function=get_embeddings(), ) logger.info("Loaded existing vector store from disk.") return storedef retrieve_relevant_chunks( store: Chroma, query: str, k: int = 4, score_threshold: float = 0.3) -> List[Tuple[Document, float]]: """ Retrieve the top-k most relevant chunks for a query. Chunks below score_threshold are filtered out to avoid injecting irrelevant context into the prompt. """ results = store.similarity_search_with_relevance_scores(query, k=k) filtered = [(doc, score) for doc, score in results if score >= score_threshold] if not filtered: logger.warning(f"No chunks above threshold {score_threshold} for query: {query\!r}") else: logger.info(f"Retrieved {len(filtered)} chunks (scores: " f"{[f'{s:.2f}' for _, s in filtered]})") return filtered
The score_threshold filter is critical and often omitted in tutorials. Without it, if your query has no good match in the document store, you'll still inject the "least bad" chunks into the prompt — which actively confuses the model. Better to pass no context and let Gemma 4 say "I don't know" than to hallucinate on bad context.
Stage 4: Generation with Gemma 4
Here's where everything comes together. The key is building a clean, structured prompt that tells the model exactly how to use the retrieved context.
import ollamafrom typing import List, Tuplefrom langchain.schema import DocumentGEMMA4_MODEL = "gemma4"ANTIGRAVITY_MODEL = "gemma-2.5-pro-preview-05-06" # Cloud fallbackdef build_context(results: List[Tuple[Document, float]]) -> str: """Concatenate retrieved chunks into a single context block.""" if not results: return "" parts = [] for i, (doc, score) in enumerate(results, 1): source = doc.metadata.get("source", "unknown") parts.append(f"[Source {i} — {source} (relevance: {score:.2f})]\n{doc.page_content}") return "\n\n---\n\n".join(parts)def generate_answer( query: str, context: str, stream: bool = False) -> str: """ Generate an answer using local Gemma 4 via Ollama. The prompt structure is tuned for factual, document-grounded answers. Gemma 4 responds well to explicit instructions not to fabricate. """ if context: system_prompt = ( "You are a helpful assistant with access to specific document excerpts. " "Answer based on those excerpts only. " "If the answer isn't in the excerpts, say so clearly — do not guess." ) user_prompt = f"""Document excerpts:{context}---Question: {query}Answer based on the excerpts above.""" else: system_prompt = "You are a helpful assistant." user_prompt = ( f"The following question has no relevant document context available.\n\n" f"Question: {query}\n\n" f"Answer as best you can from general knowledge, and note that no documents were found." ) messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ] if stream: # Stream tokens to stdout for interactive use full_response = "" for chunk in ollama.chat(model=GEMMA4_MODEL, messages=messages, stream=True): token = chunk["message"]["content"] print(token, end="", flush=True) full_response += token print() # newline after stream ends return full_response else: response = ollama.chat(model=GEMMA4_MODEL, messages=messages) return response["message"]["content"]def generate_answer_cloud(query: str, context: str) -> str: """ Cloud fallback using Google Antigravity API. Use when Gemma 4 struggles with complex multi-hop reasoning. """ import google.generativeai as genai import os genai.configure(api_key=os.environ["GOOGLE_API_KEY"]) model = genai.GenerativeModel(ANTIGRAVITY_MODEL) if context: prompt = ( f"Document excerpts:\n{context}\n\n" f"---\n\nQuestion: {query}\n\n" f"Answer based on the excerpts above." ) else: prompt = query response = model.generate_content(prompt) return response.text
Putting It All Together
import osfrom pathlib import Pathdef run_rag_pipeline( document_paths: List[str], query: str, use_cloud: bool = False, stream: bool = True, force_rebuild: bool = False) -> str: """ Full RAG pipeline: load → chunk → retrieve → generate. document_paths: files to index (PDF, TXT, MD) query: the user's question use_cloud: use Antigravity API instead of local Gemma 4 stream: stream tokens to stdout (local Gemma 4 only) force_rebuild: ignore cached vector store and rebuild from scratch """ # --- Load or build vector store --- store = None if not force_rebuild: store = load_existing_vector_store() if store is None: docs = load_documents(document_paths) if not docs: return "Error: No documents could be loaded." chunks = split_documents(docs) store = create_vector_store(chunks) # --- Retrieve relevant chunks --- results = retrieve_relevant_chunks(store, query, k=4, score_threshold=0.3) context = build_context(results) # --- Generate answer --- if use_cloud: answer = generate_answer_cloud(query, context) else: answer = generate_answer(query, context, stream=stream) return answerdef main(): """Interactive RAG session.""" import sys # Default documents — modify as needed docs = [ "./docs/manual.pdf", "./docs/faq.txt", "./docs/changelog.md", ] # Filter to existing files only docs = [d for d in docs if Path(d).exists()] if not docs: print("No documents found in ./docs/ — place your files there and retry.") sys.exit(1) print(f"RAG pipeline ready. Documents: {[Path(d).name for d in docs]}") print("Type your question (or 'quit' to exit, 'rebuild' to re-index documents):\n") while True: try: query = input("You: ").strip() except (EOFError, KeyboardInterrupt): print("\nExiting.") break if not query: continue if query.lower() == "quit": break if query.lower() == "rebuild": run_rag_pipeline(docs, "warmup", force_rebuild=True, stream=False) print("Vector store rebuilt.\n") continue print("\nGemma 4: ", end="") answer = run_rag_pipeline(docs, query, stream=True) print()if __name__ == "__main__": main()
Performance Reality Check
After running this pipeline across several real projects, here's what I've actually observed:
Where Gemma 4 local RAG works well:
Single-hop factual retrieval from well-structured docs ("What is the refund policy?")
Code generation when the codebase is indexed ("How does the auth module work?")
Summarization of retrieved chunks
Where it struggles:
Multi-hop reasoning ("Which features added in v2 are relevant to the issue mentioned on page 47?")
Implicit inference across distant chunks
Very large documents where relevant content is sparse
For comparison, the same pipeline with Antigravity's Gemini 2.5 Pro handles multi-hop questions noticeably better, but adds ~800ms network latency and cloud API cost. I use Gemma 4 locally for development and testing, and offer cloud fallback as a production option for complex queries.
Common Issues and Fixes
OllamaEmbeddings raises ConnectionRefusedError
Ollama server isn't running. Run ollama serve in a separate terminal, or on Mac, launch Ollama from the app menu.
ChromaDB returns stale results after document updates
Chroma doesn't auto-update when source files change. Call run_rag_pipeline(..., force_rebuild=True) to re-index, or implement incremental updates by tracking file modification times.
Low relevance scores for all queries
Usually means the embedding model and your document type are mismatched. Try mxbai-embed-large instead of nomic-embed-text for longer documents:
ollama pull mxbai-embed-large
Then update EMBED_MODEL = "mxbai-embed-large" and rebuild the store.
Gemma 4 ignores the context and answers from training data
This happens with overly short or generic system prompts. Be explicit: tell the model it must cite the provided excerpts, and instruct it to respond with "not found in documents" rather than guessing. The system prompt in this guide is tuned for this.
A Note from an Indie Developer
Next Steps
The pipeline here is a complete, working foundation. From this base, the natural directions are:
Metadata filtering: Pass document category or date as ChromaDB filter conditions to narrow retrieval before similarity search
Hybrid search: Combine vector similarity with BM25 keyword search for better recall on technical terms
Reranking: After retrieving 8–10 candidates, use a cross-encoder to rerank before passing the top 4 to Gemma 4
Eval harness: Build a test set of query/expected-answer pairs to measure pipeline quality as you tune chunk size and retrieval parameters
The jump from "working demo" to "reliable production feature" is mostly an eval and iteration problem, not a model problem. Start measuring early.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.