Skip to content

Commit 225146f

Browse files
committed
feat(store): Introduce support
1 parent 57ef243 commit 225146f

File tree

10 files changed

+412
-0
lines changed

10 files changed

+412
-0
lines changed

docs/components/store.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ Similarity Search Examples
7070
* `Similarity Search with Milvus (RAG)`_
7171
* `Similarity Search with MongoDB (RAG)`_
7272
* `Similarity Search with Neo4j (RAG)`_
73+
* `Similarity Search with OpenSearch (RAG)`_
7374
* `Similarity Search with Pinecone (RAG)`_
7475
* `Similarity Search with Qdrant (RAG)`_
7576
* `Similarity Search with SurrealDB (RAG)`_
@@ -97,6 +98,7 @@ Supported Stores
9798
* `Milvus`_
9899
* `MongoDB Atlas`_ (requires ``mongodb/mongodb`` as additional dependency)
99100
* `Neo4j`_
101+
* `OpenSearch`_
100102
* `Pinecone`_ (requires ``probots-io/pinecone-php`` as additional dependency)
101103
* `Postgres`_ (requires ``ext-pdo``)
102104
* `Qdrant`_
@@ -165,6 +167,7 @@ This leads to a store implementing two methods::
165167
.. _`Similarity Search with Milvus (RAG)`: https://github.com/symfony/ai/blob/main/examples/rag/milvus.php
166168
.. _`Similarity Search with MongoDB (RAG)`: https://github.com/symfony/ai/blob/main/examples/rag/mongodb.php
167169
.. _`Similarity Search with Neo4j (RAG)`: https://github.com/symfony/ai/blob/main/examples/rag/neo4j.php
170+
.. _`Similarity Search with OpenSearch (RAG)`: https://github.com/symfony/ai/blob/main/examples/rag/opensearch.php
168171
.. _`Similarity Search with Pinecone (RAG)`: https://github.com/symfony/ai/blob/main/examples/rag/pinecone.php
169172
.. _`Similarity Search with Symfony Cache (RAG)`: https://github.com/symfony/ai/blob/main/examples/rag/cache.php
170173
.. _`Similarity Search with Qdrant (RAG)`: https://github.com/symfony/ai/blob/main/examples/rag/qdrant.php
@@ -186,6 +189,7 @@ This leads to a store implementing two methods::
186189
.. _`InMemory`: https://www.php.net/manual/en/language.types.array.php
187190
.. _`Qdrant`: https://qdrant.tech/
188191
.. _`Neo4j`: https://neo4j.com/
192+
.. _`OpenSearch`: https://opensearch.org/
189193
.. _`Typesense`: https://typesense.org/
190194
.. _`Symfony Cache`: https://symfony.com/doc/current/components/cache.html
191195
.. _`Weaviate`: https://weaviate.io/

examples/.env

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,3 +182,6 @@ REDIS_HOST=localhost
182182

183183
# Manticore (store)
184184
MANTICORE_HOST=http://127.0.0.1:9308
185+
186+
# OpenSearch (store)
187+
OPENSEARCH_ENDPOINT=http://127.0.0.1:9200

