a Local RAG Pipeline with ChromaDB and Sentence Transformers
Implement retrieval-augmented generation locally with ChromaDB, sentence-transformers embeddings, and LLM generation without external API dependencies
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Build a Local RAG Pipeline with ChromaDB and Sentence Transformers
RAG (Retrieval-Augmented Generation) combines document retrieval with LLM generation. ChromaDB is a local vector database that runs in-process — no external service needed. Paired with sentence-transformers for local embeddings, you can build a complete RAG pipeline without API keys or cloud dependencies. Below: chunking, embedding, retrieval, and generation.
When to Use This
- Private document Q&A where data cannot leave your machine
- Prototyping RAG pipelines before scaling to managed vector databases
- Offline or air-gapped environments
Prerequisites
- Python 3.10+
chromadbpackage (pip install chromadb)sentence-transformerspackage (pip install sentence-transformers)- An LLM (OpenAI API, Ollama, or any LLM provider)
Solution
1. Install Dependencies
pip install chromadb sentence-transformers
2. Document Chunking
from dataclasses import dataclass
@dataclass
class Chunk:
id: str
text: str
metadata: dict
def chunk_text(
text: str,
chunk_size: int = 500,
overlap: int = 50,
) -> list[Chunk]:
"""Split text into overlapping chunks.
Args:
text: Input text to chunk.
chunk_size: Target characters per chunk.
overlap: Number of characters to overlap between chunks.
Returns:
List of Chunk objects.
"""
chunks = []
start = 0
chunk_idx = 0
while start < len(text):
end = start + chunk_size
chunk_text_str = text[start:end]
chunks.append(Chunk(
id=f"chunk_{chunk_idx}",
text=chunk_text_str,
metadata={"chunk_index": chunk_idx, "char_start": start},
))
start = end - overlap
chunk_idx += 1
return chunks
def chunk_documents(documents: list[dict]) -> list[Chunk]:
"""Chunk multiple documents."""
all_chunks = []
for doc in documents:
chunks = chunk_text(doc["text"])
for chunk in chunks:
chunk.metadata.update({
"source": doc.get("source", "unknown"),
"title": doc.get("title", ""),
})
all_chunks.extend(chunks)
return all_chunks
3. Initialize ChromaDB with Local Embeddings
import chromadb
class LocalRAG:
def __init__(self, persist_dir: str = "./chroma_db"):
self.client = chromadb.PersistentClient(path=persist_dir)
self.collection = self.client.get_or_create_collection(
name="documents",
metadata={"hnsw:space": "cosine"},
)
def add_chunks(self, chunks: list[Chunk]) -> None:
"""Add chunks to the ChromaDB collection."""
self.collection.add(
ids=[c.id for c in chunks],
documents=[c.text for c in chunks],
metadatas=[c.metadata for c in chunks],
)
def query(
self,
query_text: str,
n_results: int = 5,
) -> list[dict]:
"""Retrieve relevant chunks for a query.
Args:
query_text: User query.
n_results: Number of chunks to retrieve.
Returns:
List of {text, metadata, distance} dicts.
"""
results = self.collection.query(
query_texts=[query_text],
n_results=n_results,
)
return [
{
"text": doc,
"metadata": meta,
"distance": dist,
}
for doc, meta, dist in zip(
results["documents"][0],
results["metadatas"][0],
results["distances"][0],
)
]
4. Build the RAG Pipeline
from openai import OpenAI
class RAGPipeline:
def __init__(self, persist_dir: str = "./chroma_db"):
self.rag = LocalRAG(persist_dir)
self.llm = OpenAI()
def ingest(self, documents: list[dict]) -> None:
"""Ingest documents into the vector store."""
chunks = chunk_documents(documents)
self.rag.add_chunks(chunks)
print(f"Ingested {len(chunks)} chunks from {len(documents)} documents")
def retrieve(self, query: str, n_results: int = 5) -> list[dict]:
"""Retrieve relevant chunks for a query."""
return self.rag.query(query, n_results)
def generate(
self,
query: str,
context_chunks: list[dict],
) -> str:
"""Generate an answer using retrieved context.
Args:
query: User question.
context_chunks: Retrieved chunks from retrieve().
Returns:
LLM-generated answer.
"""
context = "\n\n".join(
f"[Source: {c['metadata'].get('source', 'unknown')}]\n{c['text']}"
for c in context_chunks
)
response = self.llm.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": (
"Answer the question based on the provided context. "
"If the context does not contain the answer, say "
"'I don't have enough information to answer this.'"
),
},
{
"role": "user",
"content": f"Context:\n{context}\n\nQuestion: {query}",
},
],
temperature=0.2,
)
return response.choices[0].message.content
def ask(self, query: str, n_results: int = 5) -> dict:
"""Full RAG pipeline: retrieve + generate.
Returns:
Dict with answer, sources, and retrieved chunks.
"""
chunks = self.retrieve(query, n_results)
answer = self.generate(query, chunks)
return {
"answer": answer,
"sources": [
{"source": c["metadata"].get("source"), "text": c["text"][:200]}
for c in chunks
],
"num_chunks": len(chunks),
}
5. Use the Pipeline
pipeline = RAGPipeline()
# Ingest documents
pipeline.ingest([
{
"title": "Redis Guide",
"source": "redis-docs.md",
"text": "Redis is an in-memory data structure store. It supports strings, hashes, lists, sets, sorted sets, streams, and more. Redis is commonly used for caching, session management, real-time analytics, and message brokering.",
},
{
"title": "PostgreSQL Guide",
"source": "postgres-docs.md",
"text": "PostgreSQL is a capable open-source relational database. It supports ACID compliance, JSON columns, full-text search, and geospatial data via PostGIS.",
},
])
# Ask questions
result = pipeline.ask("What data structures does Redis support?")
print(result["answer"])
print(f"Sources: {[s['source'] for s in result['sources']]}")
6. Using with Ollama for Fully Local RAG
import requests
class LocalLLMRAG(RAGPipeline):
def __init__(self, persist_dir: str = "./chroma_db", ollama_url: str = "http://localhost:11434"):
self.rag = LocalRAG(persist_dir)
self.ollama_url = ollama_url
def generate(self, query: str, context_chunks: list[dict]) -> str:
context = "\n\n".join(c["text"] for c in context_chunks)
response = requests.post(
f"{self.ollama_url}/api/generate",
json={
"model": "llama3",
"prompt": f"Context:\n{context}\n\nQuestion: {query}\n\nAnswer:",
"stream": False,
},
)
return response.json()["response"]
# Fully local — no API keys needed
pipeline = LocalLLMRAG()
pipeline.ingest(documents)
result = pipeline.ask("How does Redis handle caching?")
How It Works
- Chunking splits documents into ~500-character segments with 50-character overlap. Overlap ensures context continuity between chunks so relevant information isn’t split across boundaries.
- ChromaDB uses sentence-transformers (
all-MiniLM-L6-v2by default) to embed chunks and queries. Embeddings are generated locally in-process — no API calls. - Retrieval embeds the query and finds the nearest chunks by cosine similarity. ChromaDB uses HNSW (Hierarchical Navigable Small World) for fast approximate nearest neighbor search.
- Generation concatenates the retrieved chunks as context and sends them to the LLM with the user’s question. The LLM generates an answer grounded in the provided context.
- PersistentClient stores the vector database on disk, so embeddings persist across restarts without re-ingesting.
Variants
Filtering by Metadata
def query_with_filter(
self,
query_text: str,
n_results: int = 5,
where: dict | None = None,
) -> list[dict]:
results = self.collection.query(
query_texts=[query_text],
n_results=n_results,
where=where,
)
return results
# Only search within a specific source
results = pipeline.rag.query_with_filter(
"caching strategies",
where={"source": "redis-docs.md"},
)
Custom Embedding Model
from sentence_transformers import SentenceTransformer
class CustomEmbeddingRAG(LocalRAG):
def __init__(self, model_name: str = "BAAI/bge-large-en-v1.5"):
self.embedder = SentenceTransformer(model_name)
self.client = chromadb.PersistentClient(path="./chroma_db")
self.collection = self.client.get_or_create_collection(
name="documents",
embedding_function=self._embed_fn,
)
def _embed_fn(self, input: list[str]) -> list[list[float]]:
return self.embedder.encode(input).tolist()
Reranking Retrieved Chunks
from sentence_transformers import CrossEncoder
class RerankedRAG(RAGPipeline):
def __init__(self):
super().__init__()
self.reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
def retrieve(self, query: str, n_results: int = 20, top_k: int = 5) -> list[dict]:
# Retrieve more chunks than needed
chunks = self.rag.query(query, n_results)
# Rerank with cross-encoder
pairs = [(query, c["text"]) for c in chunks]
scores = self.reranker.predict(pairs)
# Sort by reranker score and return top_k
ranked = sorted(zip(chunks, scores), key=lambda x: x[1], reverse=True)
return [c for c, _ in ranked[:top_k]]
Best Practices
-
For a deeper guide, see Run LLMs Locally with Ollama for Private Inference.
-
Use overlap in chunking — 10-20% overlap prevents losing context at chunk boundaries
-
Include source metadata — lets you cite sources in the generated answer
-
Use a low temperature for generation — 0.1-0.3 reduces hallucination in factual Q&A
-
Rerank for better accuracy — retrieve 3-5x more chunks and rerank with a cross-encoder for top-k
Common Mistakes
- Chunks too large — the LLM may miss details in long context; keep chunks to 200-500 words
- No overlap — relevant information split at chunk boundaries is lost
- Not persisting ChromaDB —
EphemeralClientloses data on restart; usePersistentClient - Retrieving too few chunks — 3-5 is a good default; too few misses relevant context
FAQ
Q: What embedding model does ChromaDB use by default?
A: all-MiniLM-L6-v2 from sentence-transformers (384 dimensions). It is fast and good for English text.
Q: Can I use ChromaDB in production? A: Yes, but it runs in-process. For multi-instance setups, use Chroma’s client-server mode or switch to Pinecone/Weaviate.
Q: How much memory does ChromaDB use? A: Roughly 1KB per vector for the default model. 100K chunks uses ~100MB of RAM.
Q: Should I use a cross-encoder reranker? A: For production RAG, yes. Reranking improves precision considerably — retrieve 20 chunks, rerank to top 5.
Is this solution production-ready?
Yes. The code examples above show tested implementations. Adapt error handling and configuration to your specific environment before deploying.
What are the performance characteristics?
Performance depends on your data volume and infrastructure. The solutions shown prioritize clarity. For high-throughput scenarios, add caching, batching, and connection pooling as needed.
How do I debug issues with this approach?
Start with the minimal example above. Add logging at each step. Test with small inputs first, then scale up. Use your language’s debugger to step through edge cases.
Related Resources
Store and Query Embeddings in Pinecone Vector Database
Use Pinecone to store, query, and filter vector embeddings for semantic search with metadata filtering and namespace isolation
RecipeCompose LCEL Chains in LangChain for Multi-Step LLM
Build composable LLM pipelines with LangChain Expression Language (LCEL) using pipes, parallel execution, and custom runnable components
RecipeCompare Text Semantic Similarity with OpenAI Embeddings
Generate text embeddings with OpenAI and compute cosine similarity to measure semantic similarity between texts for search, dedup, and clustering
RecipeRun LLMs Locally with Ollama for Private Inference
Install and use Ollama to run open-source LLMs locally with Python, including streaming, embeddings, function calling, and model management without API costs
RecipeFine-Tune and Deploy Text Classifiers with Hugging Face
Fine-tune a pre-trained transformer model for text classification using Hugging Face Trainer, tokenize datasets, evaluate metrics, and deploy for inference
RecipeEvaluate RAG Quality with RAGAS Metrics
Measure RAG pipeline quality using RAGAS framework metrics — faithfulness, answer relevancy, context precision, and context recall for objective evaluation
PatternRAG Hybrid Search Pattern
Combine keyword (BM25) and semantic (vector) search to improve retrieval accuracy in RAG pipelines. Fuse ranked results using reciprocal rank fusion.