From a22a54baf92fc538cb7bf5517160b8324d50b544 Mon Sep 17 00:00:00 2001 From: llupa Date: Wed, 10 Dec 2025 22:06:40 +0100 Subject: [PATCH] Add ElasticSearch Store support --- examples/.env | 3 + examples/commands/stores.php | 6 + examples/compose.yaml | 22 ++ examples/composer.json | 1 + examples/rag/elasticsearch.php | 67 ++++++ splitsh.json | 1 + src/ai-bundle/config/options.php | 21 ++ src/ai-bundle/src/AiBundle.php | 28 +++ src/store/CHANGELOG.md | 1 + src/store/composer.json | 1 + .../src/Bridge/Elasticsearch/.gitattributes | 3 + src/store/src/Bridge/Elasticsearch/.gitignore | 4 + src/store/src/Bridge/Elasticsearch/LICENSE | 19 ++ src/store/src/Bridge/Elasticsearch/Store.php | 152 ++++++++++++++ .../Bridge/Elasticsearch/Tests/StoreTest.php | 196 ++++++++++++++++++ .../src/Bridge/Elasticsearch/composer.json | 56 +++++ .../src/Bridge/Elasticsearch/phpunit.xml.dist | 32 +++ 17 files changed, 613 insertions(+) create mode 100644 examples/rag/elasticsearch.php create mode 100644 src/store/src/Bridge/Elasticsearch/.gitattributes create mode 100644 src/store/src/Bridge/Elasticsearch/.gitignore create mode 100644 src/store/src/Bridge/Elasticsearch/LICENSE create mode 100644 src/store/src/Bridge/Elasticsearch/Store.php create mode 100644 src/store/src/Bridge/Elasticsearch/Tests/StoreTest.php create mode 100644 src/store/src/Bridge/Elasticsearch/composer.json create mode 100644 src/store/src/Bridge/Elasticsearch/phpunit.xml.dist diff --git a/examples/.env b/examples/.env index ec6055a1f..fce85f4b8 100644 --- a/examples/.env +++ b/examples/.env @@ -187,5 +187,8 @@ REDIS_HOST=localhost # Manticore Search (store) MANTICORESEARCH_HOST=http://127.0.0.1:9308 +# Elasticsearch (store) +ELASTICSEARCH_ENDPOINT=http://127.0.0.1:9201 + # OpenSearch (store) OPENSEARCH_ENDPOINT=http://127.0.0.1:9200 diff --git a/examples/commands/stores.php b/examples/commands/stores.php index 72d6eff74..5ef2eb5e5 100644 --- a/examples/commands/stores.php +++ b/examples/commands/stores.php @@ -16,6 +16,7 @@ use MongoDB\Client as MongoDbClient; use Symfony\AI\Store\Bridge\Cache\Store as CacheStore; use Symfony\AI\Store\Bridge\ClickHouse\Store as ClickHouseStore; +use Symfony\AI\Store\Bridge\Elasticsearch\Store as ElasticsearchStore; use Symfony\AI\Store\Bridge\ManticoreSearch\Store as ManticoreSearchStore; use Symfony\AI\Store\Bridge\MariaDb\Store as MariaDbStore; use Symfony\AI\Store\Bridge\Meilisearch\Store as MeilisearchStore; @@ -47,6 +48,11 @@ env('CLICKHOUSE_DATABASE'), env('CLICKHOUSE_TABLE'), ), + 'elasticsearch' => static fn (): ElasticsearchStore => new ElasticsearchStore( + http_client(), + env('ELASTICSEARCH_ENDPOINT'), + 'symfony', + ), 'manticoresearch' => static fn (): ManticoreSearchStore => new ManticoreSearchStore( http_client(), env('MANTICORESEARCH_HOST'), diff --git a/examples/compose.yaml b/examples/compose.yaml index 4a1a17fa6..1423519d0 100644 --- a/examples/compose.yaml +++ b/examples/compose.yaml @@ -124,6 +124,28 @@ services: - '7474:7474' - '7687:7687' + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:9.2.2 + environment: + discovery.type: 'single-node' + xpack.security.enabled: false + ES_JAVA_OPTS: '-Xms512m -Xmx512m' + ulimits: + memlock: + soft: -1 + hard: -1 + nofile: + soft: 65536 + hard: 65536 + healthcheck: + test: [ 'CMD', 'curl', '-f', 'http://127.0.0.1:9200/_cluster/health' ] + interval: 30s + start_period: 120s + timeout: 20s + retries: 3 + ports: + - '9201:9200' + opensearch: image: opensearchproject/opensearch environment: diff --git a/examples/composer.json b/examples/composer.json index 55d75be91..6d20fbb40 100644 --- a/examples/composer.json +++ b/examples/composer.json @@ -36,6 +36,7 @@ "symfony/ai-click-house-store": "@dev", "symfony/ai-clock-tool": "@dev", "symfony/ai-cloudflare-store": "@dev", + "symfony/ai-elasticsearch-store": "@dev", "symfony/ai-manticore-search-store": "@dev", "symfony/ai-maria-db-store": "@dev", "symfony/ai-meilisearch-store": "@dev", diff --git a/examples/rag/elasticsearch.php b/examples/rag/elasticsearch.php new file mode 100644 index 000000000..a23c076f9 --- /dev/null +++ b/examples/rag/elasticsearch.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Agent\Agent; +use Symfony\AI\Agent\Bridge\SimilaritySearch\SimilaritySearch; +use Symfony\AI\Agent\Toolbox\AgentProcessor; +use Symfony\AI\Agent\Toolbox\Toolbox; +use Symfony\AI\Fixtures\Movies; +use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Store\Bridge\Elasticsearch\Store; +use Symfony\AI\Store\Document\Loader\InMemoryLoader; +use Symfony\AI\Store\Document\Metadata; +use Symfony\AI\Store\Document\TextDocument; +use Symfony\AI\Store\Document\Vectorizer; +use Symfony\AI\Store\Indexer; +use Symfony\Component\Uid\Uuid; + +require_once dirname(__DIR__).'/bootstrap.php'; + +// initialize the store +$store = new Store( + httpClient: http_client(), + endpoint: env('ELASTICSEARCH_ENDPOINT'), + indexName: 'movies', +); + +// create embeddings and documents +$documents = []; +foreach (Movies::all() as $i => $movie) { + $documents[] = new TextDocument( + id: Uuid::v4(), + content: 'Title: '.$movie['title'].\PHP_EOL.'Director: '.$movie['director'].\PHP_EOL.'Description: '.$movie['description'], + metadata: new Metadata($movie), + ); +} + +// initialize the index +$store->setup(); + +// create embeddings for documents +$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client()); +$vectorizer = new Vectorizer($platform, 'text-embedding-3-small', logger()); +$indexer = new Indexer(new InMemoryLoader($documents), $vectorizer, $store, logger: logger()); +$indexer->index($documents); + +$similaritySearch = new SimilaritySearch($vectorizer, $store); +$toolbox = new Toolbox([$similaritySearch], logger: logger()); +$processor = new AgentProcessor($toolbox); +$agent = new Agent($platform, 'gpt-4o-mini', [$processor], [$processor]); + +$messages = new MessageBag( + Message::forSystem('Please answer all user questions only using SimilaritySearch function.'), + Message::ofUser('Which movie fits the theme of technology?') +); +$result = $agent->call($messages); + +echo $result->getContent().\PHP_EOL; diff --git a/splitsh.json b/splitsh.json index 485d84dad..1cdc98665 100644 --- a/splitsh.json +++ b/splitsh.json @@ -27,6 +27,7 @@ "ai-click-house-store": "src/store/src/Bridge/ClickHouse", "ai-cloudflare-store": "src/store/src/Bridge/Cloudflare", "ai-chroma-db-store": "src/store/src/Bridge/ChromaDb", + "ai-elasticsearch-store": "src/store/src/Bridge/Elasticsearch", "ai-manticore-search-store": "src/store/src/Bridge/ManticoreSearch", "ai-maria-db-store": "src/store/src/Bridge/MariaDb", "ai-meilisearch-store": "src/store/src/Bridge/Meilisearch", diff --git a/src/ai-bundle/config/options.php b/src/ai-bundle/config/options.php index 2510a963a..5547307d6 100644 --- a/src/ai-bundle/config/options.php +++ b/src/ai-bundle/config/options.php @@ -755,6 +755,27 @@ ->end() ->end() ->end() + ->arrayNode('elasticsearch') + ->useAttributeAsKey('name') + ->arrayPrototype() + ->children() + ->stringNode('endpoint')->cannotBeEmpty()->end() + ->stringNode('index_name')->end() + ->stringNode('vectors_field') + ->defaultValue('_vectors') + ->end() + ->integerNode('dimensions') + ->defaultValue(1536) + ->end() + ->stringNode('similarity') + ->defaultValue('cosine') + ->end() + ->stringNode('http_client') + ->defaultValue('http_client') + ->end() + ->end() + ->end() + ->end() ->arrayNode('opensearch') ->useAttributeAsKey('name') ->arrayPrototype() diff --git a/src/ai-bundle/src/AiBundle.php b/src/ai-bundle/src/AiBundle.php index f7d879523..8fbf869f3 100644 --- a/src/ai-bundle/src/AiBundle.php +++ b/src/ai-bundle/src/AiBundle.php @@ -87,6 +87,7 @@ use Symfony\AI\Store\Bridge\ChromaDb\Store as ChromaDbStore; use Symfony\AI\Store\Bridge\ClickHouse\Store as ClickHouseStore; use Symfony\AI\Store\Bridge\Cloudflare\Store as CloudflareStore; +use Symfony\AI\Store\Bridge\Elasticsearch\Store as ElasticsearchStore; use Symfony\AI\Store\Bridge\ManticoreSearch\Store as ManticoreSearchStore; use Symfony\AI\Store\Bridge\MariaDb\Store as MariaDbStore; use Symfony\AI\Store\Bridge\Meilisearch\Store as MeilisearchStore; @@ -1396,6 +1397,33 @@ private function processStoreConfig(string $type, array $stores, ContainerBuilde } } + if ('elasticsearch' === $type) { + if (!ContainerBuilder::willBeAvailable('symfony/ai-elasticsearch-store', ElasticsearchStore::class, ['symfony/ai-bundle'])) { + throw new RuntimeException('Elasticsearch store configuration requires "symfony/ai-elasticsearch-store" package. Try running "composer require symfony/ai-elasticsearch-store".'); + } + + foreach ($stores as $name => $store) { + $definition = new Definition(ElasticsearchStore::class); + $definition + ->setLazy(true) + ->setArguments([ + new Reference($store['http_client']), + $store['endpoint'], + $store['index_name'] ?? $name, + $store['vectors_field'], + $store['dimensions'], + $store['similarity'], + ]) + ->addTag('proxy', ['interface' => StoreInterface::class]) + ->addTag('proxy', ['interface' => ManagedStoreInterface::class]) + ->addTag('ai.store'); + + $container->setDefinition('ai.store.'.$type.'.'.$name, $definition); + $container->registerAliasForArgument('ai.store.'.$type.'.'.$name, StoreInterface::class, $name); + $container->registerAliasForArgument('ai.store.'.$type.'.'.$name, StoreInterface::class, $type.'_'.$name); + } + } + if ('opensearch' === $type) { if (!ContainerBuilder::willBeAvailable('symfony/ai-open-search-store', OpenSearchStore::class, ['symfony/ai-bundle'])) { throw new RuntimeException('OpenSearch store configuration requires "symfony/ai-open-search-store" package. Try running "composer require symfony/ai-open-search-store".'); diff --git a/src/store/CHANGELOG.md b/src/store/CHANGELOG.md index 746eb5efe..fa54f16f1 100644 --- a/src/store/CHANGELOG.md +++ b/src/store/CHANGELOG.md @@ -38,6 +38,7 @@ CHANGELOG - ChromaDB - ClickHouse - Cloudflare + - Elasticsearch - Manticore Search - MariaDB - Meilisearch diff --git a/src/store/composer.json b/src/store/composer.json index 32873a3f7..68307ba63 100644 --- a/src/store/composer.json +++ b/src/store/composer.json @@ -9,6 +9,7 @@ "chromadb", "clickhouse", "cloudflare", + "elasticsearch", "mariadb", "meilisearch", "milvus", diff --git a/src/store/src/Bridge/Elasticsearch/.gitattributes b/src/store/src/Bridge/Elasticsearch/.gitattributes new file mode 100644 index 000000000..14c3c3594 --- /dev/null +++ b/src/store/src/Bridge/Elasticsearch/.gitattributes @@ -0,0 +1,3 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.git* export-ignore diff --git a/src/store/src/Bridge/Elasticsearch/.gitignore b/src/store/src/Bridge/Elasticsearch/.gitignore new file mode 100644 index 000000000..76367ee5b --- /dev/null +++ b/src/store/src/Bridge/Elasticsearch/.gitignore @@ -0,0 +1,4 @@ +vendor/ +composer.lock +phpunit.xml +.phpunit.result.cache diff --git a/src/store/src/Bridge/Elasticsearch/LICENSE b/src/store/src/Bridge/Elasticsearch/LICENSE new file mode 100644 index 000000000..bc38d714e --- /dev/null +++ b/src/store/src/Bridge/Elasticsearch/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2025-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/store/src/Bridge/Elasticsearch/Store.php b/src/store/src/Bridge/Elasticsearch/Store.php new file mode 100644 index 000000000..a5798d6f2 --- /dev/null +++ b/src/store/src/Bridge/Elasticsearch/Store.php @@ -0,0 +1,152 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Store\Bridge\Elasticsearch; + +use Symfony\AI\Platform\Vector\NullVector; +use Symfony\AI\Platform\Vector\Vector; +use Symfony\AI\Store\Document\Metadata; +use Symfony\AI\Store\Document\VectorDocument; +use Symfony\AI\Store\Exception\InvalidArgumentException; +use Symfony\AI\Store\ManagedStoreInterface; +use Symfony\AI\Store\StoreInterface; +use Symfony\Component\Uid\Uuid; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +final class Store implements ManagedStoreInterface, StoreInterface +{ + public function __construct( + private readonly HttpClientInterface $httpClient, + private readonly string $endpoint, + private readonly string $indexName, + private readonly string $vectorsField = '_vectors', + private readonly int $dimensions = 1536, + private readonly string $similarity = 'cosine', + ) { + } + + public function setup(array $options = []): void + { + $indexExistResponse = $this->httpClient->request('HEAD', \sprintf('%s/%s', $this->endpoint, $this->indexName)); + + if (200 === $indexExistResponse->getStatusCode()) { + return; + } + + $this->request('PUT', $this->indexName, [ + 'mappings' => [ + 'properties' => [ + $this->vectorsField => [ + 'type' => 'dense_vector', + 'dims' => $options['dimensions'] ?? $this->dimensions, + 'similarity' => $options['similarity'] ?? $this->similarity, + ], + ], + ], + ]); + } + + public function drop(): void + { + $indexExistResponse = $this->httpClient->request('HEAD', \sprintf('%s/%s', $this->endpoint, $this->indexName)); + + if (404 === $indexExistResponse->getStatusCode()) { + throw new InvalidArgumentException(\sprintf('The index "%s" does not exist.', $this->indexName)); + } + + $this->request('DELETE', $this->indexName); + } + + public function add(VectorDocument ...$documents): void + { + $documentToIndex = fn (VectorDocument $document): array => [ + 'index' => [ + '_index' => $this->indexName, + '_id' => $document->id->toRfc4122(), + ], + ]; + + $documentToPayload = fn (VectorDocument $document): array => [ + $this->vectorsField => $document->vector->getData(), + 'metadata' => json_encode($document->metadata->getArrayCopy()), + ]; + + $this->request('POST', '_bulk', function () use ($documents, $documentToIndex, $documentToPayload) { + foreach ($documents as $document) { + yield json_encode($documentToIndex($document)).\PHP_EOL.json_encode($documentToPayload($document)).\PHP_EOL; + } + }); + } + + public function query(Vector $vector, array $options = []): iterable + { + $k = $options['k'] ?? 100; + $numCandidates = $options['num_candidates'] ?? max($k * 2, 100); + + $documents = $this->request('POST', \sprintf('%s/_search', $this->indexName), [ + 'knn' => [ + 'field' => $this->vectorsField, + 'query_vector' => $vector->getData(), + 'k' => $k, + 'num_candidates' => $numCandidates, + ], + ]); + + foreach ($documents['hits']['hits'] as $document) { + yield $this->convertToVectorDocument($document); + } + } + + /** + * @param \Closure|array $payload + * + * @return array + */ + private function request(string $method, string $path, \Closure|array $payload = []): array + { + $finalOptions = []; + + if (\is_array($payload) && [] !== $payload) { + $finalOptions['json'] = $payload; + } + + if ($payload instanceof \Closure) { + $finalOptions = [ + 'headers' => [ + 'Content-Type' => 'application/x-ndjson', + ], + 'body' => $payload(), + ]; + } + + $response = $this->httpClient->request($method, \sprintf('%s/%s', $this->endpoint, $path), $finalOptions); + + return $response->toArray(); + } + + /** + * @param array{ + * '_id'?: string, + * '_source': array, + * '_score': float, + * } $document + */ + private function convertToVectorDocument(array $document): VectorDocument + { + $id = $document['_id'] ?? throw new InvalidArgumentException('Missing "_id" field in the document data.'); + + $vector = !\array_key_exists($this->vectorsField, $document['_source']) || null === $document['_source'][$this->vectorsField] + ? new NullVector() + : new Vector($document['_source'][$this->vectorsField]); + + return new VectorDocument(Uuid::fromString($id), $vector, new Metadata(json_decode($document['_source']['metadata'], true)), $document['_score'] ?? null); + } +} diff --git a/src/store/src/Bridge/Elasticsearch/Tests/StoreTest.php b/src/store/src/Bridge/Elasticsearch/Tests/StoreTest.php new file mode 100644 index 000000000..91d262b7e --- /dev/null +++ b/src/store/src/Bridge/Elasticsearch/Tests/StoreTest.php @@ -0,0 +1,196 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Store\Bridge\Elasticsearch\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Vector\Vector; +use Symfony\AI\Store\Bridge\Elasticsearch\Store; +use Symfony\AI\Store\Document\VectorDocument; +use Symfony\AI\Store\Exception\InvalidArgumentException; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\JsonMockResponse; +use Symfony\Component\Uid\Uuid; + +final class StoreTest extends TestCase +{ + public function testStoreCannotSetupOnExistingIndex() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse('', [ + 'http_code' => 200, + ]), + ]); + + $store = new Store($httpClient, 'http://127.0.0.1:9200', 'foo'); + $store->setup(); + + $this->assertSame(1, $httpClient->getRequestsCount()); + } + + public function testStoreCanSetup() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse('', [ + 'http_code' => 404, + ]), + new JsonMockResponse([ + 'acknowledged' => true, + 'shards_acknowledged' => true, + 'index' => 'foo', + ], [ + 'http_code' => 200, + ]), + ]); + + $store = new Store($httpClient, 'http://127.0.0.1:9200', 'foo'); + $store->setup(); + + $this->assertSame(2, $httpClient->getRequestsCount()); + } + + public function testStoreCanSetupWithExtraOptions() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse('', [ + 'http_code' => 400, + ]), + new JsonMockResponse([ + 'acknowledged' => true, + 'shards_acknowledged' => true, + 'index' => 'foo', + ], [ + 'http_code' => 200, + ]), + ]); + + $store = new Store($httpClient, 'http://127.0.0.1:9200', 'foo'); + + $store->setup([ + 'dimensions' => 768, + 'similarity' => 'dot_product', + ]); + + $this->assertSame(2, $httpClient->getRequestsCount()); + } + + public function testStoreCannotDropOnUndefinedIndex() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse('', [ + 'http_code' => 404, + ]), + ]); + + $store = new Store($httpClient, 'http://127.0.0.1:9200', 'foo'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The index "foo" does not exist.'); + $this->expectExceptionCode(0); + $store->drop(); + } + + public function testStoreCanDrop() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse('', [ + 'http_code' => 200, + ]), + new JsonMockResponse([ + 'acknowledged' => true, + ], [ + 'http_code' => 200, + ]), + ]); + + $store = new Store($httpClient, 'http://127.0.0.1:9200', 'foo'); + $store->drop(); + + $this->assertSame(2, $httpClient->getRequestsCount()); + } + + public function testStoreCanSave() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse([ + 'took' => 100, + 'errors' => false, + 'items' => [ + [ + 'index' => [ + '_index' => 'foo', + '_id' => Uuid::v7()->toRfc4122(), + '_version' => 1, + 'result' => 'created', + '_shards' => [], + 'status' => 201, + ], + ], + ], + ], [ + 'http_code' => 200, + ]), + ]); + + $store = new Store($httpClient, 'http://127.0.0.1:9200', 'foo'); + $store->add(new VectorDocument(Uuid::v7(), new Vector([0.1, 0.2, 0.3]))); + + $this->assertSame(1, $httpClient->getRequestsCount()); + } + + public function testStoreCanQuery() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse([ + 'took' => 100, + 'errors' => false, + 'hits' => [ + 'total' => [ + 'value' => 1, + 'relation' => 'eq', + ], + 'hits' => [ + [ + '_index' => 'foo', + '_id' => Uuid::v7()->toRfc4122(), + '_score' => 1.4363918, + '_source' => [ + '_vectors' => [0.1, 0.2, 0.3], + 'metadata' => json_encode([ + 'foo' => 'bar', + ]), + ], + ], + [ + '_index' => 'foo', + '_id' => Uuid::v7()->toRfc4122(), + '_score' => 1.3363918, + '_source' => [ + '_vectors' => [0.1, 0.4, 0.3], + 'metadata' => json_encode([ + 'foo' => 'bar', + ]), + ], + ], + ], + ], + ], [ + 'http_code' => 200, + ]), + ]); + + $store = new Store($httpClient, 'http://127.0.0.1:9200', 'foo'); + $results = $store->query(new Vector([0.1, 0.2, 0.3])); + + $this->assertCount(2, iterator_to_array($results)); + $this->assertSame(1, $httpClient->getRequestsCount()); + } +} diff --git a/src/store/src/Bridge/Elasticsearch/composer.json b/src/store/src/Bridge/Elasticsearch/composer.json new file mode 100644 index 000000000..c2aeb73bb --- /dev/null +++ b/src/store/src/Bridge/Elasticsearch/composer.json @@ -0,0 +1,56 @@ +{ + "name": "symfony/ai-elasticsearch-store", + "description": "Elasticsearch vector store bridge for Symfony AI", + "license": "MIT", + "type": "symfony-ai-store", + "keywords": [ + "ai", + "bridge", + "elasticsearch", + "store", + "vector" + ], + "authors": [ + { + "name": "Stiven Llupa", + "email": "stivenllupa@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=8.2", + "symfony/ai-platform": "@dev", + "symfony/ai-store": "@dev", + "symfony/http-client": "^7.3|^8.0", + "symfony/uid": "^7.3|^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.13" + }, + "minimum-stability": "dev", + "autoload": { + "psr-4": { + "Symfony\\AI\\Store\\Bridge\\Elasticsearch\\": "" + } + }, + "autoload-dev": { + "psr-4": { + "Symfony\\AI\\Store\\Bridge\\Elasticsearch\\Tests\\": "Tests/" + } + }, + "config": { + "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-main": "0.x-dev" + }, + "thanks": { + "name": "symfony/ai", + "url": "https://github.com/symfony/ai" + } + } +} diff --git a/src/store/src/Bridge/Elasticsearch/phpunit.xml.dist b/src/store/src/Bridge/Elasticsearch/phpunit.xml.dist new file mode 100644 index 000000000..3058463bb --- /dev/null +++ b/src/store/src/Bridge/Elasticsearch/phpunit.xml.dist @@ -0,0 +1,32 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + + ./Resources + ./Tests + ./vendor + + +