Chroma Vector Database Integration

Embeddings & Vector Search intermediate Building with AI ~20 min read

Overview

Chroma is an open-source embedding database designed for building AI applications that need fast, filterable similarity search. Unlike general-purpose databases that bolt on vector support as an afterthought, Chroma is purpose-built around the embedding workflow: store documents with their vector representations and arbitrary metadata, then query by semantic similarity with optional metadata filters—all through a simple API that feels native to Python and Go applications.

In a typical AI application stack, the search platform uses Chroma as its primary vector store via langchaingo abstractions. This integration layer provides a uniform interface for storing, indexing, and querying embeddings regardless of whether the underlying store is Chroma, Pinecone, or an in-memory index. The langchaingo vector store interface wraps Chroma’s collection API, handling serialization of metadata filters, pagination of large upsert batches, and automatic retry on transient failures.

Understanding how Chroma works under the hood—its HNSW indexing, metadata filtering pipeline, and persistence model—is essential for tuning query performance and making informed decisions about when Chroma is the right choice versus alternatives like pgvector or Pinecone.

Core Concepts

Collections

A Chroma collection is analogous to a table in a relational database. Each collection stores documents as (id, embedding, metadata, document_text) tuples. Collections are the unit of index management—each collection maintains its own HNSW graph, and queries are scoped to a single collection. The search platform creates one collection per tenant to provide data isolation.

HNSW Index

Chroma uses Hierarchical Navigable Small World (HNSW) graphs for approximate nearest neighbor search. HNSW builds a multi-layer graph where higher layers contain fewer nodes with longer-range connections, enabling logarithmic search time. Key tuning parameters are M (connections per node, affecting recall vs. memory) and ef_construction / ef_search (controlling build-time and query-time accuracy).

Metadata Filtering

Chroma supports filtering query results by metadata fields using a where clause with operators like $eq, $ne, $gt, $in, and $contains. Filters are applied after the HNSW search narrows candidates, which means highly selective filters on large collections may return fewer results than requested. The langchaingo wrapper translates Go filter structs into Chroma’s JSON filter format.

Langchaingo VectorStore Interface

Langchaingo defines a VectorStore interface with methods like AddDocuments, SimilaritySearch, and Delete. The Chroma implementation of this interface handles connection management, collection lifecycle, and translates langchaingo’s generic query options (top-k, score threshold, metadata filters) into Chroma-native API calls. This abstraction lets the search platform swap vector stores without changing application logic.

Persistence Modes

Chroma supports in-memory mode (for testing), persistent local storage (SQLite + parquet for metadata, memory-mapped HNSW files), and client-server mode (connecting to a remote Chroma server over HTTP/gRPC). The search platform uses client-server mode in production and in-memory mode in integration tests, configured through the langchaingo store options.

How It Works

The integration follows a layered architecture: application code calls the langchaingo VectorStore interface, which delegates to a Chroma-specific adapter that communicates with the Chroma server over its REST or gRPC API. Documents flow through an embedding function (registered separately) before being upserted into a collection.

Initializing the Chroma Store

package search

import (
    "context"
    "log"

    "github.com/tmc/langchaingo/embeddings"
    "github.com/tmc/langchaingo/vectorstores/chroma"
)

// NewChromaStore creates a langchaingo-compatible vector store backed by Chroma.
func NewChromaStore(ctx context.Context, chromaURL string, collectionName string, embedder embeddings.Embedder) (*chroma.Store, error) {
    store, err := chroma.New(
        chroma.WithChromaURL(chromaURL),
        chroma.WithNameSpace(collectionName),
        chroma.WithEmbedder(embedder),
        chroma.WithDistanceFunction("cosine"),
    )
    if err != nil {
        return nil, fmt.Errorf("creating chroma store: %w", err)
    }
    log.Printf("Connected to Chroma collection %q at %s", collectionName, chromaURL)
    return &store, nil
}

Adding Documents with Metadata

package search

import (
    "context"
    "fmt"

    "github.com/tmc/langchaingo/schema"
    "github.com/tmc/langchaingo/vectorstores"
)

