Skip to content

Commit 9336705

Browse files
committed
feature #111 [Store] Add Qdrant (Guikingone)
This PR was merged into the main branch. Discussion ---------- [Store] Add Qdrant | Q | A | ------------- | --- | Bug fix? | no | New feature? | yes | Docs? | yes | Issues | None | License | MIT Hi 👋🏻 Alright, last one for today, here's the implementation for `Qdrant`, this one's tricky as the default search route is deprecated, the implementation is based on https://qdrant.tech/documentation/quickstart/#run-a-query, the whole store use the HTTP API along with the API key. Some options are supported (not all as some depends on the query you're building), more can be added if needed. Commits ------- 1a94a19 feat(store): qdrant store started
2 parents 9762c5d + 1a94a19 commit 9336705

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)