Skip to content

Commit 1a94a19

Browse files
committed
feat(store): qdrant store started
1 parent c0266db commit 1a94a19

File tree

7 files changed

+433
-5
lines changed

7 files changed

+433
-5
lines changed

examples/.env

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,7 @@ MEILISEARCH_API_KEY=changeMe
8181

8282
# For using LMStudio
8383
LMSTUDIO_HOST_URL=http://127.0.0.1:1234
84+
85+
# Qdrant
86+
QDRANT_HOST='http://127.0.0.1:6333'
87+
QDRANT_SERVICE_API_KEY=changeMe

examples/compose.yaml

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ services:
55
MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 1
66
MARIADB_DATABASE: my_database
77
ports:
8-
- "3309:3306"
8+
- '3309:3306'
99

1010
postgres:
1111
image: pgvector/pgvector:0.8.0-pg17
@@ -14,11 +14,18 @@ services:
1414
POSTGRES_USER: postgres
1515
POSTGRES_PASSWORD: postgres
1616
ports:
17-
- "5432:5432"
17+
- '5432:5432'
1818

1919
meilisearch:
2020
image: getmeili/meilisearch:v1.15
2121
environment:
22-
MEILI_MASTER_KEY: "${MEILISEARCH_MASTER_KEY:-changeMe}"
22+
MEILI_MASTER_KEY: '${MEILISEARCH_MASTER_KEY:-changeMe}'
2323
ports:
24-
- "7700:7700"
24+
- '7700:7700'
25+
26+
qdrant:
27+
image: qdrant/qdrant
28+
environment:
29+
QDRANT__SERVICE__API_KEY: '${QDRAT_SERVICE_API_KEY:-changeMe}'
30+
ports:
31+
- '6333:6333'
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
use Symfony\AI\Agent\Agent;
13+
use Symfony\AI\Agent\Toolbox\AgentProcessor;
14+
use Symfony\AI\Agent\Toolbox\Tool\SimilaritySearch;
15+
use Symfony\AI\Agent\Toolbox\Toolbox;
16+
use Symfony\AI\Fixtures\Movies;
17+
use Symfony\AI\Platform\Bridge\OpenAI\Embeddings;
18+
use Symfony\AI\Platform\Bridge\OpenAI\GPT;
19+
use Symfony\AI\Platform\Bridge\OpenAI\PlatformFactory;
20+
use Symfony\AI\Platform\Message\Message;
21+
use Symfony\AI\Platform\Message\MessageBag;
22+
use Symfony\AI\Store\Bridge\Qdrant\Store;
23+
use Symfony\AI\Store\Document\Metadata;
24+
use Symfony\AI\Store\Document\TextDocument;
25+
use Symfony\AI\Store\Document\Vectorizer;
26+
use Symfony\AI\Store\Indexer;
27+
use Symfony\Component\Dotenv\Dotenv;
28+
use Symfony\Component\HttpClient\HttpClient;
29+
use Symfony\Component\Uid\Uuid;
30+
31+
require_once dirname(__DIR__).'/vendor/autoload.php';
32+
(new Dotenv())->loadEnv(dirname(__DIR__).'/.env');
33+
34+
if (!isset($_SERVER['OPENAI_API_KEY'], $_SERVER['QDRANT_HOST'], $_SERVER['QDRANT_SERVICE_API_KEY'])) {
35+
echo 'Please set OPENAI_API_KEY, QDRANT_HOST and QDRANT_SERVICE_API_KEY environment variables.'.\PHP_EOL;
36+
exit(1);
37+
}
38+
39+
// initialize the store
40+
$store = new Store(
41+
HttpClient::create(),
42+
'http://127.0.0.1:6333',
43+
$_SERVER['QDRANT_SERVICE_API_KEY'],
44+
'movies',
45+
);
46+
47+
// initialize the collection (needs to be called before the indexer)
48+
$store->initialize();
49+
50+
// create embeddings and documents
51+
foreach (Movies::all() as $movie) {
52+
$documents[] = new TextDocument(
53+
id: Uuid::v4(),
54+
content: 'Title: '.$movie['title'].\PHP_EOL.'Director: '.$movie['director'].\PHP_EOL.'Description: '.$movie['description'],
55+
metadata: new Metadata($movie),
56+
);
57+
}
58+
59+
// create embeddings for documents
60+
$platform = PlatformFactory::create($_SERVER['OPENAI_API_KEY']);
61+
$vectorizer = new Vectorizer($platform, $embeddings = new Embeddings());
62+
$indexer = new Indexer($vectorizer, $store);
63+
$indexer->index($documents);
64+
65+
$model = new GPT(GPT::GPT_4O_MINI);
66+
67+
$similaritySearch = new SimilaritySearch($platform, $embeddings, $store);
68+
$toolbox = Toolbox::create($similaritySearch);
69+
$processor = new AgentProcessor($toolbox);
70+
$agent = new Agent($platform, $model, [$processor], [$processor]);
71+
72+
$messages = new MessageBag(
73+
Message::forSystem('Please answer all user questions only using SimilaritySearch function.'),
74+
Message::ofUser('Which movie fits the theme of the mafia?')
75+
);
76+
$response = $agent->call($messages);
77+
78+
echo $response->getContent().\PHP_EOL;

