Skip to content

ChromaDB and Open-Source Vector Databases

Prerequisites

Before this lesson you should be comfortable with:

  • Module 13, Lessons 01–04 — embeddings, indexing, Pinecone patterns
  • Module 09, Lesson 02 — basic Chroma usage
  • Python file I/O — for the document search build below

Module 09 showed Chroma in 20 lines. This lesson builds a production-shaped document search app, compares Qdrant and Weaviate for hybrid search and sharding, and gives you a decision framework for open-source vs managed.


What You'll Learn

Objective Time Difficulty
Build a persistent ChromaDB collection with custom embeddings 10 min Intermediate
Implement filtered search and metadata strategies 10 min Intermediate
Compare Chroma, Qdrant, Weaviate, and Milvus for production 10 min Intermediate
Build a complete document search app from a directory of files 20 min Intermediate

Intuition First: Build vs Buy

Pinecone (Lesson 4) is like renting a fully managed apartment — move in, pay rent, maintenance included. Open-source vector databases are like buying a house — lower long-term cost, full control, but you handle plumbing (sharding), electrical (replication), and renovations (upgrades).

The open-source ecosystem has matured rapidly. Qdrant and Weaviate now match managed services on features like hybrid search, quantization, and multi-tenancy — at the cost of operational complexity.

Choose open-source when: you have infra expertise, need data residency, want hybrid search without vendor lock-in, or operate at scale where managed pricing exceeds self-host cost.


ChromaDB: Quick Recap and Production Patterns

Persistent Client with Custom Embeddings

import chromadb
from chromadb.utils.embedding_functions import OpenAIEmbeddingFunction

client = chromadb.PersistentClient(path="./chroma_db")

openai_ef = OpenAIEmbeddingFunction(
    api_key="your-api-key",
    model_name="text-embedding-3-small",
)

collection = client.get_or_create_collection(
    name="documents",
    embedding_function=openai_ef,
    metadata={"hnsw:space": "cosine"},
)

collection.add(
    documents=[
        "RAG combines retrieval with generation for grounded answers",
        "Vector databases store embeddings for similarity search",
        "Fine-tuning customizes models for specific tasks",
    ],
    metadatas=[
        {"category": "rag", "difficulty": "intermediate"},
        {"category": "vector-db", "difficulty": "beginner"},
        {"category": "training", "difficulty": "advanced"},
    ],
    ids=["doc-1", "doc-2", "doc-3"],
)
results = collection.query(
    query_texts=["beginner AI concepts"],
    n_results=5,
    where={"difficulty": "beginner"},
)

results = collection.query(
    query_texts=["search techniques"],
    n_results=5,
    where={"$and": [
        {"category": {"$in": ["rag", "vector-db"]}},
        {"difficulty": {"$ne": "advanced"}},
    ]},
    where_document={"$contains": "retrieval"},
)

Chroma Limitations for Production

Limitation Impact Workaround
No native hybrid search Misses exact keyword matches Add BM25 layer externally (Lesson 8)
Single-node only No horizontal scaling Migrate to Qdrant/Weaviate at scale
Limited multi-tenancy Metadata filter only Separate collections per tenant
HNSW params not exposed Can't tune ef/M Accept defaults or switch DB

Chroma excels for prototyping and workloads under ~500K vectors. Plan migration before you hit scaling walls.


Qdrant: High-Performance Self-Hosted Option

Qdrant (Rust) offers production features Chroma lacks: hybrid search, quantization, sharding, and gRPC API.

from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct

client = QdrantClient(url="http://localhost:6333")

client.create_collection(
    collection_name="documents",
    vectors_config=VectorParams(size=1536, distance=Distance.COSINE),
)

points = [
    PointStruct(
        id=i,
        vector=embedding,
        payload={"text": doc["text"], "category": doc["category"]},
    )
    for i, (doc, embedding) in enumerate(zip(documents, embeddings))
]
client.upsert(collection_name="documents", points=points)

results = client.search(
    collection_name="documents",
    query_vector=query_embedding,
    limit=5,
    query_filter={"must": [{"key": "category", "match": {"value": "rag"}}]},
)

