DEV Community

BAOFUFAN
BAOFUFAN

Posted on

Pytest + Chroma: The 6‑Hour Bug That Erased AI’s Memory

At 2:30 AM, I was jolted awake by a stream of PagerDuty alerts. Users were complaining that our “AI memory assistant” kept asking “Hello, who am I?” in a loop—despite having chatted the day before about a cat named Pudding. I groggily opened the Chroma console. All vector records were still there. I reran the Pytest suite. Twenty‑seven tests glowed green like a spring meadow. Data intact, tests passing—yet the AI had amnesia. That night I stared at Chroma’s source code and logs from 2:30 to 8:30 AM, finally smoking out a ghost hiding inside a testing blind spot: a boundary case that only real users triggered, rendering our automated verification completely useless.

When your tests become a placebo

Our architecture was straightforward: LangChain’s ConversationBufferMemory stored conversation summaries in Chroma. At query time, we used semantic search with the current user input to fetch relevant memories and inject them into the LLM. To guarantee storage reliability, we wrote a full Pytest suite: insert a memory, query with the exact same text, assert that the returned document ID matched. It looked bulletproof—until users rephrased their sentences in production and the memories never came back.

The root cause quickly surfaced: a missing distance threshold on query results. Chroma returns results by top_k and will happily give you garbage when similarity is extremely low. Because our test always queried with the identical insertion text, the cosine similarity was always 1.0, the result was always first, and the assertion always passed. In reality, users don’t repeat themselves like a broken record. They reword, add filler words, make typos. Once the vector distance between the rephrased query and the stored memory widened to 0.6 or lower, Chroma still returned something—possibly completely irrelevant memory fragments with a miserable score of 0.4. We never validated the score, so the LLM swallowed that noise as real memory, producing incoherent replies that looked like amnesia.

Standard solutions—like LangChain’s built‑in Memory test utilities—failed here. LangChain’s testing wrappers are too opaque: you can only check whether a final string appears, but you cannot control the vector search distance threshold, the similarity metric, or run parametrized tests with borderline text variations. To truly guard Chroma’s quality, we had to tear into the testing logic ourselves at the Pytest level.

Turning Pytest into the vector‑DB coroner

We ditched all high‑level wrappers and drove the chromadb client directly, building three dimensions of integrity validation inside Pytest:

  1. Content correctness: don’t just check IDs; assert the returned document text is genuinely relevant.
  2. Distance reasonableness: force an assertion that distance (or score) exceeds our business threshold. If it doesn’t, the retrieval is considered a failure even if something was returned.
  3. Semantic robustness: hammer the retrieval with dozens of semantically similar queries that use different wording—misspellings, synonyms, colloquial expressions—to guarantee the memory’s “fault tolerance.”

Why not use Chroma’s own integration tests? They’re too coarse, and the default similarity metric even changed between versions (pitfall #1, for another day). We chose Pytest for brutally simple reasons: great ecosystem, effortless parametrization, and fixture lifecycle management that isolates Chroma collections cleanly. We weren’t writing a one‑off script; we were building an automated gate that must turn green before any deployment can happen.

Core implementation: three building blocks for an amnesia‑proof test suite

Block 1: a reusable Chroma fixture for perfect isolation

This snippet solves the classic “one test pollutes the next” problem. We use tmp_path to give each test its own persistent directory and explicitly delete_collection during teardown—ephemeral mode alone wasn’t enough after a leftover bug we hit.

import pytest
import chromadb
from chromadb.config import Settings

@pytest.fixture
def memory_collection(tmp_path):
    """
    每个测试用例独享一个 Chroma 集合,彻底隔离。
    强制指定余弦相似度,避免默认度量因版本变化。
    """
    client = chromadb.Client(Settings(
        chroma_db_impl="duckdb+parquet",
        persist_directory=str(tmp_path / "chroma_test")
    ))
    collection = client.create_collection(
        name="test_memory",
        metadata={"hnsw:space": "cosine"}  # 强制余弦距离
    )
    yield collection
    # 清理——官方文档没强调,但不删会导致多测试套件相互影响
    client.delete_collection("test_memory")
Enter fullscreen mode Exit fullscreen mode

Block 2: dual assertions on content and score

This is the core validation: insert a known memory, then query with differently worded phrases, and simultaneously verify both the returned text and the distance score. Our previous mistake was checking only IDs and ignoring the score, which let low‑similarity “fake memories” slip through.

def test_memory_retrieval_with_score_threshold(memory_collection):
    """
    验证:用语义相近但措辞不同的查询,仍能召回正确记忆,
    且余弦距离分数不低于 0.75(我们的业务安全阈值)。
    """
    # Arrange:插入一段用户的记忆
    memory_collection.add(
        documents=["用户养了一只名叫布丁的橘猫,今年三岁。"],
        metadatas=[{"user_id": "u1", "session": "s1"}],
        ids=["mem-1"]
    )

    # 用户真实场景可能说的话
    queries = [
        "我家的猫叫什么来着?",
        "我那只橘猫多大了?",
        "布丁几岁了",           # 省略“猫”
        "我的宠物是什么品种"    # 语义相关但不完全重叠
    ]

    for query in queries:
        results = memory_collection.query(
            query_texts=[query],
            n_results=1,
            include=["documents", "distances"]
        )
        # 关键断言 1:必须返回至少一条结果
        assert len(results["documents"][0]) > 0, f"查询 '{query}' 未返回任何结果"
Enter fullscreen mode Exit fullscreen mode

For each query we also assert that the returned document contains a key piece of information (e.g., “布丁”) and that the distance stays below the 0.75 threshold—the full test goes on to catch exactly those low‑score poison pills. Without this, our AI would keep “remembering” things that never happened.

With the fixture and the parametrized barrage of real‑world queries, the test suite finally became the brutal gatekeeper we needed. No green, no deployment—and no more 2 AM amnesia wake‑up calls.

Top comments (0)