symfony/ai-store
Symfony AI Store provides a low-level abstraction for storing and retrieving documents in vector stores, with bridge packages for popular backends. Experimental and not covered by Symfony’s BC promise.
composer require symfony/ai-store:^0.14
pgvector):
composer require symfony/ai-postgres-store
config/services.php:
'ai_store' => [
'default' => env('AI_STORE_DRIVER', 'postgres'),
'stores' => [
'postgres' => [
'dsn' => env('DATABASE_URL'),
'table' => 'vector_documents',
'embedding_dimension' => 384, // Match your model's output
],
],
];
use Symfony\Component\AI\Store\StoreFactory;
public function register()
{
$this->app->singleton(StoreFactory::class, function ($app) {
return new StoreFactory(
$app['config']['services.ai_store.stores']
);
});
}
use Symfony\Component\AI\Store\StoreFactory;
use Symfony\Component\AI\Store\TextDocument;
$storeFactory = app(StoreFactory::class);
$store = $storeFactory->getStore('postgres');
$documents = [
new TextDocument('id1', 'Laravel is a PHP framework...', ['source' => 'docs']),
new TextDocument('id2', 'Symfony AI provides vector stores...', ['source' => 'docs']),
];
$store->add($documents); // Batch add
// v0.14+: sanity-check what's actually stored
$total = $store->count(); // int
use Symfony\Component\AI\Store\Query\VectorQuery;
$query = new VectorQuery(
vector: $embedding, // Any VectorInterface (v0.14+), pre-computed upstream
limit: 3,
distance: 'cosine'
);
$results = $store->query($query);
foreach ($results as $result) {
echo $result->getText(); // Retrieve document text
}
StoreInterface — the contract every bridge implements (add, query, delete, count).VectorDocumentInterface (new in v0.14) — the document contract, of which TextDocument is the built-in implementation.VectorInterface (new in v0.14) — anything implementing this can be passed as a query vector.Indexer / Loader — the batch ingestion path, now configurable via indexer options.symfony/ai-platform):
$vectorizer = app(Symfony\Component\AI\Vectorizer::class);
$embedding = $vectorizer->vectorize('Your query text');
$query = new VectorQuery($embedding, limit: 5);
$results = $store->query($query);
$context = implode("\n---\n", array_map(fn($r) => $r->getText(), $results));
// app/Providers/AIServiceProvider.php
public function register()
{
$this->app->bind(\Symfony\Component\AI\Store\StoreInterface::class, function ($app) {
return $app->make(StoreFactory::class)->getStore(config('ai_store.default'));
});
}
// app/Console/Commands/IndexDocuments.php
use Symfony\Component\AI\Store\Indexer\Indexer;
use Symfony\Component\AI\Store\Loader\JsonFileLoader;
protected function handle()
{
$loader = new JsonFileLoader('path/to/documents.json');
$indexer = new Indexer($this->store, $loader);
$indexer->index(); // Batch process documents
}
Because every store now exposes count(), you can build a cheap status endpoint or scheduled check without running a vector query:
Route::get('/ai/store/status', function (\Symfony\Component\AI\Store\StoreInterface $store) {
return response()->json([
'driver' => config('ai_store.default'),
'documents' => $store->count(),
]);
});
Pair it with a scheduled command that logs count() deltas to detect a stalled indexing job.
// Listen to query events (e.g., log queries)
use Symfony\Component\AI\Store\Event\PreQueryEvent;
public function boot()
{
event(new PreQueryEvent($query, $store));
}
use Symfony\Component\AI\Store\Transformer\TextSplitTransformer;
$transformer = new TextSplitTransformer(
chunkSize: 1000,
overlap: 200
);
$chunks = $transformer->transform($document);
// Dispatch a job for each document batch
IndexDocumentsJob::dispatch($documents)->onQueue('ai-indexing');
$indexer = new Indexer($store, $loader);
$indexer->index([
'batch_size' => 50,
'skip_header' => true, // interpreted by your loader
]);
class CsvLoader implements LoaderInterface
{
public function load(array $options = []): iterable
{
$skip = $options['skip_header'] ?? false;
// ...
}
}
Type against VectorDocumentInterface rather than TextDocument so you can carry rich metadata or a pre-computed vector without fighting the concrete class:
use Symfony\Component\AI\Store\VectorDocumentInterface;
final class ArticleDocument implements VectorDocumentInterface
{
public function __construct(
private string $id,
private string $text,
private array $metadata = [],
private ?array $vector = null,
) {}
public function getId(): string { return $this->id; }
public function getText(): string { return $this->text; }
public function getMetadata(): array { return $this->metadata; }
public function getVector(): ?array { return $this->vector; }
}
public function add(VectorDocumentInterface ...$documents): void;
// or, on the consuming side:
$store->add(new ArticleDocument('a1', $body, ['tenant' => $tenantId]));
VectorQuery now accepts any VectorInterface, so an embedding object returned by a platform adapter can be dropped in directly rather than being converted to a plain array:
use Symfony\Component\AI\Store\Query\VectorQuery;
$query = new VectorQuery($platformEmbedding, limit: 5); // VectorInterface
// Still fine: new VectorQuery([0.12, -0.44, ...], limit: 5);
use Symfony\Component\AI\Store\Query\HybridQuery;
$query = new HybridQuery(
vector: $embedding,
text: 'Laravel framework',
limit: 3
);
$results = $store->query($query);
Vector Dimension Mismatch:
embedding_dimension in config matches your model’s output (e.g., 384 for sentence-transformers/all-MiniLM-L6-v2).Memory Leaks with Lazy Iterators:
$store->query() results) in long-lived objects.iterator_to_array() if you need to materialize results immediately.Bridge-Specific Quirks:
pgvector extension. Run:
CREATE EXTENSION vector;
symfony/ai-redis-store but may need Redis 7+ for vector search.count() Semantics Are Bridge-Dependent (v0.14+):
int, but what is counted differs: documents vs. chunks vs. rows. If you chunked a document into 12 pieces, most bridges report 12, not 1.count() into user-facing dashboards or billing logic.Version Pinning:
VectorDocumentInterface and VectorInterface landed in 0.14, changing type hints in custom implementations). Pin the minor:
"symfony/ai-store": "^0.14.0"
TextDocument type hints — those are the places where VectorDocumentInterface is now the better contract.Loader Option Contract Is Informal:
batch_sise doing nothing.$store = $storeFactory->getStore('postgres');
$store->setLogger($this->app->make(Psr\Log\LoggerInterface::class));
PreQueryEvent to log raw queries before execution:
event(new PreQueryEvent($query, $store));
NaN or infinite values in embeddings:
$vector = $query->getVector();
if (array_key_exists(0, array_filter($vector, fn($v) => !is_finite($v)))) {
throw new \RuntimeException('Invalid vector detected');
}
VectorInterface support, normalise at the boundary: cast/normalise to array inside your own adapter so validation logic lives in one place rather than per-call-site.Batch Size Tuning:
100 documents per batch. Monitor memory usage and adjust.$indexer->setBatchSize(50); // For memory-constrained environments
Indexing Strategies:
ResetInterface to clear the store before bulk indexing:
if ($store instanceof \Symfony\Component\AI\Store\ResetInterface) {
$store->reset();
}
count(): After a bulk run, compare $store->count() against the number of documents loaded. A mismatch almost always means duplicate IDs collapsing or chunks silently dropped.Query Optimization:
distance calculations for large datasets:
$query->setDistance('cosine'); // Faster than 'euclidean' for high-dimensional vectors
filter in VectorQuery to narrow results:
$query->setFilter(['source' => 'docs']);
Custom Bridges:
StoreInterface for unsupported databases (e.g., MongoDB Atlas):
class MongoDbStore implements StoreInterface
{
public function add(iterable $documents): void
{
// Custom MongoDB logic
}
public function count(): int
{
// v0.14+: must be implemented
return $this->collection->countDocuments();
}
// ...
}
StoreFactory:
$factory->addStore('mongodb', new MongoDbStore($client));
Custom Transformers:
TransformerInterface for domain-specific preprocessing:
class DomainSpecificTransformer implements TransformerInterface
{
public function transform(VectorDocumentInterface $document): iterable
{
// Custom logic (e.g., extract entities before chunking)
}
}
Custom Loaders:
LoaderInterface and read options passed from the indexer (v0.14+) instead of hard-coding paths or credentials.Event Subscribers:
use Symfony\Component\AI\Store\Event\PreQueryEvent;
$subscriber = new class implements EventSubscriberInterface {
public function onPreQuery(PreQueryEvent $event)
{
$event->getQuery()->setLimit(min($event->getQuery()->getLimit(), 10));
}
};
ai-indexing) so embedding rate limits don't starve user-facing jobs.$key = 'rag:' . md5(json_encode([$vector, $filters, $limit]));
return Cache::remember($key, now()->addHour(), fn() => $store->query($query));
StoreInterface (see above) but resolve the concrete store from StoreFactory lazily — bridges hold connection objects you don't want duplicated per request.count():
count() call is a cheap liveness probe; add it to your health route or a scheduled ai:store:check command now that v0.14 makes it part of the contract.embedding_dimension lives in config/services.php while the model lives in config/ai.php. Reference a single env var for the model name and derive the dimension, or a model swap will silently break every query.How can I help you explore Laravel packages today?