src/store/CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ CHANGELOG
4242
- Meilisearch
4343
- ChromaDB
4444
- Pinecone
45+
- Qdrant
4546
* Add Retrieval Augmented Generation (RAG) support:
4647
- Document embedding storage
4748
- Similarity search for relevant documents
@@ -52,4 +53,4 @@ CHANGELOG
5253
- Result limiting
5354
- Distance/similarity scoring
5455
* Add custom exception hierarchy with `ExceptionInterface`
55-
* Add support for specific exceptions for invalid arguments and runtime errors
56+
* Add support for specific exceptions for invalid arguments and runtime errors

src/store/doc/index.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ You can find more advanced usage in combination with an Agent using the store fo
4242
* `Similarity Search with Pinecone (RAG)`_
4343
* `Similarity Search with Meilisearch (RAG)`_
4444
* `Similarity Search with memory storage (RAG)`_
45+
* `Similarity Search with Qdrant (RAG)`_
4546

4647
Supported Stores
4748
----------------
@@ -54,6 +55,7 @@ Supported Stores
5455
* `Postgres`_ (requires `ext-pdo`)
5556
* `Meilisearch`_
5657
* `InMemory`_
58+
* `Qdrant`_
5759

5860
.. note::
5961

@@ -92,6 +94,7 @@ This leads to a store implementing two methods::
9294
.. _`Similarity Search with Pinecone (RAG)`: https://github.com/symfony/ai/blob/main/examples/store/pinecone-similarity-search.php
9395
.. _`Similarity Search with Meilisearch (RAG)`: https://github.com/symfony/ai/blob/main/examples/store/meilisearch-similarity-search.php
9496
.. _`Similarity Search with memory storage (RAG)`: https://github.com/symfony/ai/blob/main/examples/store/memory-similarity-search.php
97+
.. _`Similarity Search with Qdrant (RAG)`: https://github.com/symfony/ai/blob/main/examples/store/qdrant-similarity-search.php
9598
.. _`Azure AI Search`: https://azure.microsoft.com/products/ai-services/ai-search
9699
.. _`Chroma`: https://www.trychroma.com/
97100
.. _`MariaDB`: https://mariadb.org/projects/mariadb-vector/
@@ -100,4 +103,5 @@ This leads to a store implementing two methods::
100103
.. _`Postgres`: https://www.postgresql.org/about/news/pgvector-070-released-2852/
101104
.. _`Meilisearch`: https://www.meilisearch.com/
102105
.. _`InMemory`: https://www.php.net/manual/en/language.types.array.php
106+
.. _`Qdrant`: https://qdrant.tech/
103107
.. _`GitHub`: https://github.com/symfony/ai/issues/16