examples/commands/stores.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
use Symfony\AI\Store\Bridge\Milvus\Store as MilvusStore;
2424
use Symfony\AI\Store\Bridge\MongoDb\Store as MongoDbStore;
2525
use Symfony\AI\Store\Bridge\Neo4j\Store as Neo4jStore;
26+
use Symfony\AI\Store\Bridge\OpenSearch\Store as OpenSearchStore;
2627
use Symfony\AI\Store\Bridge\Postgres\Store as PostgresStore;
2728
use Symfony\AI\Store\Bridge\Qdrant\Store as QdrantStore;
2829
use Symfony\AI\Store\Bridge\Redis\Store as RedisStore;
@@ -86,6 +87,11 @@
8687
vectorIndexName: 'Commands',
8788
nodeName: 'symfony',
8889
),
90+
'opensearch' => static fn (): OpenSearchStore => new OpenSearchStore(
91+
http_client(),
92+
env('OPENSEARCH_ENDPOINT'),
93+
'symfony',
94+
),
8995
'postgres' => static fn (): PostgresStore => PostgresStore::fromDbal(
9096
DriverManager::getConnection((new DsnParser())->parse(env('POSTGRES_URI'))),
9197
'my_table',

examples/compose.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,16 @@ services:
124124
- '7474:7474'
125125
- '7687:7687'
126126

127+
opensearch:
128+
image: opensearchproject/opensearch
129+
environment:
130+
discovery.type: 'single-node'
131+
indices.requests.cache.maximum_cacheable_size: 256
132+
DISABLE_SECURITY_PLUGIN: true
133+
ports:
134+
- '9200:9200'
135+
- '9600:9600'
136+
127137
pogocache:
128138
image: pogocache/pogocache
129139
command: [ 'pogocache', '--auth', 'symfony' ]

examples/rag/opensearch.php

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
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\Bridge\SimilaritySearch\SimilaritySearch;
14+
use Symfony\AI\Agent\Toolbox\AgentProcessor;
15+
use Symfony\AI\Agent\Toolbox\Toolbox;
16+
use Symfony\AI\Fixtures\Movies;
17+
use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory;
18+
use Symfony\AI\Platform\Message\Message;
19+
use Symfony\AI\Platform\Message\MessageBag;
20+
use Symfony\AI\Store\Bridge\OpenSearch\Store;
21+
use Symfony\AI\Store\Document\Loader\InMemoryLoader;
22+
use Symfony\AI\Store\Document\Metadata;
23+
use Symfony\AI\Store\Document\TextDocument;
24+
use Symfony\AI\Store\Document\Vectorizer;
25+
use Symfony\AI\Store\Indexer;
26+
use Symfony\Component\Uid\Uuid;
27+
28+
require_once dirname(__DIR__).'/bootstrap.php';
29+
30+
// initialize the store
31+
$store = new Store(
32+
httpClient: http_client(),
33+
endpoint: env('OPENSEARCH_ENDPOINT'),
34+
indexName: 'movies',
35+
);
36+
37+
// create embeddings and documents
38+
$documents = [];
39+
foreach (Movies::all() as $i => $movie) {
40+
$documents[] = new TextDocument(
41+
id: Uuid::v4(),
42+
content: 'Title: '.$movie['title'].\PHP_EOL.'Director: '.$movie['director'].\PHP_EOL.'Description: '.$movie['description'],
43+
metadata: new Metadata($movie),
44+
);
45+
}
46+
47+
// initialize the index
48+
$store->setup();
49+
50+
// create embeddings for documents
51+
$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client());
52+
$vectorizer = new Vectorizer($platform, 'text-embedding-3-small', logger());
53+
$indexer = new Indexer(new InMemoryLoader($documents), $vectorizer, $store, logger: logger());
54+
$indexer->index($documents);
55+
56+
$similaritySearch = new SimilaritySearch($vectorizer, $store);
57+
$toolbox = new Toolbox([$similaritySearch], logger: logger());
58+
$processor = new AgentProcessor($toolbox);
59+
$agent = new Agent($platform, 'gpt-4o-mini', [$processor], [$processor]);
60+
61+
$messages = new MessageBag(
62+
Message::forSystem('Please answer all user questions only using SimilaritySearch function.'),
63+
Message::ofUser('Which movie fits the theme of technology?')
64+
);
65+
$result = $agent->call($messages);
66+
67+
echo $result->getContent().\PHP_EOL;