Qdrant Quantization (Memory Savings)

from qdrant_client.models import ScalarQuantization, ScalarQuantizationConfig, ScalarType

client.update_collection(
    collection_name="documents",
    quantization_config=ScalarQuantization(
        scalar=ScalarQuantizationConfig(type=ScalarType.INT8, always_ram=True)
    ),
)
# 4× memory reduction with ~1% recall loss

Weaviate: Hybrid Search Built-In

Weaviate combines BM25 keyword search and vector search natively — critical for production RAG (Lesson 8):

import weaviate

client = weaviate.connect_to_local()
documents = client.collections.get("Documents")

results = documents.query.hybrid(
    query="Python TypeError debugging",
    alpha=0.5,
    limit=10,
)

Open-Source Comparison Matrix

Feature ChromaDB Qdrant Weaviate Milvus
Best for Prototyping High-perf production Hybrid search Enterprise scale
Language Python Rust Go Go/C++
Hybrid search No Yes (sparse vectors) Yes (BM25) Yes
Quantization No Scalar + PQ PQ Scalar + PQ
Multi-tenancy Collections Sharding + payload filter Multi-tenancy module Partitions
Max scale ~1M vectors Billions Billions Billions
Setup complexity Very low Low Medium High
Cloud option Chroma Cloud Qdrant Cloud Weaviate Cloud Zilliz Cloud

Step-by-Step Build: Document Search App

Build a complete semantic search tool over a directory of text files:

"""Production-shaped document search with ChromaDB."""
import hashlib
import chromadb
from pathlib import Path
from chromadb.utils.embedding_functions import SentenceTransformerEmbeddingFunction

CHUNK_SIZE = 500  # characters
CHUNK_OVERLAP = 50

def chunk_text(text: str, size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> list[str]:
    chunks, start = [], 0
    while start < len(text):
        chunks.append(text[start:start + size])
        start += size - overlap
    return chunks

def content_id(source: str, chunk_idx: int) -> str:
    return f"{source}::chunk-{chunk_idx:04d}"

def build_index(docs_dir: str, db_path: str = "./search_db") -> chromadb.Collection:
    ef = SentenceTransformerEmbeddingFunction(model_name="all-MiniLM-L6-v2")
    client = chromadb.PersistentClient(path=db_path)
    collection = client.get_or_create_collection("file-search", embedding_function=ef)

    documents, metadatas, ids = [], [], []

    for filepath in sorted(Path(docs_dir).glob("**/*.txt")):
        text = filepath.read_text(encoding="utf-8")
        content_hash = hashlib.sha256(text.encode()).hexdigest()[:16]
        chunks = chunk_text(text)

        for i, chunk in enumerate(chunks):
            documents.append(chunk)
            metadatas.append({
                "filename": filepath.name,
                "source_path": str(filepath),
                "chunk_index": i,
                "total_chunks": len(chunks),
                "content_hash": content_hash,
            })
            ids.append(content_id(filepath.stem, i))

    if documents:
        # Upsert in batches of 100
        for i in range(0, len(documents), 100):
            collection.upsert(
                documents=documents[i:i+100],
                metadatas=metadatas[i:i+100],
                ids=ids[i:i+100],
            )

    print(f"Indexed {len(documents)} chunks from {docs_dir}")
    return collection

def search(collection, query: str, n: int = 5, filename_filter: str = None):
    where = {"filename": filename_filter} if filename_filter else None
    results = collection.query(query_texts=[query], n_results=n, where=where)

    for doc, meta, dist in zip(
        results["documents"][0],
        results["metadatas"][0],
        results["distances"][0],
    ):
        score = 1 - dist  # cosine distance → similarity
        print(f"\n--- {meta['filename']} (chunk {meta['chunk_index']}, score: {score:.3f}) ---")
        print(doc[:300] + "..." if len(doc) > 300 else doc)

# Usage
# collection = build_index("./my_documents")
# search(collection, "How do I configure HNSW parameters?")

Incremental Re-Index

def reindex_if_changed(collection, filepath: Path):
    """Only re-index files whose content hash changed."""
    text = filepath.read_text(encoding="utf-8")
    new_hash = hashlib.sha256(text.encode()).hexdigest()[:16]

    existing = collection.get(where={"filename": filepath.name}, include=["metadatas"])
    if existing["metadatas"] and existing["metadatas"][0].get("content_hash") == new_hash:
        print(f"Skipping {filepath.name} — unchanged")
        return

    # Delete old chunks for this file
    if existing["ids"]:
        collection.delete(ids=existing["ids"])

    # Re-index
    chunks = chunk_text(text)
    collection.upsert(
        documents=chunks,
        metadatas=[{
            "filename": filepath.name,
            "chunk_index": i,
            "content_hash": new_hash,
        } for i in range(len(chunks))],
        ids=[content_id(filepath.stem, i) for i in range(len(chunks))],
    )
    print(f"Re-indexed {filepath.name} ({len(chunks)} chunks)")

Milvus: Enterprise-Scale Option

For billion-vector deployments, Milvus offers distributed sharding, GPU-accelerated search, and DiskANN:

from pymilvus import connections, Collection, FieldSchema, CollectionSchema, DataType

connections.connect(uri="http://localhost:19530")

fields = [
    FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True),
    FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=1536),
    FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=65535),
    FieldSchema(name="category", dtype=DataType.VARCHAR, max_length=128),
]
schema = CollectionSchema(fields, description="Document embeddings")
collection = Collection("documents", schema)