// IndexDocuments embeds and stores documents in the Chroma collection.
func IndexDocuments(ctx context.Context, store vectorstores.VectorStore, docs []schema.Document) error {
    // langchaingo handles batching internally based on the embedder's limits
    ids, err := store.AddDocuments(ctx, docs)
    if err != nil {
        return fmt.Errorf("adding documents to chroma: %w", err)
    }
    fmt.Printf("Indexed %d documents, IDs: %v\n", len(ids), ids[:min(3, len(ids))])
    return nil
}

// PrepareDocument creates a langchaingo Document with metadata for Chroma storage.
func PrepareDocument(content string, source string, docType string, tenantID string) schema.Document {
    return schema.Document{
        PageContent: content,
        Metadata: map[string]any{
            "source":    source,
            "doc_type":  docType,
            "tenant_id": tenantID,
            "indexed_at": time.Now().UTC().Format(time.RFC3339),
        },
    }
}

Querying with Metadata Filters

package search

import (
    "context"
    "fmt"

    "github.com/tmc/langchaingo/vectorstores"
)

// SemanticSearch queries the Chroma collection with optional metadata filtering.
func SemanticSearch(ctx context.Context, store vectorstores.VectorStore, query string, tenantID string, topK int) ([]schema.Document, error) {
    // Build filter: restrict results to the requesting tenant
    filter := map[string]any{
        "tenant_id": map[string]any{
            "$eq": tenantID,
        },
    }

    results, err := store.SimilaritySearch(ctx, query, topK,
        vectorstores.WithFilters(filter),
        vectorstores.WithScoreThreshold(0.7),
    )
    if err != nil {
        return nil, fmt.Errorf("chroma similarity search: %w", err)
    }

    fmt.Printf("Found %d results for query %q (tenant: %s)\n", len(results), query, tenantID)
    return results, nil
}

Practical Applications

The search platform in a production AI application stack uses the Chroma integration for its core semantic search pipeline. When a user submits a natural-language query, the service embeds the query using the same model that produced the stored embeddings, then calls SimilaritySearch on the tenant’s Chroma collection. Results are ranked by cosine similarity and filtered by metadata (document type, recency, access level) before being returned to the frontend.

During development and testing, the team runs Chroma in-memory mode, which avoids the need for a running Chroma server. Integration tests create ephemeral collections, index a known corpus, run queries, and verify that expected documents appear in the results with appropriate similarity scores. This pattern catches embedding model regressions and filter logic bugs before they reach production.

For multi-tenant deployments, each tenant gets a dedicated Chroma collection. This provides strong data isolation without the operational overhead of running separate Chroma instances. The collection-per-tenant model also allows per-tenant tuning of HNSW parameters based on corpus size and query patterns.

Implementation Patterns

Batch Upsert with Progress Tracking

package search

import (
    "context"
    "fmt"

    "github.com/tmc/langchaingo/schema"
    "github.com/tmc/langchaingo/vectorstores"
)

const upsertBatchSize = 100

// BatchIndex indexes a large corpus in chunks, reporting progress.
func BatchIndex(ctx context.Context, store vectorstores.VectorStore, docs []schema.Document) error {
    total := len(docs)
    for i := 0; i < total; i += upsertBatchSize {
        end := min(i+upsertBatchSize, total)
        batch := docs[i:end]

        if _, err := store.AddDocuments(ctx, batch); err != nil {
            return fmt.Errorf("batch %d-%d failed: %w", i, end, err)
        }
        fmt.Printf("Indexed %d/%d documents (%.0f%%)\n", end, total, float64(end)/float64(total)*100)
    }
    return nil
}

Collection Health Check

package search

import (
    "context"
    "net/http"
    "fmt"
    "io"
)

// HealthCheck verifies the Chroma server is reachable and the collection exists.
func HealthCheck(ctx context.Context, chromaURL string) error {
    resp, err := http.Get(chromaURL + "/api/v1/heartbeat")
    if err != nil {
        return fmt.Errorf("chroma unreachable: %w", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        body, _ := io.ReadAll(resp.Body)
        return fmt.Errorf("chroma health check failed (status %d): %s", resp.StatusCode, body)
    }
    return nil
}

Common Pitfalls

Further Reading