symfony/ai-weaviate-store
Weaviate vector store integration for Symfony AI Store, with links to the REST API, GraphQL Get, and nearVector similarity search docs. Part of the Symfony AI monorepo for reporting issues and contributions.
Install and stand up Weaviate first. The package is only a bridge — it will not create collections for you.
composer require symfony/ai-weaviate-store
You need a reachable Weaviate instance before any PHP matters:
# docker-compose.yml
weaviate:
image: semitechnologies/weaviate:1.25.x
ports: ["8080:8080"]
environment:
QUERY_DEFAULTS_LIMIT: 25
AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: "true"
PERSISTENCE_DATA_PATH: /var/lib/weaviate
DEFAULT_VECTORIZER_MODULE: none
DEFAULT_VECTORIZER_MODULE: none is the important line — you are generating embeddings in PHP, so Weaviate should store vectors, not compute them.
Where to look first. The README is deliberately thin; it links to the three sources of truth you actually need:
StoreInterface / VectorDocument / query classes in symfony/ai-store — this is the API you code against day-to-day.nearVector search docs — because that is literally what your query() calls become on the wire.Wire it into Laravel. Since v0.7.0 there is a StoreFactory and ScopingHttpClient support, which is the intended entry point:
// app/Providers/AiServiceProvider.php
$this->app->singleton(StoreInterface::class, function ($app) {
$factory = new StoreFactory();
return $factory->create([
'dsn' => config('ai.weaviate.dsn'), // e.g. http://weaviate:8080
'headers' => [
'Authorization' => 'Bearer '.config('ai.weaviate.token'),
],
]);
});
Then bind a narrow application-facing repository on top of it — do not inject StoreInterface into controllers.
First use case: verify the round trip before building features. Create the collection, insert two vectors, query for one, and look at the result. If that works, everything else is chunking and prompt plumbing.
# schema is manual — REST, not PHP
curl -X POST http://localhost:8080/v1/schema -H 'Content-Type: application/json' -d '{
"class": "Document",
"vectorizer": "none",
"properties": [{"name": "content", "dataType": ["text"]}]
}'
use Symfony\AI\Store\Document\VectorDocument;
use Symfony\AI\Store\Document\Metadata;
use Symfony\AI\Platform\Vector\Vector;
$store->add(new VectorDocument(
id: 'doc-1',
vector: new Vector($embedding), // float[] from your embedding model
metadata: new Metadata(['content' => $text, 'source' => 'faq']),
));
If add() succeeds and query() returns your document, you have a working vector store. Confirm the exact constructor signatures against the version you installed — this API has shifted across 0.x releases.
Schema-as-Artisan-command. The bridge does not migrate schema, so make it explicit and idempotent:
class WeaviateSyncSchema extends Command
{
protected $signature = 'ai:weaviate:schema';
// GET /v1/schema, POST missing classes, PUT property additions
}
Keep collection name, vector dimensionality, and distance metric (cosine almost always) in config that both this command and the repository read from. Dimensionality drift between your embedding model and the collection is the single most common production break.
Ingestion pipeline (chunk → embed → store → queue). Never embed synchronously in a request. Pattern:
final class IndexDocumentJob implements ShouldQueue
{
public function handle(Embedder $embedder, VectorRepository $repo): void
{
foreach ($this->chunks() as $chunk) {
$repo->upsert(
id: $chunk->stableId(), // deterministic hash, so re-runs are idempotent
text: $chunk->text,
vector: $embedder->embed($chunk->text),
meta: ['doc_id' => $this->docId, 'chunk' => $chunk->index],
);
}
}
}
Deterministic IDs are what make re-indexing safe. If your IDs are random, deleting a document leaves orphans forever.
RAG retrieval. The query path is: embed the user's question with the same model, then nearVector, then stuff the top-k metadata into the prompt.
final class Retriever
{
public function context(string $question, int $k = 5): array
{
$vector = $this->embedder->embed($question); // same model as ingestion
$results = $this->store->query(new VectorQuery(new Vector($vector), limit: $k));
return array_map(fn($d) => $d->getMetadata()['content'] ?? '', $results);
}
}
Keep the embedder behind one interface used by both write and read paths. Two different embedding configurations is the second most common production break.
Counts for UX, not for truth. Since v0.14 there is a count capability. Use it for admin dashboards and "N results in the knowledge base" labels. For pagination totals on a filtered query, benchmark it first — Weaviate counts are cheap-ish but not free on large filtered collections.
Re-indexing and cleanup. v0.11 added clear(); v0.4 added remove(). Standard re-index flow:
$this->store->clear(); // or per-collection equivalent
dispatch(new IndexAllDocuments());
Prefer remove-by-ID over clear() in multi-tenant setups unless each tenant has its own collection.
Testing. Two layers, both worth having:
StoreInterface implementation — fast, no network, catches wiring mistakes.[1,0,0], [0,1,0]) so similarity ordering is deterministic and assertions are readable.Isolation layer. Wrap StoreInterface in a VectorRepository that speaks your domain (searchFaq, indexProduct, countByTenant). Symfony types stay inside. This is also your swap point if you move to Qdrant or pgvector later — the bridge is a dependency, not an architectural commitment.
The collection must exist, with vectorizer: none. If you leave the default vectorizer module on, Weaviate will re-embed your text with its own model and your nearVector results will be nonsense. DEFAULT_VECTORIZER_MODULE: none at the container level plus "vectorizer": "none" on the class.
Vector dimension mismatches surface as opaque 422s. Weaviate returns a generic-looking error. Log the HTTP body and assert count($embedding) === $config['dimensions'] in your repository upsert() — it will save you an afternoon.
GraphQL returns HTTP 200 on query errors. Weaviate puts errors in the errors array of a 200 response. If you're debugging "empty results", dump the raw body, not just the status code. Add a Symfony HttpClient middleware that logs request/response bodies at debug level — Laravel has no Symfony profiler, so this is your only visibility.
Pin the minor version. Everything is pre-1.0 (0.14.x at time of writing) and releases are cut from the symfony/ai monorepo, so several releases in the log genuinely say "no significant changes" — the churn is mostly sync bumps. But the breaking changes do land: VectorDocumentInterface arrived in 0.14, and if you built custom document DTOs before that, validate on a branch. "symfony/ai-weaviate-store": "~0.14.0", not ^0.14.
HTTP client friction is real. The bridge uses Symfony HttpClient; a Laravel app often has Guzzle in the tree for other reasons. They coexist fine, but Guzzle's global config never touches this client — so verify, proxy, and timeout must be set explicitly via the ScopingHttpClient options in the StoreFactory config. Don't assume your Guzzle defaults apply.
clear() semantics changed in 0.11. If you were deleting documents in a loop to emulate a wipe before that, re-read what clear() does in your version — it may be class-level, which on a shared collection is not what you want.
Eventual consistency after writes. A document add()ed a millisecond ago may not appear in a query() immediately. Tests that write-then-read in one process will flake. Either sleep briefly in integration tests or accept a retry-once helper in the repository.
Batching is your problem. The bridge is REST-per-operation unless you lean on Weaviate's batch endpoint yourself. For bulk ingestion (thousands of chunks), a direct batched REST call inside your Artisan backfill command is often an order of magnitude faster than looping add(). Keep add() for the incremental queue jobs.
Filters are relatively new. Query abstraction with filter support landed in 0.4; check what operators the bridge actually translates before designing a feature around metadata filtering. For anything advanced (nested filters, where + nearVector combinations the abstraction doesn't cover), drop to a direct Weaviate call in one clearly-marked adapter class rather than fighting the abstraction.
Extension points worth knowing. StoreInterface is small enough to decorate — a CachedStore, a LoggingStore, or a MultiTenantStore that prefixes collection names are all thin wrappers around the inner store and keep the Weaviate-specific code in one place. That's the cleanest way to add retries, metrics, or tenant isolation without forking the bridge.
How can I help you explore Laravel packages today?