src/store/src/Bridge/Qdrant/Store.php

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Store\Bridge\Qdrant;
13+
14+
use Symfony\AI\Platform\Vector\NullVector;
15+
use Symfony\AI\Platform\Vector\Vector;
16+
use Symfony\AI\Store\Document\Metadata;
17+
use Symfony\AI\Store\Document\VectorDocument;
18+
use Symfony\AI\Store\Exception\InvalidArgumentException;
19+
use Symfony\AI\Store\InitializableStoreInterface;
20+
use Symfony\AI\Store\VectorStoreInterface;
21+
use Symfony\Component\Uid\Uuid;
22+
use Symfony\Contracts\HttpClient\HttpClientInterface;
23+
24+
/**
25+
* @author Guillaume Loulier <[email protected]>
26+
*/
27+
final readonly class Store implements InitializableStoreInterface, VectorStoreInterface
28+
{
29+
public function __construct(
30+
private HttpClientInterface $httpClient,
31+
private string $endpointUrl,
32+
#[\SensitiveParameter] private string $apiKey,
33+
private string $collectionName,
34+
private int $embeddingsDimension = 1536,
35+
private string $embeddingsDistance = 'Cosine',
36+
) {
37+
}
38+
39+
public function add(VectorDocument ...$documents): void
40+
{
41+
$this->request('PUT', \sprintf('collections/%s/points', $this->collectionName), [
42+
'points' => array_map($this->convertToIndexableArray(...), $documents),
43+
]);
44+
}
45+
46+
/**
47+
* @param array{
48+
* limit?: positive-int,
49+
* offset?: positive-int
50+
* } $options
51+
*/
52+
public function query(Vector $vector, array $options = [], ?float $minScore = null): array
53+
{
54+
$payload = [
55+
'vector' => $vector->getData(),
56+
'with_payload' => true,
57+
'with_vector' => true,
58+
];
59+
60+
if (\array_key_exists('limit', $options)) {
61+
$payload['limit'] = $options['limit'];
62+
}
63+
64+
if (\array_key_exists('offset', $options)) {
65+
$payload['offset'] = $options['offset'];
66+
}
67+
68+
$response = $this->request('POST', \sprintf('collections/%s/points/query', $this->collectionName), $payload);
69+
70+
return array_map($this->convertToVectorDocument(...), $response['result']['points']);
71+
}
72+
73+
public function initialize(array $options = []): void
74+
{
75+
if ([] !== $options) {
76+
throw new InvalidArgumentException('No supported options');
77+
}
78+
79+
$collectionExistResponse = $this->request('GET', \sprintf('collections/%s/exists', $this->collectionName));
80+
81+
if ($collectionExistResponse['result']['exists']) {
82+
return;
83+
}
84+
85+
$this->request('PUT', \sprintf('collections/%s', $this->collectionName), [
86+
'vectors' => [
87+
'size' => $this->embeddingsDimension,
88+
'distance' => $this->embeddingsDistance,
89+
],
90+
]);
91+
}
92+
93+
/**
94+
* @param array<string, mixed> $payload
95+
*
96+
* @return array<string, mixed>
97+
*/
98+
private function request(string $method, string $endpoint, array $payload = []): array
99+
{
100+
$url = \sprintf('%s/%s', $this->endpointUrl, $endpoint);
101+
102+
$response = $this->httpClient->request($method, $url, [
103+
'headers' => [
104+
'api-key' => $this->apiKey,
105+
],
106+
'json' => $payload,
107+
]);
108+
109+
return $response->toArray();
110+
}
111+
112+
/**
113+
* @return array<string, mixed>
114+
*/
115+
private function convertToIndexableArray(VectorDocument $document): array
116+
{
117+
return [
118+
'id' => $document->id->toRfc4122(),
119+
'vector' => $document->vector->getData(),
120+
'payload' => $document->metadata->getArrayCopy(),
121+
];
122+
}
123+
124+
/**
125+
* @param array<string, mixed> $data
126+
*/
127+
private function convertToVectorDocument(array $data): VectorDocument
128+
{
129+
$id = $data['id'] ?? throw new InvalidArgumentException('Missing "id" field in the document data');
130+
131+
$vector = !\array_key_exists('vector', $data) || null === $data['vector']
132+
? new NullVector()
133+
: new Vector($data['vector']);
134+
135+
return new VectorDocument(
136+
id: Uuid::fromString($id),
137+
vector: $vector,
138+
metadata: new Metadata($data['payload']),
139+
score: $data['score'] ?? null
140+
);
141+
}
142+
}

0 commit comments

Comments
 (0)