Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Ai Chroma Db Store Laravel Package

symfony/ai-chroma-db-store

ChromaDB vector store integration for Symfony AI Store, enabling collection management and vector queries against ChromaDB.

View on GitHub
Deep Wiki
Context7

Getting Started

Prerequisites

  • PHP 8.2+ and a Laravel app on Composer.
  • The Symfony AI store abstraction (symfony/ai-store, pulled in transitively — verify in composer.json of the bridge). As of v0.14 that abstraction ships VectorDocumentInterface and store-level count support, so the contracts you code against have moved since the 0.8 era.
  • A running Chroma server. Locally, Docker is the fastest path:
docker run --rm -p 8000:8000 -v chroma:/chroma/chroma chromadb/chroma

Install

composer require symfony/ai-chroma-db-store:^0.14

Pin the version explicitly — the package is pre-1.0, and it has now gone from 0.8 to 0.14 in a handful of releases. v0.14.0 adds count capabilities (#1755), introduces VectorDocumentInterface (#2495), and fixes the store to defer to ChromaDB's default query limit (#2474). That last one changes result counts for anyone who omitted limit.

Where to look first

The README is still a stub, so read the source, in this order:

  1. vendor/symfony/ai-chroma-db-store/src/ — the concrete store class and its factory. This is the only authoritative list of constructor options (host, collection name, tenant/database, API key, HTTP client, embedding dimension if any) and the only reliable answer to "does this store implement count, and does it accept a filter?"
  2. The interfaces in symfony/ai-store: StoreInterface (what add, query, remove accept), plus the new VectorDocumentInterface and whatever interface backs the count feature. Run class_implements() on the Chroma store once — it takes ten seconds and tells you exactly which of the new capabilities you actually got.
  3. tests/ in the bridge — the best "documentation" for a new Symfony AI store, and now the clearest demonstration of how VectorDocument objects are built.
  4. Chroma's own docs for collection management and query/get payload shapes, since filters pass through close to the wire format.

First use case: index three documents, query top-2, count them back

  1. Generate embeddings with your Symfony AI embedding platform (symfony/ai-* provider of choice) for a few sentences.
  2. Instantiate the Chroma store (via the factory or directly), pointing at http://localhost:8000 and a collection like laravel_docs.
  3. Persist three entries built as vector documents (VectorDocumentInterface implementations as of 0.14 — check the concrete class name in src/), each with an id, embedding, document text, and metadata.
  4. Call count() on the store and assert 3. This replaces the old "curl the collection endpoint" sanity check and is a far better first smoke test than eyeballing IDs.
  5. Embed a question, run a similarity query with an explicit limit: 2, and dump IDs plus distances.

Once that round-trip works — especially the count — everything else in this guide is plumbing around it.


Implementation Patterns

Bind the store as a singleton in the container

Keep construction in one place so tests and Octane don't rebuild the HTTP client per request:

// app/Providers/AiServiceProvider.php
public function register(): void
{
    $this->app->singleton(StoreInterface::class, function ($app) {
        return ChromaDbFactory::create([   // confirm exact factory name/signature in src/
            'host'       => config('ai.chroma.host'),
            'collection' => config('ai.chroma.collection'),
            // tenant / database / api_key as required by your Chroma deployment
        ]);
    });
}

Config from .env: CHROMA_HOST, CHROMA_COLLECTION, and credentials if you're on a secured deployment. Never hardcode the host.

Bind the interface, not the concrete class. With 0.14 adding count on some stores but not others, an interface-typed binding plus a capability check is what keeps a future pgvector/Qdrant swap honest.

Wrap it in a repository, not in controllers

Chroma speaks its own filter dialect ({"field": {"$eq": "value"}}) and returns raw arrays. Hide that behind a small VectorRepository that exposes domain methods (similarFAQs($query, $limit), indexArticle(Article $a), vectorCount(?array $filter = null)). This gives you one place to change when the bridge's API shifts — and it will shift, pre-1.0 — and one place to cache, log, or feature-flag.

Ingestion pipeline (the bread and butter)

chunk → embed (Symfony AI embeddings) → upsert vector document with deterministic id → store metadata

Use a deterministic id derived from the source model and chunk index (hash('xxh128', "article:{$id}:{$i}")). Re-indexing then becomes idempotent — the same chunk overwrites rather than duplicating. Store the Eloquent model class + primary key in metadata so you can map hits back:

$store->add(new VectorDocument(          // exact class/ctor — check src/ after VectorDocumentInterface
    id: $chunkId,
    embedding: $vector,
    document: $chunkText,
    metadata: [
        'model'     => Article::class,
        'model_id'  => $article->id,
        'tenant_id' => $article->tenant_id,   // scalar only — see Gotchas
        'locale'    => $article->locale,
    ],
));

The 0.14 move to VectorDocumentInterface replaces the loose positional/named-args call shape. If you have 0.13-era code, expect a compile-time break rather than a silent one — the value object is a genuine improvement (explicit, mockable, inspectable before it hits the wire), but it is a mechanical migration across every ingestion call site. Do it once, in the repository, and nowhere else.

Run ingestion from a queued job, batched (50–200 chunks per call, tune against Chroma). Never loop over HTTP calls inside a web request handler.

Use count for reconciliation, not just curiosity

0.14's count support is the feature you'll use most after query. Pair it with your deterministic IDs:

$expected = $article->chunks()->count();      // your own DB
$actual   = $this->store->count(filter: [     // verify filter support on the Chroma store
    'model'    => ['$eq' => Article::class],
    'model_id' => ['$eq' => $article->id],
]);

if ($actual !== $expected) {
    ReindexArticle::dispatch($article);
}

A nightly job that compares source-of-truth row counts to vector counts catches the failure mode this package is most prone to: a batch that died halfway with no rollback. If filtered count isn't wired up in the Chroma store, fall back to per-tenant collections or your own indexed_at bookkeeping table — but check first, because a full count() alone still gives you a cheap drift alarm at the collection level.

Query pattern with multi-tenancy

Embed the query once, filter on metadata, then hydrate:

$hits = $store->query($queryVector, limit: 10, filter: [
    'tenant_id' => ['$eq' => $tenantId],
    'locale'    => ['$eq' => app()->getLocale()],
]);

$articles = Article::whereIn('id', array_column($hits, 'model_id'))->get();

Always pass limit explicitly — see the default-limit note in Gotchas. Always include the tenant filter. Chroma has no row-level security; isolation is entirely your responsibility.

RAG assembly

Retrieve top-k chunks → concatenate into the prompt context → call the Symfony AI chat platform → return the answer plus source IDs for citations. Keep a sources array in your response; users trust RAG far more when they can click through, and it makes debugging retrieval quality trivial.

Lifecycle hooks

Register an Eloquent observer (or model events) so deleted removes the corresponding vector IDs, and saved dispatches a re-index job. Track the mapping (model + model_id → vector ids) in a normal database table — you'll need it for deletion, since deleting by metadata filter is not reliably available. Optionally trigger a count-diff on delete so a failed cleanup doesn't linger undetected.

Testing strategy

  • Unit tests: mock StoreInterface (it's an interface — this is easy) and assert your repository builds the right calls. If your repository calls count() or type-hints the count/vector-document interfaces, mock those too, or your fakes will be missing methods after a version bump.
  • Integration tests: spin Chroma as a CI service container and run a thin suite covering add/query/update/remove and count against a throwaway collection (test_ prefix, dropped after). Count assertions are the cheapest way to prove ingestion actually landed.
  • Don't hit a real Chroma in the default phpunit.xml suite; gate it behind a group like @group integration.

Gotchas and Tips

The README still tells you almost nothing. With zero stars and zero dependents, there is no folk knowledge to lean on — and now the API has churned twice in the package's short life. Budget an hour to read src/ and tests/ before writing code, and treat any blog post (including the guesses in the PM/TPM notes) as unverified.

Pin ^0.14 and read every minor diff. Since the last assessment the store gained count support, swapped to VectorDocumentInterface, and changed its default-limit behaviour. That's three behavior-affecting changes in six minor versions. Pre-1.0 Symfony packages ship breaking changes in minors; add a Renovate/Dependabot PR but always read the store-method diff, not just the title.

The default query limit changed in 0.14 (#2474). The store now lets ChromaDB apply its own default when you don't pass limit (currently 10). If any code path relied on the bridge's previous behaviour — or on "give me everything" — result sets will shrink silently. This is the single most likely cause of a "retrieval got worse after composer update" bug report. Grep your codebase for ->query( and make every call's limit explicit.

VectorDocumentInterface migration is mechanical but wide. Every add/update call site needs a value object now. Do the migration inside your repository so it's one file, and add a test that asserts the document's id/embedding/metadata before it's sent — that's much easier to debug than a type error three layers into the HTTP client.

Distance metric is baked into the collection. Chroma configures hnsw:space (cosine / l2 / ip) at collection creation. You cannot override it per query. Pick one (cosine is the sane default for text embeddings) at creation time, and normalize your embeddings consistently — mixing normalized and unnormalized vectors silently degrades results without erroring.

Embedding dimension is fixed per collection. Swap embedding models and you must create a new collection and re-index. Store the model name in collection metadata so future-you knows what produced the vectors. Your count-based reconciliation job will catch the half-migrated state.

Chroma metadata is scalar-only. Strings, ints, floats, booleans. No nested arrays, no objects, no null. Encode lists as JSON strings (json_encode($tags)) or as one field per element, and remember that filtering inside JSON strings isn't possible — denormalize the fields you actually filter on. This applies equally to the metadata on your VectorDocument objects.

Filter syntax is not Eloquent. It's ['field' => ['$eq' => $v]], ['$and' => [...]]. Build a tiny filter builder rather than hand-writing arrays in twelve places. $in is your friend for multi-value filters. If count accepts a filter, feed it through the same builder so metrics and retrieval can't drift apart.

id must be a non-empty string. Chroma rejects numeric or empty IDs. Use UUID/ULID or deterministic hashes, never auto-increment integers.

Updates can be subtle. "Upsert" means embedding + document + metadata replace, but partial-metadata semantics vary between Chroma versions. For anything you care about, send the full record rather than patching. The historic "Fix document update" change is a reminder that this path has been buggy before.

No transactions, no rollback. A 500-chunk batch that fails at chunk 480 leaves 479 behind. Make ingestion idempotent (deterministic IDs) so a retry just overwrites — and schedule a count-diff reconciliation job so you find out rather than a user finding out.

Latency is HTTP latency. Chroma is a network hop. Don't do similarity search inline in a 200ms page render without caching. For search-as-you-type, debounce aggressively and consider caching hot query vectors → results for 60 seconds. For RAG in a chat endpoint, the LLM call dwarfs Chroma; still, batch your writes. And don't call count() in a hot path — it's a round trip too.

Exposing port 8000 publicly is a data breach waiting to happen. OSS Chroma has minimal auth. Keep it on a private network, put it behind your VPC/security group, or front it with a reverse proxy that enforces auth headers. Vectors of PII are recoverable-ish; treat Chroma as a datastore with the same sensitivity as your database.

Debugging checklist when a query returns nothing:

  1. curl http://localhost:8000/api/v2/heartbeat — is Chroma up?
  2. $store->count() — is it zero? If so this is an ingestion bug, not a query bug, and you just saved an hour. (If the store doesn't implement count, hit /api/v2/tenants/{t}/databases/{d}/collections instead.)
  3. Does the collection exist, and is it the one you think?
  4. Is your filter matching anything? Drop the filter and re-query; filters fail closed with no error.
  5. Did you pass a limit? Post-0.14, an omitted limit means Chroma's default (10) — not "everything".
  6. Are query and stored vectors the same dimension and the same normalization?
  7. Log the raw HTTP status/body from the bridge (inject a logging HTTP client if the factory allows) — Symfony's HTTP client exceptions carry the response body and are your fastest path to the cause.

Extension points worth knowing:

  • Implement or decorate StoreInterface to add cross-cutting concerns (caching, timing, per-tenant metrics) without touching the bridge. With count now in the mix, a decorator is also the clean place to emit drift metrics.
  • If the bridge's factory supports injecting an HTTP client, inject a retrying/logging one — Chroma restarts in local dev are common.
  • Keep a VectorStore alias binding so a future swap (pgvector, Qdrant, in-memory for tests) is a one-line container change. This remains the single most valuable architectural decision you can make given the package's maturity — and it's what makes absorbing 0.14-style interface changes a one-file edit instead of a refactor.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
frankyso/ipaymu
3mdikram/worldmoneywords
snowsoft/config
itmosoft/laravel-subscriptions
larva/laravel-ai-qwen
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core