Home / AI & GenAI / Vector Databases

AI & GenAITroubleshootingIntermediate

Chroma returns poor matches: default L2 distance, HNSW settings fixed at creation, and rebuilding a collection correctly

Retrieval from Chroma misses obvious matches, distances look odd, and changing ef_search on an existing collection doesn't help. Check the distance space, the HNSW parameters the collection was really built with, and rebuild into a correctly configured collection with a measured recall check.

Deepak Bhardwaj5 min read

Applies toChroma 0.5-1.x (PersistentClient, HttpClient, Docker server); configuration syntax differs between 0.x metadata keys and 1.x configuration objects

Symptoms

  • Questions whose answer is clearly in the corpus retrieve unrelated chunks.
  • distances in query results are large numbers (e.g. 0.8–1.6) where you expected cosine similarities near 1.
  • Recall drops as the collection grows past a few hundred thousand items.
  • You changed ef_search (or hnsw:search_ef) on an existing collection and results didn't change.

Cause 1: the collection uses L2, not cosine

Chroma's default distance space is L2 (squared Euclidean). Many people assume cosine. For normalised embeddings, L2 and cosine give the same ranking, so results look fine. For embeddings that aren't normalised, rankings differ, and thresholds written for cosine are wrong.

Check what the collection uses:

import chromadb
client = chromadb.PersistentClient(path="./chroma")    # or HttpClient(host=..., port=8000)
col = client.get_collection("kb_chunks")
print(col.metadata)                                    # 0.x: {"hnsw:space": "cosine"} if set
print(getattr(col, "configuration", None))             # 1.x: configuration with hnsw/spann settings
Space Distance value Convert
l2 Squared Euclidean (0 = identical) —
cosine 1 − cosine similarity (0 = identical, 2 = opposite) similarity = 1 − distance
ip 1 − inner product similarity = 1 − distance

Using the wrong conversion in the application (e.g. treating L2 distances as cosine distances and filtering distance < 0.3) throws good results away.

Cause 2: HNSW parameters set at creation, low defaults

HNSW parameters in Chroma are set when the collection is created:

Parameter (1.x configuration / 0.x metadata) Default Effect
max_neighbors / hnsw:M 16 Graph connectivity
ef_construction / hnsw:construction_ef 100 Build quality
ef_search / hnsw:search_ef 100 (lower in some older versions) Query-time candidate list
space / hnsw:space l2 Distance

space, max_neighbors and ef_construction can't be changed afterwards. Chroma's docs describe ef_search as modifiable, but in practice we've seen versions where modifying it on an existing collection didn't change query results. Don't rely on it: test whether a change takes effect on your version (Step 4), and if not, rebuild.

Step 1: measure recall, don't guess

Compare Chroma's results with an exact search done in NumPy on a sample:

import numpy as np

data = col.get(include=["embeddings"], limit=20000)          # sample; for big sets page with offset
ids, embs = data["ids"], np.array(data["embeddings"], dtype=np.float32)
embs_n = embs / np.linalg.norm(embs, axis=1, keepdims=True)

def recall_at_k(queries, k=10):
    total = 0.0
    for q in queries:
        qn = q / np.linalg.norm(q)
        exact = set(np.array(ids)[np.argsort(-embs_n @ qn)[:k]])
        got = set(col.query(query_embeddings=[q.tolist()], n_results=k)["ids"][0])
        total += len(exact & got) / k
    return total / len(queries)

Run the comparison on a collection holding only the sampled items, or compare against full exact search, so both sides search the same set. Use 100+ real query embeddings. Recall below ~0.9 at k=10 is a problem for RAG.

Step 2: rebuild into a correctly configured collection

Chroma 1.x:

new = client.create_collection(
    name="kb_chunks_v2",
    configuration={
        "hnsw": {
            "space": "cosine",
            "max_neighbors": 32,
            "ef_construction": 200,
            "ef_search": 128,
        }
    },
    embedding_function=None,             # we pass embeddings explicitly
)

Chroma 0.5/0.6 metadata style:

new = client.create_collection(
    name="kb_chunks_v2",
    metadata={"hnsw:space": "cosine", "hnsw:M": 32,
              "hnsw:construction_ef": 200, "hnsw:search_ef": 128},
)

Copy data in pages, reusing the stored embeddings so nothing is re-embedded:

old = client.get_collection("kb_chunks")
offset, page = 0, 1000
while True:
    batch = old.get(include=["embeddings", "documents", "metadatas"], limit=page, offset=offset)
    if not batch["ids"]:
        break
    new.add(ids=batch["ids"], embeddings=batch["embeddings"],
            documents=batch["documents"], metadatas=batch["metadatas"])
    offset += page
print(old.count(), new.count())

Then point the application at kb_chunks_v2 (keep the collection name in configuration), re-run the recall check and delete the old collection after a few days.

Step 3: check the embedding side too

Problem Check
Query embedded with a different model than documents Log the model name used at ingestion and at query time
Collection's embedding function differs from ingestion If you pass query_texts, Chroma embeds with the collection's embedding function; if you ingested with your own embeddings, pass query_embeddings
Documents truncated by the model's max tokens Chunk size within model limits

The second one is common: data ingested with OpenAI embeddings, queries sent as query_texts, embedded by Chroma's default local model. Dimensions may even match by coincidence for some models; results are then garbage without any error.

Step 4: test whether a query-time change applies

before = recall_at_k(sample_queries)
col.modify(configuration={"hnsw": {"ef_search": 256}})      # 1.x syntax
col = client.get_collection("kb_chunks")                    # re-open
after = recall_at_k(sample_queries)
print(before, after)

If after doesn't change, treat ef_search as fixed for your version and rebuild with the value you need.

Decision table

Finding Fix
Space is l2, app assumes cosine Rebuild with cosine, or fix distance conversion
Recall < 0.9 Rebuild with higher max_neighbors/ef_construction/ef_search
query_texts used with external embeddings Use query_embeddings with the same model
ef_search change has no effect Rebuild with the target value

Prevent it next time

  • Always set space explicitly when creating a collection.
  • Store the embedding model name in collection metadata and check it at startup.
  • Run a recall check in CI on a sample collection.
  • Keep collection names in config so rebuild-and-switch is a config change.