symfony/ai-qdrant-store
Qdrant vector store integration for Symfony AI Store, with links to official Qdrant documentation on collections, points, and unified query search, plus Symfony AI contributing and issue resources.
Install the Package:
composer require symfony/ai-qdrant-store
Configure Qdrant Store in config/packages/ai.yaml (Symfony) or a Laravel config file if you're wiring it up manually:
framework:
ai:
stores:
qdrant:
type: qdrant
url: '%env(QDRANT_API_URL)%' # e.g., http://localhost:6333
api_key: '%env(QDRANT_API_KEY)%' # Optional if using auth
collection: 'your_collection_name'
options:
timeout: 30
retries: 3
First Use Case: Vector Storage, Search & Count
Inject the store via dependency injection (Symfony autowiring, or bind StoreInterface to the Qdrant store in a Laravel service provider):
use Symfony\AI\Store\StoreInterface;
use Symfony\AI\Store\Document\VectorDocumentInterface;
class DocumentService {
public function __construct(
private StoreInterface $qdrantStore
) {}
public function storeDocument(array $embedding, string $documentId): void {
$this->qdrantStore->upsert([
[
'id' => $documentId,
'vector' => $embedding,
'payload' => ['content' => '...', 'metadata' => [...]],
],
]);
}
public function findSimilarDocuments(array $queryEmbedding, int $limit = 5): array {
return $this->qdrantStore->search($queryEmbedding, limit: $limit);
}
// New in v0.14: cheap document counts (with optional filters)
public function countPublished(): int {
return $this->qdrantStore->count([
'must' => [['key' => 'status', 'match' => ['value' => 'published']]],
]);
}
}
Verify Collection Exists The store will auto-create the collection on first write. Check Qdrant's API:
curl -X GET http://localhost:6333/collections/your_collection_name
VectorDocumentInterface:
$this->qdrantStore->upsert([
[
'id' => 'doc_123',
'vector' => [0.1, 0.2, ...],
'payload' => ['title' => 'Example', 'tags' => ['ai', 'search']],
],
]);
$this->qdrantStore->remove(['doc_123']);
VectorDocumentInterface instances — both are accepted (v0.14 is additive, arrays still work).VectorDocumentInterface (new in v0.14)Implement the interface on a domain object to keep embeddings and payloads typed rather than juggling arrays:
use Symfony\AI\Store\Document\VectorDocumentInterface;
final class Article implements VectorDocumentInterface {
public function __construct(
private string $id,
private array $vector,
private array $payload,
) {}
public function getId(): string { return $this->id; }
public function getVector(): array { return $this->vector; }
public function getPayload(): array { return $this->payload; }
}
$this->qdrantStore->upsert([new Article('doc_123', $embedding, ['status' => 'published'])]);
This is the recommended path going forward for anything non-trivial — it gives you IDE completion, validation on construction, and a single place to evolve your document shape.
count() is the cheapest way to page, dashboard, or guard against re-indexing. It accepts the same filter payload as search():
$total = $this->qdrantStore->count(); // whole collection
$archived = $this->qdrantStore->count([
'must' => [['key' => 'status', 'match' => ['value' => 'archived']]],
]);
Typical uses: pagination totals, "do we need to re-embed this tenant?" checks, and cheap pre-flight validation before expensive upserts.
$results = $this->qdrantStore->search(
[0.15, 0.25, ...],
limit: 10,
filter: [
'must' => [
['key' => 'status', 'match' => ['value' => 'published']],
['key' => 'tags', 'match' => ['any' => ['ai']]],
],
]
);
Use Qdrant's filter syntax (must, should, must_not, range).
class EmbeddingProcessor {
public function __construct(
private StoreInterface $qdrantStore,
private MessageBusInterface $bus
) {}
public function processDocuments(array $documents): void {
$this->bus->dispatch(new ProcessEmbeddingsMessage($documents));
}
}
class SearchService {
public function __construct(
private EmbeddingGeneratorInterface $embeddingGenerator,
private StoreInterface $qdrantStore
) {}
public function semanticSearch(string $query, int $limit = 5): array {
$embedding = $this->embeddingGenerator->generate($query);
return $this->qdrantStore->search($embedding, limit: $limit);
}
}
QDRANT_API_URL, QDRANT_API_KEY).$store = new QdrantStore(
new QdrantClient($url, $apiKey),
'collection_' . $tenantId
);
StoreInterface in a service provider and use contextual bindings (when()->needs()->give()) for tenant-scoped stores.try {
$this->qdrantStore->upsert($points);
} catch (\Symfony\AI\Exception\StoreException $e) {
$this->logger->error('Qdrant upsert failed', ['error' => $e->getMessage()]);
throw new \RuntimeException('Failed to store embeddings', 0, $e);
}
count() (v0.14) instead of search() when you only need a number — it skips vector scoring entirely.$key = 'search_results_' . md5(serialize($query));
if (!$cache->has($key)) {
$cache->set($key, $this->qdrantStore->search($query), 3600);
}
$process = new Process(['docker', 'run', '-d', '-p', '6333:6333', 'qdrant/qdrant']);
$process->start();
count() is now also part of StoreInterface (or the counting capability interface), so add ->method('count')->willReturn(42) to your mocks.Initialize collections explicitly so the auto-created schema matches expectations:
class CollectionInitializer {
public function __construct(private QdrantClient $client) {}
public function initialize(string $collectionName, int $dimensions): void {
$this->client->recreateCollection($collectionName, [
'vectors' => ['size' => $dimensions, 'distance' => 'Cosine'],
'payload_schema' => [
'title' => new \stdClass(),
'tags' => new \stdClass(),
'status' => new \stdClass(),
],
]);
}
}
VectorDocumentInterface is Additive, Not MandatoryVectorDocumentInterface for domain objects and raw arrays only for one-off scripts. If you need a shared getter shape, extend your own base class implementing the interface once.count() Filter Semantics vs search()count() accepts the same filter payload as search(), but it does not take limit/offset/score_threshold. Passing those keys silently does nothing (or throws depending on client version).search() and count() so pagination totals and result pages stay consistent. Never eyeball a filter in one call and copy it to the other.count() on a Fresh/Empty Storecount() may trigger the same auto-creation path your first write would — or return 0 for a not-yet-existing collection, depending on the client. Either way, don't use count() === 0 as your "safe to write" guard without understanding which behaviour you're getting.count() dashboards or guards run.if (count($vector) !== $dimensions) {
throw new \InvalidArgumentException("Vector must be {$dimensions} dimensions");
}
search() and count()).StoreInterface in tests, adding count() to the store (v0.14) can produce "unexpected method call" failures at runtime rather than compile time.count() expectations to any mock that receives the store.count(), VectorDocumentInterface) are additive. Adopt VectorDocumentInterface progressively, not in a big-bang refactor.count() for pagination UIs — one cheap request per filtered page beats fetching full vectors just to compute a total.search() and count() in your codebase so a filter change can't desync between the two.How can I help you explore Laravel packages today?