symfony/ai-cache-store
Symfony AI Cache Store integrates Symfony Cache with Symfony AI Store, letting you use a cache backend as a vector store for AI applications. It provides a lightweight bridge for storing and retrieving embeddings through familiar cache infrastructure.
Install and orient yourself in vendor/ first. The README is a stub; it links to the monorepo (symfony/ai) and nothing else. The real documentation lives in the symfony/ai-store component source, so plan to read code, not docs.
composer require symfony/ai-cache-store:0.14.*
ls vendor/symfony/ai-cache-store/src # find the actual FQCN
ls vendor/symfony/ai-store/src # StoreInterface, VectorDocumentInterface, Document, Query, StoreFactory
Confirm four things before writing a line of application code:
Symfony\AI\Store\Bridge\Cache\CacheStore, but verify — the README never states it).CacheItemPoolInterface vs PSR-16 CacheInterface). This dictates your Laravel wiring more than anything else.query() signature — it changed in v0.4.0 ("query abstraction with filter support"), so the call shape depends on the version you pinned.VectorDocumentInterface (new in v0.14.0) actually declares. It is the contract your write path should type-hint against — Document is now just the shipped implementation. Read the interface before you construct documents by hand; the accessor names for id, vector, and metadata are what your indexing code will touch everywhere.Minimal wiring in AppServiceProvider. Laravel's Illuminate\Contracts\Cache\Repository is not PSR-6/PSR-16, so don't try to hand Cache::store() straight to the bridge. Use a Symfony cache adapter pointed at the same backend, in its own pool:
use Symfony\AI\Store\Bridge\Cache\CacheStore;
use Symfony\AI\Store\StoreInterface;
use Symfony\Component\Cache\Adapter\RedisAdapter;
public function register(): void
{
$this->app->singleton(StoreInterface::class, function () {
$redis = RedisAdapter::createConnection(config('database.redis.default'));
$pool = new RedisAdapter($redis, 'ai_vectors', (int) config('ai.store.ttl', 86400));
return new CacheStore($pool);
});
}
Bind against StoreInterface, never the concrete class — that's the whole migration story if you outgrow this package (see Gotchas). Type-hint VectorDocumentInterface for anything you accept as input to the indexer, so a future custom document class doesn't ripple through your app.
First use case: an internal-docs semantic search over a few hundred chunks. It exercises the whole pipeline (chunk → vectorize → add() → query() with a filter) in an afternoon, and it's the workload this bridge is actually good at. A single-document demo hides the two things that will bite you later: index size and eviction. Seed 500–1000 docs on day one so query latency is honest — and confirm the seed with $store->count() (new in v0.14.0) rather than by trusting the loop ran.
foreach ($chunks as $chunk) {
$store->add(new Document(
id: $chunk->id,
text: $chunk->body,
vector: $vectorizer->vectorize($chunk->body),
metadata: ['section' => $chunk->section, 'tenant' => $chunk->tenant_id],
));
}
$hits = $store->query(new Query(
vector: $vectorizer->vectorize($question),
filter: ['tenant' => tenant()->id],
));
logger()->info('vector index', ['count' => $store->count()]);
Keep the store behind a domain service, not a facade. A VectorStore facade looks tidy in a blog post and is painful in tests. Inject StoreInterface into one small service (DocumentIndexer, SemanticSearch) and let everything else depend on that service. Swapping to pgvector or Qdrant later then means editing one binding.
Reindexing is a queued job, not a request. Embedding calls are slow and rate-limited; a 5k-document reindex inside a controller will time out. Pattern that works:
final class IndexDocumentJob implements ShouldQueue
{
public function handle(DocumentIndexer $indexer): void
{
$this->document->chunks()->each(
fn ($chunk) => $indexer->upsert($chunk)
);
}
}
Because IDs are stable, add() doubles as an upsert for changed documents. For deletions, v0.4.0 added remove() — call it from an observer on your model's deleted event, or you accumulate orphaned vectors that pollute every query.
Use count() as your index health signal. v0.14.0 gives stores count capabilities. Wire it into three places, not into request paths:
php artisan vectors:status command that prints configured store, expected document count, and actual count.$store->count() every N chunks) so a stalled job is obvious.Before you lean on it, check whether count() is cheap or scans the pool — the answer depends on what CacheStore can get out of the cache adapter.
Build custom documents against VectorDocumentInterface, not Document. The v0.14.0 interface means you can hydrate a document straight from an Eloquent model or a DTO without a mapping layer. Worth doing when you already carry metadata as typed properties. Don't do it for one class in one place — Document is fine there.
Separate the two "AI caches" in your head. This package is a vector store, not an LLM response cache. Day-to-day you'll want both:
Cache::remember on a hash of the prompt + model): pure cost saving, zero vectors involved.Conflating them produces a store full of full-text completions and no useful index.
Use a dedicated cache store. In config/cache.php add:
'vectors' => ['driver' => 'redis', 'connection' => 'vectors', ...],
with its own Redis database number or key prefix. If your vectors live in the same Redis DB as sessions and rate limiters, php artisan cache:clear wipes your index, and maxmemory-policy eviction silently deletes embeddings under load. Both are "worked yesterday, empty today" bugs.
Filter, then rank, and don't trust a threshold across stores. The filter support added in v0.4.0 is metadata-based; treat it as a scoping mechanism (tenant, locale, section), not as a query planner. Score thresholds you tuned against cosine similarity here will not transfer to a store using inner product — keep thresholds in config, not code.
Test against ArrayAdapter. A Symfony ArrayAdapter-backed CacheStore plus a deterministic fake vectorizer (hash text → fixed-length float array) gives you fast, hermetic tests of your indexing and filtering logic without touching Redis or an embedding API. Assert on count() before and after remove() — that's the cheapest regression test for orphaned-vector bugs.
redis-cli info memory after seeding, and consider smaller embedding models (384–768 dims) before considering a new backend.php artisan optimize:clear / cache:clear / queue:restart are the three commands most likely to nuke your index. Dedicated DB + a reindex console command (php artisan vectors:reindex) is the mitigation you'll actually use.apcu or array in production. APCu is per-FPM-worker: writes land in one worker's memory and vanish for the next request. file works but serializes on lock contention and gets slow fast. Redis (with appendonly yes) is the only backend here worth calling "production" at small scale.hash('xxh128', $id)) before they become cache keys — the 250-byte key cap and no-scan behaviour will otherwise surprise you at the worst moment.StoreFactory, v0.14.0 introduced VectorDocumentInterface and count(). Pin exact minor versions (composer require symfony/ai-cache-store:0.14.*) and read the changelog before every composer update — "minor" releases here still ship interface-adjacent refactors upstream in symfony/ai.$store->count() answers "is anything in there?" in one line. If it's non-zero but queries return nothing, redis-cli --scan --pattern 'ai_vectors*' | head tells you whether the keys are where you think they are. If both look right, the failure is almost always (a) a vectorizer mismatch or (b) a filter that excludes everything — check them in that order. Add a Log::debug('store.add', ['id' => $doc->getId(), 'dims' => count($doc->getVector())]) around writes for the first week; use the VectorDocumentInterface accessors so the log survives a document class change.->filter(fn ($d) => $d->metadata['model'] === 'text-embedding-3-small').count() is not free by default. On a cache-backed store it may be a key scan rather than an O(1) read. Fine for a console command or a deploy check; not fine in a hot request path or inside a loop over per-tenant queries. Cache the value in your own observability layer if you need it on a dashboard.VectorDocumentInterface is additive — existing Document code keeps working. Don't rewrite your indexing layer just to type-hint the interface; do it incrementally, and only where you actually want a second implementation (Eloquent hydration, DTOs, fixtures). If you do implement it, match the accessor names exactly — a typo shows up as a runtime Error, not a static-analysis failure.StoreInterface for logging/metrics (query latency, hit counts, count() delta) without touching the bridge. Implement StoreInterface yourself if you need a feature the bridge lacks. And StoreFactory (v0.7.0) is the Symfony-idiomatic way to select a store from config — usable in Laravel but not autowired, so instantiate it manually if you want env-driven backend switching.StoreInterface (and your documents on VectorDocumentInterface), migration to pgvector/Qdrant is a binding swap plus a reindex. Keep the reindex command and the vectors:status count check working from day one; those two commands are your migration plan.How can I help you explore Laravel packages today?