diff --git a/examples/.env b/examples/.env index 57c60d0bef..4e021ebc2e 100644 --- a/examples/.env +++ b/examples/.env @@ -38,11 +38,14 @@ AZURE_OPENAI_WHISPER_API_VERSION= AZURE_LLAMA_BASEURL= AZURE_LLAMA_KEY= -# For using Bedrock +# For using Bedrock and S3 Vectors AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_DEFAULT_REGION= +# For S3 Vectors (store) +S3_VECTORS_BUCKET= + # Hugging Face Access Token HUGGINGFACE_KEY= diff --git a/examples/commands/stores.php b/examples/commands/stores.php index ee5986feff..0e510cb235 100644 --- a/examples/commands/stores.php +++ b/examples/commands/stores.php @@ -11,6 +11,7 @@ require_once dirname(__DIR__).'/bootstrap.php'; +use AsyncAws\S3Vectors\S3VectorsClient; use Doctrine\DBAL\DriverManager; use Doctrine\DBAL\Tools\DsnParser; use MongoDB\Client as MongoDbClient; @@ -29,6 +30,7 @@ use Symfony\AI\Store\Bridge\Postgres\Store as PostgresStore; use Symfony\AI\Store\Bridge\Qdrant\Store as QdrantStore; use Symfony\AI\Store\Bridge\Redis\Store as RedisStore; +use Symfony\AI\Store\Bridge\S3Vectors\Store as S3VectorsStore; use Symfony\AI\Store\Bridge\SurrealDb\Store as SurrealDbStore; use Symfony\AI\Store\Bridge\Typesense\Store as TypesenseStore; use Symfony\AI\Store\Bridge\Weaviate\Store as WeaviateStore; @@ -119,6 +121,15 @@ 'host' => env('REDIS_HOST'), 'port' => 6379, ]), 'symfony'), + // 's3vectors' => static fn (): S3VectorsStore => new S3VectorsStore( + // new S3VectorsClient([ + // 'region' => env('AWS_DEFAULT_REGION'), + // 'accessKeyId' => env('AWS_ACCESS_KEY_ID'), + // 'accessKeySecret' => env('AWS_SECRET_ACCESS_KEY'), + // ]), + // env('S3_VECTORS_BUCKET'), + // 'symfony', + // ), 'surrealdb' => static fn (): SurrealDbStore => new SurrealDbStore( httpClient: http_client(), endpointUrl: env('SURREALDB_HOST'), diff --git a/examples/composer.json b/examples/composer.json index 35e11a52a8..65293bef8e 100644 --- a/examples/composer.json +++ b/examples/composer.json @@ -89,6 +89,7 @@ "symfony/ai-vertex-ai-platform": "^0.3", "symfony/ai-voyage-platform": "^0.3", "symfony/ai-weaviate-store": "^0.3", + "symfony/ai-s3vectors-store": "^0.4", "symfony/ai-wikipedia-tool": "^0.3", "symfony/ai-youtube-tool": "^0.3", "symfony/console": "^7.4|^8.0", diff --git a/splitsh.json b/splitsh.json index 03d90909e3..aa48eecad4 100644 --- a/splitsh.json +++ b/splitsh.json @@ -91,6 +91,7 @@ "ai-supabase-store": "src/store/src/Bridge/Supabase", "ai-surreal-db-store": "src/store/src/Bridge/SurrealDb", "ai-typesense-store": "src/store/src/Bridge/Typesense", - "ai-weaviate-store": "src/store/src/Bridge/Weaviate" + "ai-weaviate-store": "src/store/src/Bridge/Weaviate", + "ai-s3vectors-store": "src/store/src/Bridge/S3Vectors" } } diff --git a/src/ai-bundle/composer.json b/src/ai-bundle/composer.json index 3988b23abf..b5d2c2964e 100644 --- a/src/ai-bundle/composer.json +++ b/src/ai-bundle/composer.json @@ -94,6 +94,7 @@ "symfony/ai-vertex-ai-platform": "^0.3", "symfony/ai-voyage-platform": "^0.3", "symfony/ai-weaviate-store": "^0.3", + "symfony/ai-s3vectors-store": "^0.4", "symfony/expression-language": "^7.3|^8.0", "symfony/security-core": "^7.3|^8.0", "symfony/translation": "^7.3|^8.0" diff --git a/src/ai-bundle/config/options.php b/src/ai-bundle/config/options.php index 11d6bb20b1..923e1caa5b 100644 --- a/src/ai-bundle/config/options.php +++ b/src/ai-bundle/config/options.php @@ -359,6 +359,7 @@ ->append($import('store/postgres')) ->append($import('store/qdrant')) ->append($import('store/redis')) + ->append($import('store/s3vectors')) ->append($import('store/supabase')) ->append($import('store/surrealdb')) ->append($import('store/typesense')) diff --git a/src/ai-bundle/config/store/s3vectors.php b/src/ai-bundle/config/store/s3vectors.php new file mode 100644 index 0000000000..a155a63b27 --- /dev/null +++ b/src/ai-bundle/config/store/s3vectors.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Config\Definition\Configurator; + +use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; + +return (new ArrayNodeDefinition('s3vectors')) + ->useAttributeAsKey('name') + ->arrayPrototype() + ->children() + ->stringNode('client') + ->info('Service reference to an existing S3VectorsClient') + ->end() + ->arrayNode('configuration') + ->info('AsyncAws S3Vectors client configuration (used if client service is not provided)') + ->end() + ->stringNode('vector_bucket_name') + ->isRequired() + ->cannotBeEmpty() + ->end() + ->stringNode('index_name')->end() + ->arrayNode('filter') + ->info('Default filter for queries') + ->end() + ->integerNode('top_k') + ->info('Default number of results to return') + ->defaultValue(3) + ->end() + ->end() + ->end(); diff --git a/src/ai-bundle/src/AiBundle.php b/src/ai-bundle/src/AiBundle.php index 325a614a29..6553efc3dc 100644 --- a/src/ai-bundle/src/AiBundle.php +++ b/src/ai-bundle/src/AiBundle.php @@ -106,6 +106,7 @@ use Symfony\AI\Store\Bridge\Qdrant\Store as QdrantStore; use Symfony\AI\Store\Bridge\Redis\Distance as RedisDistance; use Symfony\AI\Store\Bridge\Redis\Store as RedisStore; +use Symfony\AI\Store\Bridge\S3Vectors\Store as S3VectorsStore; use Symfony\AI\Store\Bridge\Supabase\Store as SupabaseStore; use Symfony\AI\Store\Bridge\SurrealDb\Store as SurrealDbStore; use Symfony\AI\Store\Bridge\Typesense\Store as TypesenseStore; @@ -1939,6 +1940,47 @@ private function processStoreConfig(string $type, array $stores, ContainerBuilde $container->registerAliasForArgument('ai.store.'.$type.'.'.$name, StoreInterface::class, $type.'_'.$name); } } + + if ('s3vectors' === $type) { + if (!ContainerBuilder::willBeAvailable('symfony/ai-s3vectors-store', S3VectorsStore::class, ['symfony/ai-bundle'])) { + throw new RuntimeException('S3Vectors store configuration requires "symfony/ai-s3vectors-store" package. Try running "composer require symfony/ai-s3vectors-store".'); + } + + foreach ($stores as $name => $store) { + if (isset($store['client'])) { + $s3VectorsClient = new Reference($store['client']); + } else { + $s3VectorsClient = new Definition(\AsyncAws\S3Vectors\S3VectorsClient::class); + $s3VectorsClient->setArguments([$store['configuration'] ?? []]); + } + + $arguments = [ + $s3VectorsClient, + $store['vector_bucket_name'], + $store['index_name'] ?? $name, + ]; + + if (\array_key_exists('filter', $store)) { + $arguments[3] = $store['filter']; + } + + if (\array_key_exists('top_k', $store)) { + $arguments[4] = $store['top_k']; + } + + $definition = new Definition(S3VectorsStore::class); + $definition + ->setLazy(true) + ->setArguments($arguments) + ->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); + } + } } /** diff --git a/src/store/src/Bridge/S3Vectors/.gitattributes b/src/store/src/Bridge/S3Vectors/.gitattributes new file mode 100644 index 0000000000..50f63fab1d --- /dev/null +++ b/src/store/src/Bridge/S3Vectors/.gitattributes @@ -0,0 +1,6 @@ +/.github export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpstan.dist.neon export-ignore +/phpunit.xml.dist export-ignore +/Tests export-ignore diff --git a/src/store/src/Bridge/S3Vectors/.github/PULL_REQUEST_TEMPLATE.md b/src/store/src/Bridge/S3Vectors/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..fcb87228ae --- /dev/null +++ b/src/store/src/Bridge/S3Vectors/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +Please do not submit any Pull Requests here. They will be closed. +--- + +Please submit your PR here instead: +https://github.com/symfony/ai + +This repository is what we call a "subtree split": a read-only subset of that main repository. +We're looking forward to your PR there! diff --git a/src/store/src/Bridge/S3Vectors/.github/workflows/close-pull-request.yml b/src/store/src/Bridge/S3Vectors/.github/workflows/close-pull-request.yml new file mode 100644 index 0000000000..bb5a02835b --- /dev/null +++ b/src/store/src/Bridge/S3Vectors/.github/workflows/close-pull-request.yml @@ -0,0 +1,20 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/symfony/ai + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! diff --git a/src/store/src/Bridge/S3Vectors/.gitignore b/src/store/src/Bridge/S3Vectors/.gitignore new file mode 100644 index 0000000000..1d9d0f0845 --- /dev/null +++ b/src/store/src/Bridge/S3Vectors/.gitignore @@ -0,0 +1,5 @@ +/composer.lock +/phpunit.xml +/.phpunit.result.cache +/phpstan.neon +/vendor/ diff --git a/src/store/src/Bridge/S3Vectors/CHANGELOG.md b/src/store/src/Bridge/S3Vectors/CHANGELOG.md new file mode 100644 index 0000000000..ada19eba5d --- /dev/null +++ b/src/store/src/Bridge/S3Vectors/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +0.4 +--- + + * Add the bridge diff --git a/src/store/src/Bridge/S3Vectors/LICENSE b/src/store/src/Bridge/S3Vectors/LICENSE new file mode 100644 index 0000000000..36d6fdc386 --- /dev/null +++ b/src/store/src/Bridge/S3Vectors/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2026-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/S3Vectors/README.md b/src/store/src/Bridge/S3Vectors/README.md new file mode 100644 index 0000000000..31d17ec940 --- /dev/null +++ b/src/store/src/Bridge/S3Vectors/README.md @@ -0,0 +1,91 @@ +# AWS S3 Vectors Store Bridge for Symfony AI + +This bridge provides integration between Symfony AI Store and AWS S3 Vectors for vector storage and similarity search. + +## Installation + +```bash +composer require symfony/ai-s3vectors-store +``` + +## Configuration + +```php +use AsyncAws\S3Vectors\S3VectorsClient; +use Symfony\AI\Store\Bridge\S3Vectors\Store; + +$client = new S3VectorsClient([ + 'region' => 'us-east-1', +]); + +$store = new Store( + client: $client, + vectorBucketName: 'my-vector-bucket', + indexName: 'my-index', + filter: [], // Optional: default filter for queries + topK: 3, // Optional: default number of results +); + +// Setup the vector bucket and index +$store->setup([ + 'dimension' => 1536, + 'distanceMetric' => \AsyncAws\S3Vectors\Enum\DistanceMetric::COSINE, // Optional + 'dataType' => \AsyncAws\S3Vectors\Enum\DataType::FLOAT32, // Optional + 'encryption' => ['kmsKeyId' => 'your-kms-key-id'], // Optional + 'tags' => ['env' => 'production'], // Optional +]); +``` + +## Usage + +```php +use Symfony\AI\Platform\Vector\Vector; +use Symfony\AI\Store\Document\Metadata; +use Symfony\AI\Store\Document\VectorDocument; +use Symfony\Component\Uid\Uuid; + +// Add documents +$document = new VectorDocument( + id: Uuid::v4(), + vector: new Vector([0.1, 0.2, 0.3, ...]), + metadata: new Metadata(['title' => 'My Document']) +); +$store->add($document); + +// Query similar vectors +$results = $store->query( + vector: new Vector([0.1, 0.2, 0.3, ...]), + options: [ + 'topK' => 5, + 'filter' => ['category' => 'documentation'], + ] +); + +foreach ($results as $result) { + echo $result->metadata['title'] . ' (score: ' . $result->score . ')' . PHP_EOL; +} + +// Remove documents +$store->remove(['id1', 'id2']); + +// Drop the index and bucket +$store->drop(); +``` + +## Features + +- Full CRUD operations for vector documents +- Similarity search with configurable distance metrics (cosine, euclidean) +- Metadata filtering support +- KMS encryption support +- Tag management +- Batch operations + +## Resources + - [Contributing](https://symfony.com/doc/current/contributing/index.html) + - [Report issues](https://github.com/symfony/ai/issues) and + [send Pull Requests](https://github.com/symfony/ai/pulls) + in the [main Symfony AI repository](https://github.com/symfony/ai) +- [AWS S3 Vectors Documentation](https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-vectors.html) +- [AsyncAws S3Vectors Package](https://github.com/async-aws/aws/tree/master/src/Service/S3Vectors) +- [Symfony AI Documentation](https://github.com/symfony/ai) diff --git a/src/store/src/Bridge/S3Vectors/Store.php b/src/store/src/Bridge/S3Vectors/Store.php new file mode 100644 index 0000000000..f12b84321d --- /dev/null +++ b/src/store/src/Bridge/S3Vectors/Store.php @@ -0,0 +1,218 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Store\Bridge\S3Vectors; + +use AsyncAws\S3Vectors\Enum\DataType; +use AsyncAws\S3Vectors\Enum\DistanceMetric; +use AsyncAws\S3Vectors\S3VectorsClient; +use AsyncAws\S3Vectors\ValueObject\PutInputVector; +use AsyncAws\S3Vectors\ValueObject\VectorDataMemberFloat32; +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; + +/** + * AWS S3 Vectors store implementation using AsyncAws. + * + * @author AUH Nahvi + */ +final class Store implements ManagedStoreInterface, StoreInterface +{ + /** + * @param array $filter + */ + public function __construct( + private readonly S3VectorsClient $client, + private readonly string $vectorBucketName, + private readonly string $indexName, + private readonly array $filter = [], + private readonly int $topK = 3, + ) { + } + + /** + * @param array{ + * dimension?: int, + * distanceMetric?: DistanceMetric::*, + * dataType?: DataType::*, + * metadata?: array>, + * encryption?: array{kmsKeyId?: string}, + * tags?: array, + * } $options + */ + public function setup(array $options = []): void + { + if (!isset($options['dimension'])) { + throw new InvalidArgumentException('The "dimension" option is required.'); + } + + // Create vector bucket if it doesn't exist + try { + $this->client->getVectorBucket([ + 'vectorBucketName' => $this->vectorBucketName, + ]); + } catch (\Exception) { + $bucketInput = [ + 'vectorBucketName' => $this->vectorBucketName, + ]; + + if (isset($options['encryption']['kmsKeyId'])) { + $bucketInput['encryptionConfiguration'] = [ + 'kmsKeyId' => $options['encryption']['kmsKeyId'], + ]; + } + + if (isset($options['tags'])) { + $bucketInput['tags'] = $options['tags']; + } + + $this->client->createVectorBucket($bucketInput); + } + + // Create index + $indexInput = [ + 'vectorBucketName' => $this->vectorBucketName, + 'indexName' => $this->indexName, + 'dimension' => $options['dimension'], + 'distanceMetric' => $options['distanceMetric'] ?? DistanceMetric::COSINE, + 'dataType' => $options['dataType'] ?? DataType::FLOAT_32, + ]; + + if (isset($options['metadata'])) { + $indexInput['metadataConfiguration'] = $options['metadata']; + } + + if (isset($options['encryption']['kmsKeyId'])) { + $indexInput['encryptionConfiguration'] = [ + 'kmsKeyId' => $options['encryption']['kmsKeyId'], + ]; + } + + if (isset($options['tags'])) { + $indexInput['tags'] = $options['tags']; + } + + $this->client->createIndex($indexInput); + } + + public function add(VectorDocument|array $documents): void + { + if ($documents instanceof VectorDocument) { + $documents = [$documents]; + } + + if ([] === $documents) { + return; + } + + $vectors = []; + foreach ($documents as $document) { + $vector = [ + 'key' => (string) $document->id, + 'data' => new VectorDataMemberFloat32(['float32' => $document->vector->getData()]), + ]; + + if ([] !== $document->metadata->getArrayCopy()) { + $vector['metadata'] = $document->metadata->getArrayCopy(); + } + + $vectors[] = PutInputVector::create($vector); + } + + $this->client->putVectors([ + 'vectorBucketName' => $this->vectorBucketName, + 'indexName' => $this->indexName, + 'vectors' => $vectors, + ]); + } + + /** + * @param string|array $ids + * @param array{ + * filter?: array, + * } $options + */ + public function remove(string|array $ids, array $options = []): void + { + if (\is_string($ids)) { + $ids = [$ids]; + } + + if ([] === $ids) { + return; + } + + $deleteInput = [ + 'vectorBucketName' => $this->vectorBucketName, + 'indexName' => $this->indexName, + 'keys' => $ids, + ]; + + if (isset($options['filter'])) { + $deleteInput['filter'] = $options['filter']; + } + + $this->client->deleteVectors($deleteInput); + } + + /** + * @param array{ + * filter?: array, + * topK?: int, + * returnMetadata?: bool, + * returnDistance?: bool, + * } $options + */ + public function query(Vector $vector, array $options = []): iterable + { + $result = $this->client->queryVectors([ + 'vectorBucketName' => $this->vectorBucketName, + 'indexName' => $this->indexName, + 'queryVector' => new VectorDataMemberFloat32(['float32' => $vector->getData()]), + 'topK' => $options['topK'] ?? $this->topK, + 'filter' => $options['filter'] ?? $this->filter, + 'returnMetadata' => $options['returnMetadata'] ?? true, + 'returnDistance' => $options['returnDistance'] ?? true, + ]); + + foreach ($result->getVectors() as $outputVector) { + $metadata = $outputVector->getMetadata(); + + /** @var array $metadataArray */ + $metadataArray = \is_array($metadata) ? $metadata : []; + + yield new VectorDocument( + id: $outputVector->getKey(), + vector: $vector, + metadata: new Metadata($metadataArray), + score: $outputVector->getDistance(), + ); + } + } + + public function drop(array $options = []): void + { + // Delete index first + $this->client->deleteIndex([ + 'vectorBucketName' => $this->vectorBucketName, + 'indexName' => $this->indexName, + ]); + + // Then delete the bucket + $this->client->deleteVectorBucket([ + 'vectorBucketName' => $this->vectorBucketName, + ]); + } +} diff --git a/src/store/src/Bridge/S3Vectors/Tests/StoreTest.php b/src/store/src/Bridge/S3Vectors/Tests/StoreTest.php new file mode 100644 index 0000000000..23726d8e26 --- /dev/null +++ b/src/store/src/Bridge/S3Vectors/Tests/StoreTest.php @@ -0,0 +1,343 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Store\Bridge\S3Vectors\Tests; + +use AsyncAws\Core\Test\ResultMockFactory; +use AsyncAws\S3Vectors\Enum\DataType; +use AsyncAws\S3Vectors\Enum\DistanceMetric; +use AsyncAws\S3Vectors\Result\QueryVectorsOutput; +use AsyncAws\S3Vectors\S3VectorsClient; +use AsyncAws\S3Vectors\ValueObject\QueryOutputVector; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Vector\Vector; +use Symfony\AI\Store\Bridge\S3Vectors\Store; +use Symfony\AI\Store\Document\Metadata; +use Symfony\AI\Store\Document\VectorDocument; +use Symfony\AI\Store\Exception\InvalidArgumentException; +use Symfony\Component\Uid\Uuid; + +final class StoreTest extends TestCase +{ + public function testAddSingleDocument() + { + $client = $this->createMock(S3VectorsClient::class); + + $uuid = Uuid::v4(); + + $client->expects($this->once()) + ->method('putVectors') + ->with($this->callback(static function ($input) use ($uuid) { + return 'test-bucket' === $input['vectorBucketName'] + && 'test-index' === $input['indexName'] + && 1 === \count($input['vectors']) + && (string) $uuid === $input['vectors'][0]->getKey() + && [0.1, 0.2, 0.3] === $input['vectors'][0]->getData()->requestBody()['float32']; + })); + + $document = new VectorDocument($uuid, new Vector([0.1, 0.2, 0.3]), new Metadata(['title' => 'Test Document'])); + self::createStore($client)->add($document); + } + + public function testAddMultipleDocuments() + { + $client = $this->createMock(S3VectorsClient::class); + + $uuid1 = Uuid::v4(); + $uuid2 = Uuid::v4(); + + $client->expects($this->once()) + ->method('putVectors') + ->with($this->callback(static function ($input) use ($uuid1, $uuid2) { + return 'test-bucket' === $input['vectorBucketName'] + && 'test-index' === $input['indexName'] + && 2 === \count($input['vectors']) + && (string) $uuid1 === $input['vectors'][0]->getKey() + && (string) $uuid2 === $input['vectors'][1]->getKey(); + })); + + $document1 = new VectorDocument($uuid1, new Vector([0.1, 0.2, 0.3])); + $document2 = new VectorDocument($uuid2, new Vector([0.4, 0.5, 0.6]), new Metadata(['title' => 'Second Document'])); + + self::createStore($client)->add([$document1, $document2]); + } + + public function testAddWithEmptyDocuments() + { + $client = $this->createMock(S3VectorsClient::class); + + $client->expects($this->never()) + ->method('putVectors'); + + self::createStore($client)->add([]); + } + + public function testRemoveSingleDocument() + { + $client = $this->createMock(S3VectorsClient::class); + + $vectorId = 'vector-id'; + + $client->expects($this->once()) + ->method('deleteVectors') + ->with($this->callback(static function ($input) use ($vectorId) { + return 'test-bucket' === $input['vectorBucketName'] + && 'test-index' === $input['indexName'] + && $input['keys'] === [$vectorId]; + })); + + self::createStore($client)->remove($vectorId); + } + + public function testRemoveMultipleDocuments() + { + $client = $this->createMock(S3VectorsClient::class); + + $documents = ['vector-id-1', 'vector-id-2', 'vector-id-3']; + + $client->expects($this->once()) + ->method('deleteVectors') + ->with($this->callback(static function ($input) use ($documents) { + return 'test-bucket' === $input['vectorBucketName'] + && 'test-index' === $input['indexName'] + && $input['keys'] === $documents; + })); + + self::createStore($client)->remove($documents); + } + + public function testRemoveWithEmptyDocuments() + { + $client = $this->createMock(S3VectorsClient::class); + + $client->expects($this->never()) + ->method('deleteVectors'); + + self::createStore($client)->remove([]); + } + + public function testRemoveWithFilter() + { + $client = $this->createMock(S3VectorsClient::class); + + $filter = ['category' => 'test']; + + $client->expects($this->once()) + ->method('deleteVectors') + ->with($this->callback(static function ($input) use ($filter) { + return 'test-bucket' === $input['vectorBucketName'] + && 'test-index' === $input['indexName'] + && $input['filter'] === $filter; + })); + + self::createStore($client)->remove(['id-1'], ['filter' => $filter]); + } + + public function testQueryReturnsDocuments() + { + $client = $this->createMock(S3VectorsClient::class); + + $uuid1 = Uuid::v4(); + $uuid2 = Uuid::v4(); + + $result = ResultMockFactory::create(QueryVectorsOutput::class, [ + 'vectors' => [ + new QueryOutputVector([ + 'key' => (string) $uuid1, + 'metadata' => ['title' => 'First Document'], + 'distance' => 0.95, + ]), + new QueryOutputVector([ + 'key' => (string) $uuid2, + 'metadata' => ['title' => 'Second Document'], + 'distance' => 0.85, + ]), + ], + 'distanceMetric' => DistanceMetric::COSINE, + ]); + + $client->expects($this->once()) + ->method('queryVectors') + ->with($this->callback(static function ($input) { + return 'test-bucket' === $input['vectorBucketName'] + && 'test-index' === $input['indexName'] + && [0.1, 0.2, 0.3] === $input['queryVector']->requestBody()['float32'] + && 3 === $input['topK'] + && true === $input['returnMetadata'] + && true === $input['returnDistance']; + })) + ->willReturn($result); + + $results = iterator_to_array(self::createStore($client)->query(new Vector([0.1, 0.2, 0.3]))); + + $this->assertCount(2, $results); + $this->assertInstanceOf(VectorDocument::class, $results[0]); + $this->assertInstanceOf(VectorDocument::class, $results[1]); + $this->assertEquals($uuid1, $results[0]->id); + $this->assertEquals($uuid2, $results[1]->id); + $this->assertSame(0.95, $results[0]->score); + $this->assertSame(0.85, $results[1]->score); + $this->assertSame('First Document', $results[0]->metadata['title']); + $this->assertSame('Second Document', $results[1]->metadata['title']); + } + + public function testQueryWithCustomOptions() + { + $client = $this->createMock(S3VectorsClient::class); + + $result = ResultMockFactory::create(QueryVectorsOutput::class, [ + 'vectors' => [], + 'distanceMetric' => DistanceMetric::COSINE, + ]); + + $client->expects($this->once()) + ->method('queryVectors') + ->with($this->callback(static function ($input) { + return 'test-bucket' === $input['vectorBucketName'] + && 'test-index' === $input['indexName'] + && 10 === $input['topK'] + && ['type' => 'document'] === $input['filter']; + })) + ->willReturn($result); + + $results = iterator_to_array(self::createStore($client)->query(new Vector([0.1, 0.2, 0.3]), [ + 'topK' => 10, + 'filter' => ['type' => 'document'], + ])); + + $this->assertCount(0, $results); + } + + public function testQueryWithEmptyResults() + { + $client = $this->createMock(S3VectorsClient::class); + + $result = ResultMockFactory::create(QueryVectorsOutput::class, [ + 'vectors' => [], + 'distanceMetric' => DistanceMetric::COSINE, + ]); + + $client->expects($this->once()) + ->method('queryVectors') + ->willReturn($result); + + $results = iterator_to_array(self::createStore($client)->query(new Vector([0.1, 0.2, 0.3]))); + + $this->assertCount(0, $results); + } + + public function testSetup() + { + $client = $this->createMock(S3VectorsClient::class); + + $client->expects($this->once()) + ->method('getVectorBucket') + ->with($this->callback(static function ($input) { + return 'test-bucket' === $input['vectorBucketName']; + })) + ->willThrowException(new \Exception('Bucket not found')); + + $client->expects($this->once()) + ->method('createVectorBucket') + ->with($this->callback(static function ($input) { + return 'test-bucket' === $input['vectorBucketName']; + })); + + $client->expects($this->once()) + ->method('createIndex') + ->with($this->callback(static function ($input) { + return 'test-bucket' === $input['vectorBucketName'] + && 'test-index' === $input['indexName'] + && 1536 === $input['dimension'] + && DistanceMetric::COSINE === $input['distanceMetric'] + && DataType::FLOAT_32 === $input['dataType']; + })); + + self::createStore($client)->setup([ + 'dimension' => 1536, + ]); + } + + public function testSetupWithCustomOptions() + { + $client = $this->createMock(S3VectorsClient::class); + + $client->expects($this->once()) + ->method('getVectorBucket') + ->willThrowException(new \Exception('Bucket not found')); + + $client->expects($this->once()) + ->method('createVectorBucket') + ->with($this->callback(static function ($input) { + return 'test-bucket' === $input['vectorBucketName'] + && isset($input['encryptionConfiguration']) + && 'test-key' === $input['encryptionConfiguration']['kmsKeyId'] + && ['env' => 'test'] === $input['tags']; + })); + + $client->expects($this->once()) + ->method('createIndex') + ->with($this->callback(static function ($input) { + return 'test-bucket' === $input['vectorBucketName'] + && 'test-index' === $input['indexName'] + && 384 === $input['dimension'] + && DistanceMetric::EUCLIDEAN === $input['distanceMetric'] + && isset($input['encryptionConfiguration']) + && 'test-key' === $input['encryptionConfiguration']['kmsKeyId'] + && ['env' => 'test'] === $input['tags']; + })); + + self::createStore($client)->setup([ + 'dimension' => 384, + 'distanceMetric' => DistanceMetric::EUCLIDEAN, + 'encryption' => ['kmsKeyId' => 'test-key'], + 'tags' => ['env' => 'test'], + ]); + } + + public function testSetupThrowsExceptionWithoutDimension() + { + $client = $this->createMock(S3VectorsClient::class); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The "dimension" option is required.'); + + self::createStore($client)->setup([]); + } + + public function testDrop() + { + $client = $this->createMock(S3VectorsClient::class); + + $client->expects($this->once()) + ->method('deleteIndex') + ->with($this->callback(static function ($input) { + return 'test-bucket' === $input['vectorBucketName'] + && 'test-index' === $input['indexName']; + })); + + $client->expects($this->once()) + ->method('deleteVectorBucket') + ->with($this->callback(static function ($input) { + return 'test-bucket' === $input['vectorBucketName']; + })); + + self::createStore($client)->drop(); + } + + /** + * @param array $filter + */ + private static function createStore(S3VectorsClient $client, string $vectorBucketName = 'test-bucket', string $indexName = 'test-index', array $filter = [], int $topK = 3): Store + { + return new Store($client, $vectorBucketName, $indexName, $filter, $topK); + } +} diff --git a/src/store/src/Bridge/S3Vectors/composer.json b/src/store/src/Bridge/S3Vectors/composer.json new file mode 100644 index 0000000000..8916be6455 --- /dev/null +++ b/src/store/src/Bridge/S3Vectors/composer.json @@ -0,0 +1,55 @@ +{ + "name": "symfony/ai-s3vectors-store", + "description": "AWS S3 Vectors store bridge for Symfony AI", + "license": "MIT", + "type": "symfony-ai-store", + "keywords": [ + "ai", + "aws", + "bridge", + "s3", + "s3vectors", + "store", + "vector" + ], + "authors": [ + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=8.2", + "async-aws/s3-vectors": "^2.0", + "symfony/ai-platform": "^0.3", + "symfony/ai-store": "^0.3", + "symfony/uid": "^7.3|^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^11.5.46" + }, + "minimum-stability": "dev", + "autoload": { + "psr-4": { + "Symfony\\AI\\Store\\Bridge\\S3Vectors\\": "" + } + }, + "autoload-dev": { + "psr-4": { + "Symfony\\AI\\PHPStan\\": "../../../../../.phpstan/", + "Symfony\\AI\\Store\\Bridge\\S3Vectors\\Tests\\": "Tests/" + } + }, + "config": { + "sort-packages": true + }, + "extra": { + "thanks": { + "name": "symfony/ai", + "url": "https://github.com/symfony/ai" + } + } +} diff --git a/src/store/src/Bridge/S3Vectors/phpstan.dist.neon b/src/store/src/Bridge/S3Vectors/phpstan.dist.neon new file mode 100644 index 0000000000..c8c2518f9e --- /dev/null +++ b/src/store/src/Bridge/S3Vectors/phpstan.dist.neon @@ -0,0 +1,11 @@ +includes: + - vendor/phpstan/phpstan-phpunit/extension.neon + - ../../../../../.phpstan/extension.neon + +parameters: + level: 9 + paths: + - . + excludePaths: + - Tests + - vendor diff --git a/src/store/src/Bridge/S3Vectors/phpunit.xml.dist b/src/store/src/Bridge/S3Vectors/phpunit.xml.dist new file mode 100644 index 0000000000..b8ce30cace --- /dev/null +++ b/src/store/src/Bridge/S3Vectors/phpunit.xml.dist @@ -0,0 +1,29 @@ + + + + + + + + + ./Tests + + + + + + ./ + + + ./Tests + ./vendor + + +