Skip to content

Commit 21c2889

Browse files
committed
Add ElasticSearch Store support
1 parent f2f71ae commit 21c2889

File tree

6 files changed

+403
-1
lines changed

6 files changed

+403
-1
lines changed

src/ai-bundle/config/options.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -752,6 +752,27 @@
752752
->end()
753753
->end()
754754
->end()
755+
->arrayNode('elasticsearch')
756+
->useAttributeAsKey('name')
757+
->arrayPrototype()
758+
->children()
759+
->stringNode('endpoint')->cannotBeEmpty()->end()
760+
->stringNode('index_name')->end()
761+
->stringNode('vectors_field')
762+
->defaultValue('_vectors')
763+
->end()
764+
->integerNode('dimensions')
765+
->defaultValue(1536)
766+
->end()
767+
->stringNode('similarity')
768+
->defaultValue('cosine')
769+
->end()
770+
->stringNode('http_client')
771+
->defaultValue('http_client')
772+
->end()
773+
->end()
774+
->end()
775+
->end()
755776
->arrayNode('opensearch')
756777
->useAttributeAsKey('name')
757778
->arrayPrototype()

src/ai-bundle/src/AiBundle.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@
8585
use Symfony\AI\Store\Bridge\ChromaDb\Store as ChromaDbStore;
8686
use Symfony\AI\Store\Bridge\ClickHouse\Store as ClickHouseStore;
8787
use Symfony\AI\Store\Bridge\Cloudflare\Store as CloudflareStore;
88+
use Symfony\AI\Store\Bridge\ElasticSearch\Store as ElasticSearchStore;
8889
use Symfony\AI\Store\Bridge\ManticoreSearch\Store as ManticoreSearchStore;
8990
use Symfony\AI\Store\Bridge\MariaDb\Store as MariaDbStore;
9091
use Symfony\AI\Store\Bridge\Meilisearch\Store as MeilisearchStore;
@@ -1379,6 +1380,33 @@ private function processStoreConfig(string $type, array $stores, ContainerBuilde
13791380
}
13801381
}
13811382