src/ai-bundle/config/options.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -691,6 +691,21 @@
691691
->end()
692692
->end()
693693
->end()
694+
->arrayNode('opensearch')
695+
->useAttributeAsKey('name')
696+
->arrayPrototype()
697+
->children()
698+
->stringNode('endpoint')->cannotBeEmpty()->end()
699+
->stringNode('index_name')->cannotBeEmpty()->end()
700+
->stringNode('vectors_field')->isRequired()->end()
701+
->stringNode('dimensions')->isRequired()->end()
702+
->stringNode('space_type')->isRequired()->end()
703+
->stringNode('http_client')
704+
->defaultValue('http_client')
705+
->end()
706+
->end()
707+
->end()
708+
->end()
694709
->arrayNode('pinecone')
695710
->useAttributeAsKey('name')
696711
->arrayPrototype()

src/ai-bundle/src/AiBundle.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@
9191
use Symfony\AI\Store\Bridge\Milvus\Store as MilvusStore;
9292
use Symfony\AI\Store\Bridge\MongoDb\Store as MongoDbStore;
9393
use Symfony\AI\Store\Bridge\Neo4j\Store as Neo4jStore;
94+
use Symfony\AI\Store\Bridge\OpenSearch\Store as OpenSearchStore;
9495
use Symfony\AI\Store\Bridge\Pinecone\Store as PineconeStore;
9596
use Symfony\AI\Store\Bridge\Postgres\Store as PostgresStore;
9697
use Symfony\AI\Store\Bridge\Qdrant\Store as QdrantStore;
@@ -1281,6 +1282,29 @@ private function processStoreConfig(string $type, array $stores, ContainerBuilde
12811282
}
12821283
}
12831284