index_params = {
    "index_type": "IVF_PQ",
    "metric_type": "COSINE",
    "params": {"nlist": 1024, "m": 48, "nbits": 8},
}
collection.create_index("embedding", index_params)
collection.load()

# Search
results = collection.search(
    data=[query_embedding],
    anns_field="embedding",
    param={"nprobe": 32},
    limit=10,
    output_fields=["text", "category"],
)

Milvus adds operational complexity but handles scale no single-node database can. Consider it when you exceed 50M vectors or need GPU acceleration.


When to Choose Open-Source vs Managed

Factor Open-Source (Qdrant/Weaviate) Managed (Pinecone)
Team has DevOps capacity Yes → strong fit No → strong fit
Need hybrid search Weaviate/Qdrant native Requires external BM25
Data residency requirements Full control Check region availability
Scale > 10M vectors Cost-effective self-hosted Pod pricing may exceed VPS
Time to first prototype Chroma: minutes Pinecone: minutes
Zero-ops requirement No Yes

Failure Modes

Chroma collection recreation. Calling create_collection with different embedding functions silently produces inconsistent vectors. Always use get_or_create_collection with the same config.

Path-based persistence. Chroma's PersistentClient(path=...) stores data locally. Docker containers without volume mounts lose data on restart.

Default embedding model drift. Chroma's default embedding model may change between versions. Pin an explicit embedding_function.

No backup strategy. Open-source databases don't auto-backup. Snapshot the data directory (Chroma) or use Qdrant's snapshot API.


Production Notes

  • Start with Chroma for prototyping; migrate to Qdrant or Weaviate when you need hybrid search, quantization, or > 500K vectors.
  • Pin embedding model and database version in your deployment config.
  • Use content-hash-based incremental indexing to avoid re-embedding unchanged documents.
  • Run Qdrant or Weaviate in Docker with persistent volumes from day one — not just Chroma in-memory.

Key Takeaways

  • Chroma is the fastest path to a working prototype; Qdrant and Weaviate are production-grade open-source alternatives.
  • The document search app above is a reusable template: chunk → embed → upsert → query with metadata.
  • Incremental re-indexing by content hash saves embedding API costs on unchanged files.
  • Choose open-source when you need hybrid search, data control, or cost efficiency at scale.
  • Plan your migration path before Chroma's single-node limits become a blocker.

Resources


Next Lesson

Lesson 6: Vector Database Schema Design — ID strategies, metadata design, multi-tenant sharding patterns, and schema evolution without full re-index.