1383+
if ('elasticsearch' === $type) {
1384+
if (!ContainerBuilder::willBeAvailable('symfony/ai-elasticsearch-store', ElasticSearchStore::class, ['symfony/ai-bundle'])) {
1385+
throw new RuntimeException('ElasticSearch store configuration requires "symfony/ai-elasticsearch-store" package. Try running "composer require symfony/ai-elasticsearch-store".');
1386+
}
1387+
1388+
foreach ($stores as $name => $store) {
1389+
$definition = new Definition(ElasticSearchStore::class);
1390+
$definition
1391+
->setLazy(true)
1392+
->setArguments([
1393+
new Reference($store['http_client']),
1394+
$store['endpoint'],
1395+
$store['index_name'] ?? $name,
1396+
$store['vectors_field'],
1397+
$store['dimensions'],
1398+
$store['similarity'],
1399+
])
1400+
->addTag('proxy', ['interface' => StoreInterface::class])
1401+
->addTag('proxy', ['interface' => ManagedStoreInterface::class])
1402+
->addTag('ai.store');
1403+
1404+
$container->setDefinition('ai.store.'.$type.'.'.$name, $definition);
1405+
$container->registerAliasForArgument('ai.store.'.$type.'.'.$name, StoreInterface::class, $name);
1406+
$container->registerAliasForArgument('ai.store.'.$type.'.'.$name, StoreInterface::class, $type.'_'.$name);
1407+
}
1408+
}
1409+
13821410
if ('opensearch' === $type) {
13831411
if (!ContainerBuilder::willBeAvailable('symfony/ai-open-search-store', OpenSearchStore::class, ['symfony/ai-bundle'])) {
13841412
throw new RuntimeException('OpenSearch store configuration requires "symfony/ai-open-search-store" package. Try running "composer require symfony/ai-open-search-store".');

src/store/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ CHANGELOG
3838
- ChromaDB
3939
- ClickHouse
4040
- Cloudflare
41+
- ElasticSearch
4142
- Manticore Search
4243
- MariaDB
4344
- Meilisearch

src/store/composer.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"chromadb",
1010
"clickhouse",
1111
"cloudflare",
12+
"elasticsearch",
1213
"mariadb",
1314
"meilisearch",
1415
"milvus",
@@ -76,7 +77,10 @@
7677
}
7778
},
7879
"config": {
79-
"sort-packages": true
80+
"sort-packages": true,
81+
"allow-plugins": {
82+
"php-http/discovery": true
83+
}
8084
},
8185
"extra": {
8286
"branch-alias": {
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
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\ElasticSearch;
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+
final class Store implements ManagedStoreInterface, StoreInterface
25+
{
26+
public function __construct(
27+
private readonly HttpClientInterface $httpClient,
28+
private readonly string $endpoint,
29+
private readonly string $indexName,
30+
private readonly string $vectorsField = '_vectors',
31+
private readonly int $dimensions = 1536,
32+
private readonly string $similarity = 'cosine',
33+
) {
34+
}
35+
36+
public function setup(array $options = []): void
37+
{
38+
$indexExistResponse = $this->httpClient->request('HEAD', \sprintf('%s/%s', $this->endpoint, $this->indexName));
39+
40+
if (200 === $indexExistResponse->getStatusCode()) {
41+
return;
42+
}
43+
44+
$this->request('PUT', $this->indexName, [
45+
'mappings' => [
46+
'properties' => [
47+
$this->vectorsField => [
48+
'type' => 'dense_vector',
49+
'dims' => $options['dimensions'] ?? $this->dimensions,
50+
'similarity' => $options['similarity'] ?? $this->similarity,
51+
],
52+
],
53+
],
54+
]);
55+
}
56+
57+
public function drop(): void
58+
{
59+
$indexExistResponse = $this->httpClient->request('HEAD', \sprintf('%s/%s', $this->endpoint, $this->indexName));
60+
61+
if (404 === $indexExistResponse->getStatusCode()) {
62+
throw new InvalidArgumentException(\sprintf('The index "%s" does not exist.', $this->indexName));
63+
}
64+
65+
$this->request('DELETE', $this->indexName);
66+
}
67+
68+
public function add(VectorDocument ...$documents): void
69+
{
70+
$documentToIndex = fn (VectorDocument $document): array => [
71+
'index' => [
72+
'_index' => $this->indexName,
73+
'_id' => $document->id->toRfc4122(),
74+
],
75+
];
76+
77+
$documentToPayload = fn (VectorDocument $document): array => [
78+
$this->vectorsField => $document->vector->getData(),
79+
'metadata' => json_encode($document->metadata->getArrayCopy()),
80+
];
81+
82+
$this->request('POST', '_bulk', function () use ($documents, $documentToIndex, $documentToPayload) {
83+
foreach ($documents as $document) {
84+
yield json_encode($documentToIndex($document)).\PHP_EOL.json_encode($documentToPayload($document)).\PHP_EOL;
85+
}
86+
});
87+
}
88+
89+
public function query(Vector $vector, array $options = []): iterable
90+
{
91+
$k = $options['k'] ?? 100;
92+
$numCandidates = $options['num_candidates'] ?? max($k * 2, 100);
93+
94+
$documents = $this->request('POST', \sprintf('%s/_search', $this->indexName), [
95+
'knn' => [
96+
'field' => $this->vectorsField,
97+
'query_vector' => $vector->getData(),
98+
'k' => $k,
99+
'num_candidates' => $numCandidates,
100+
],
101+
]);
102+
103+
foreach ($documents['hits']['hits'] as $document) {
104+
yield $this->convertToVectorDocument($document);
105+
}
106+
}
107+
108+
/**
109+
* @param \Closure|array<string, mixed> $payload
110+
*
111+
* @return array<string, mixed>
112+
*/
113+
private function request(string $method, string $path, \Closure|array $payload = []): array
114+
{
115+
$finalOptions = [];
116+
117+
if (\is_array($payload) && [] !== $payload) {
118+
$finalOptions['json'] = $payload;
119+
}
120+
121+
if ($payload instanceof \Closure) {
122+
$finalOptions = [
123+
'headers' => [
124+
'Content-Type' => 'application/x-ndjson',
125+
],
126+
'body' => $payload(),
127+
];
128+
}
129+
130+
$response = $this->httpClient->request($method, \sprintf('%s/%s', $this->endpoint, $path), $finalOptions);
131+
132+
return $response->toArray();
133+
}
134+
135+
/**
136+
* @param array{
137+
* '_id'?: string,
138+
* '_source': array<string, mixed>,
139+
* '_score': float,
140+
* } $document
141+
*/
142+
private function convertToVectorDocument(array $document): VectorDocument
143+
{
144+
$id = $document['_id'] ?? throw new InvalidArgumentException('Missing "_id" field in the document data.');
145+
146+
$vector = !\array_key_exists($this->vectorsField, $document['_source']) || null === $document['_source'][$this->vectorsField]
147+
? new NullVector()
148+
: new Vector($document['_source'][$this->vectorsField]);
149+
150+
return new VectorDocument(Uuid::fromString($id), $vector, new Metadata(json_decode($document['_source']['metadata'], true)), $document['_score'] ?? null);
151+
}
152+
}

0 commit comments

Comments
 (0)