1285+
if ('opensearch' === $type) {
1286+
foreach ($stores as $name => $store) {
1287+
$definition = new Definition(OpenSearchStore::class);
1288+
$definition
1289+
->setLazy(true)
1290+
->setArguments([
1291+
new Reference($store['http_client']),
1292+
$store['endpoint'],
1293+
$store['index_name'],
1294+
$store['vectors_field'],
1295+
$store['dimensions'],
1296+
$store['space_type'],
1297+
])
1298+
->addTag('proxy', ['interface' => StoreInterface::class])
1299+
->addTag('proxy', ['interface' => ManagedStoreInterface::class])
1300+
->addTag('ai.store');
1301+
1302+
$container->setDefinition('ai.store.'.$type.'.'.$name, $definition);
1303+
$container->registerAliasForArgument('ai.store.'.$type.'.'.$name, StoreInterface::class, $name);
1304+
$container->registerAliasForArgument('ai.store.'.$type.'.'.$name, StoreInterface::class, $type.'_'.$name);
1305+
}
1306+
}
1307+
12841308
if ('pinecone' === $type) {
12851309
foreach ($stores as $name => $store) {
12861310
$arguments = [

src/store/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ CHANGELOG
4343
- Meilisearch
4444
- MongoDB
4545
- Neo4j
46+
- OpenSearch
4647
- Pinecone
4748
- PostgreSQL with pgvector extension
4849
- Qdrant
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
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\OpenSearch;
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\ManagedStoreInterface;
20+
use Symfony\AI\Store\StoreInterface;
21+
use Symfony\Component\Uid\Uuid;
22+
use Symfony\Contracts\HttpClient\HttpClientInterface;
23+
24+
/**
25+
* @author Guillaume Loulier <personal@guillaumeloulier.fr>
26+
*/
27+
final class Store implements ManagedStoreInterface, StoreInterface
28+
{
29+
public function __construct(
30+
private readonly HttpClientInterface $httpClient,
31+
private readonly string $endpoint,
32+
private readonly string $indexName,
33+
private readonly string $vectorsField = '_vectors',
34+
private readonly int $dimensions = 1536,
35+
private readonly string $spaceType = 'l2',
36+
) {
37+
}
38+
39+
public function setup(array $options = []): void
40+
{
41+
$indexExistResponse = $this->httpClient->request('HEAD', \sprintf('%s/%s', $this->endpoint, $this->indexName));
42+
43+
if (200 === $indexExistResponse->getStatusCode()) {
44+
return;
45+
}
46+
47+
$this->request('PUT', $this->indexName, [
48+
'settings' => [
49+
'index.knn' => true,
50+
],
51+
'mappings' => [
52+
'properties' => [
53+
$this->vectorsField => [
54+
'type' => 'knn_vector',
55+
'dimension' => $options['dimensions'] ?? $this->dimensions,
56+
'space_type' => $options['space_type'] ?? $this->spaceType,
57+
],
58+
],
59+
],
60+
]);
61+
}
62+
63+
public function drop(): void
64+
{
65+
$indexExistResponse = $this->httpClient->request('HEAD', \sprintf('%s/%s', $this->endpoint, $this->indexName));
66+
67+
if (404 === $indexExistResponse->getStatusCode()) {
68+
throw new InvalidArgumentException(\sprintf('The index "%s" does not exist.', $this->indexName));
69+
}
70+
71+
$this->request('DELETE', $this->indexName);
72+
}
73+
74+
public function add(VectorDocument ...$documents): void
75+
{
76+
$documentToIndex = fn (VectorDocument $document): array => [
77+
'index' => [
78+
'_index' => $this->indexName,
79+
'_id' => $document->id->toRfc4122(),
80+
],
81+
];
82+
83+
$documentToPayload = fn (VectorDocument $document): array => [
84+
$this->vectorsField => $document->vector->getData(),
85+
'metadata' => json_encode($document->metadata->getArrayCopy()),
86+
];
87+
88+
$this->request('POST', '_bulk', function () use ($documents, $documentToIndex, $documentToPayload) {
89+
foreach ($documents as $document) {
90+
yield json_encode($documentToIndex($document)).\PHP_EOL.json_encode($documentToPayload($document)).\PHP_EOL;
91+
}
92+
});
93+
}
94+
95+
public function query(Vector $vector, array $options = []): iterable
96+
{
97+
$documents = $this->request('POST', \sprintf('%s/_search', $this->indexName), [
98+
'size' => $options['size'] ?? 100,
99+
'query' => [
100+
'knn' => [
101+
$this->vectorsField => [
102+
'vector' => $vector->getData(),
103+
'k' => $options['k'] ?? 100,
104+
],
105+
],
106+
],
107+
]);
108+
109+
foreach ($documents['hits']['hits'] as $document) {
110+
yield $this->convertToVectorDocument($document);
111+
}
112+
}
113+
114+
/**
115+
* @param \Closure|array<string, mixed> $payload
116+
*
117+
* @return array<string, mixed>
118+
*/
119+
private function request(string $method, string $path, \Closure|array $payload = []): array
120+
{
121+
$finalOptions = [];
122+
123+
if (\is_array($payload) && [] !== $payload) {
124+
$finalOptions['json'] = $payload;
125+
}
126+
127+
if ($payload instanceof \Closure) {
128+
$finalOptions = [
129+
'headers' => [
130+
'Content-Type' => 'application/x-ndjson',
131+
],
132+
'body' => $payload(),
133+
];
134+
}
135+
136+
$response = $this->httpClient->request($method, \sprintf('%s/%s', $this->endpoint, $path), $finalOptions);
137+
138+
return $response->toArray();
139+
}
140+
141+
/**
142+
* @param array{
143+
* '_id'?: string,
144+
* '_source': array<string, mixed>,
145+
* '_score': float,
146+
* } $document
147+
*/
148+
private function convertToVectorDocument(array $document): VectorDocument
149+
{
150+
$id = $document['_id'] ?? throw new InvalidArgumentException('Missing "_id" field in the document data.');
151+
152+
$vector = !\array_key_exists($this->vectorsField, $document['_source']) || null === $document['_source'][$this->vectorsField]
153+
? new NullVector()
154+
: new Vector($document['_source'][$this->vectorsField]);
155+
156+
return new VectorDocument(Uuid::fromString($id), $vector, new Metadata(json_decode($document['_source']['metadata'], true)), $document['_score'] ?? null);
157+
}
158+
}

0 commit comments

Comments
 (0)