symfony/ai-redis-store
Redis vector store integration for Symfony AI Store. Index and query embeddings with Redis Search, using FT.CREATE and FT.SEARCH commands, KNN vector search, and DIALECT 2 querying for fast similarity lookups.
What you're actually installing. This is a Symfony AI bridge, not a Laravel package. There is no service provider, no config/ai.php stub, no facade. What you get is a class implementing Symfony AI's StoreInterface that translates store calls into FT.CREATE / FT.SEARCH commands on a Redis connection. Treat the install as "a library + a wiring exercise," not a composer require && done.
Prerequisites, in order of how often they bite:
redis:7-alpine will not work. Locally: docker run -p 6379:6379 redis/redis-stack-server.redis-cli FT.SEARCH ... DIALECT 2 before designing anything.symfony/ai-store pulled in as a transitive dependency.Where to look first. After composer require symfony/ai-redis-store, don't read the docs first — read the source. Run composer show symfony/ai-redis-store, open vendor/symfony/ai-redis-store/, and confirm the concrete FQCN and constructor signature (namespace layout has churned across the 0.x line as packages moved into the monorepo). Then read the StoreInterface contract in symfony/ai-store — that interface, not this package, defines your day-to-day API.
Wire it up once in a service provider. This is the single piece of glue that makes everything else feel native:
// app/Providers/AiStoreServiceProvider.php
public function register(): void
{
$this->app->singleton(RedisStore::class, fn ($app) => new RedisStore(
client: $app->make(RedisClientAdapter::class), // bridge to Laravel's Redis connection
indexName: config('ai.store.index'),
// dimensionality / metric are your call — see Gotchas
));
$this->app->alias(RedisStore::class, StoreInterface::class);
}
The RedisClientAdapter is ~30 lines wrapping Illuminate\Redis\Connections\Connection (or Predis) into whatever minimal client contract the store expects. Write it once, unit-test it, forget about it.
First use case that proves the value: semantic search over an existing table. Pick one Eloquent model, embed a text column, store, and query. If that round-trip is fast and the results are sane, everything else (recommendations, dedupe, RAG retrieval) is the same three moves.
1. The three-move core loop. Everything in this package reduces to upsert a document, query by vector, count. Build a thin repository around it rather than calling the store from controllers:
final class VectorIndex
{
public function __construct(private StoreInterface $store) {}
public function upsert(array $documents): void { $this->store->add(...$documents); }
public function search(array $embedding, array $filters = [], int $limit = 10): array
{
return iterator_to_array($this->store->query($embedding, [
'limit' => $limit,
'filter' => $filters, // v0.4+ query abstraction
]));
}
}
Keep the embedding call outside the store. The store persists and searches vectors; it does not generate them. That separation is what lets you swap embedding providers or mock them in tests.
2. Model-sync via jobs, not observers. Registering an updated observer that embeds synchronously will destroy write throughput and block on HTTP calls to your embedding API. Instead: observer dispatches SyncVectorDocument to a queue, job chunks text, embeds in batches, upserts. Batch aggressively — FT.CREATE-backed writes are cheap per document but the embedding round-trip dominates.
3. Implement VectorDocumentInterface for your domain object. The v0.14 addition is the most useful thing in the package for a non-Symfony team: it formalizes the document as getId(), the vector payload, and metadata. Implement it on your own value object (not on an Eloquent model directly — keep the model out of the storage contract), and now your ingestion code, your tests, and any future backend swap all target a stable, documented shape instead of a Symfony internal DTO.
4. Multi-tenancy: pick one strategy up front.
| Strategy | Pros | Cons |
|---|---|---|
Index per tenant (tenant_{id}) |
Clean clear(), isolated quotas, easy per-tenant deletion |
Index sprawl; FT.CREATE per tenant; more admin ops |
Single index, tenant_id as a TAG filter |
One index, one schema, simpler ops | Every query must include the filter; a missed filter is a data leak |
For anything with more than a handful of tenants, use the TAG filter — but wrap it so callers cannot forget: apply the tenant filter inside your repository, never let a controller pass raw filters.
5. Use count() for the boring-but-critical stuff. v0.14's count capability is unglamorous and high-value:
total in a "X results" UI) without hydrating vectors.FT.INFO.6. clear() is a reindex primitive, not a user action. v0.11 added StoreInterface::clear() and all bridges implement it. Use it in an artisan command that rebuilds an index after changing the embedding model or chunking strategy. Never expose it to a request path.
7. Hybrid search is the cheap win. Redis's query engine can combine a numeric/TAG filter with KNN in one FT.SEARCH. That means you can pre-filter ("only documents where status = published") and do vector similarity in a single round trip — no application-side post-filtering, no over-fetching. This is the practical differentiator over bolting vector search onto something else.
Vector dimensionality is frozen at index creation. FT.CREATE bakes DIM and the distance metric into the schema. Switching from a 1536-dim to a 3072-dim model is not a config change — it's a new index plus a full re-embed of your corpus. Budget for this, and record the model name/dims alongside the index so it's discoverable.
Distance metric mismatch fails silently. COSINE vs L2 vs IP doesn't throw — it just returns slightly-wrong neighbours forever. Match the metric your embedding model was trained with (OpenAI/Cohere: cosine). Keep it in config next to the model identifier, and make the two a single config value so they can't drift.
No Laravel autowiring. StoreInterface will not resolve out of the container on its own. Every "class not found / cannot resolve" error on day one is this. Bind it explicitly and alias the interface.
Redis memory is RAM. A 1536-dim float32 vector is ~6KB before key and metadata overhead; 1M documents lands north of 8–10GB. Set a maxmemory-policy that does not evict vector keys (noeviction for the store DB) or you'll get silent partial results and corrupted indices. Give the vector store its own logical DB or Redis instance, separate from cache/sessions — because clear() on a shared DB is a bad afternoon.
The index must exist before the first insert. FT.CREATE is not a no-op if repeated; make an artisan command own the schema (ai:index:create) and run it in deploy, idempotently guarded. If you're on a module version with IF NOT EXISTS, use it — otherwise wrap in a FT._LIST check.
DIALECT 2 and managed Redis. This is the highest-probability blocker in production. Before you build anything, run a KNN query with DIALECT 2 against the staging instance. AWS ElastiCache and GCP Memorystore have historically lagged on the query engine; self-hosting Redis Stack or using Redis Cloud avoids the surprise.
Pre-1.0, moving namespaces. At 0.14 the API is settling but not settled. Pin with ~0.14.0, read minor release notes (v0.11 clear(), v0.14 document interface + count), and expect the occasional class relocation after a monorepo reshuffle. Write your own thin wrapper now — it absorbs the churn for one file instead of fifty.
Debugging, in order: FT.INFO <index> (schema, doc count, indexing failures), then reproduce the exact query in redis-cli with FT.SEARCH ... DIALECT 2, then check your Laravel-side embedding is identical in length and normalization. Nine out of ten "wrong results" are an embedding that changed shape or a metric mismatch, not the package.
Testing. Point tests at a dedicated Redis DB, and truncate between tests. Prefer a fake embedding provider (deterministic vectors) over real API calls — similarity ordering on synthetic vectors is still meaningful for asserting your plumbing, and it's what makes these tests fast enough to run in CI. Note that KNN scores are distances for cosine, so lower is better — assert with that in mind or you'll write assertions that pass by accident.
Extension points worth using: VectorDocumentInterface (portability + testability), and decoration of the store itself — wrap it to inject tenant scoping, cache embeddings for repeated query strings, and log query latency. Because the interface is narrow, a decorator is a genuinely low-risk place to put cross-cutting concerns here.
How can I help you explore Laravel packages today?