From 9eb3ccdb0452eec7546285e3fade47effcd54ab1 Mon Sep 17 00:00:00 2001 From: Oskar Stark Date: Fri, 19 Dec 2025 09:58:28 +0100 Subject: [PATCH] [Store][Pinecone] Add `ManagedStoreInterface` support --- examples/.env | 4 +- examples/commands/stores.php | 6 ++ examples/compose.yaml | 9 ++ examples/rag/pinecone.php | 2 +- src/ai-bundle/config/options.php | 1 + src/ai-bundle/src/AiBundle.php | 4 +- .../DependencyInjection/AiBundleTest.php | 99 +++-------------- src/store/src/Bridge/Pinecone/Store.php | 38 ++++++- .../src/Bridge/Pinecone/Tests/StoreTest.php | 100 ++++++++++++++---- 9 files changed, 151 insertions(+), 112 deletions(-) diff --git a/examples/.env b/examples/.env index fce85f4b8..d5d104508 100644 --- a/examples/.env +++ b/examples/.env @@ -80,8 +80,8 @@ MAPBOX_ACCESS_TOKEN= MONGODB_URI=mongodb://symfony:symfony@127.0.0.1:27017 # For using Pinecone (store) -PINECONE_API_KEY= -PINECONE_HOST= +PINECONE_API_KEY=pclocal +PINECONE_HOST=http://127.0.0.1:5080 # For using Postgres (store) POSTGRES_URI=pdo-pgsql://postgres:postgres@127.0.1:5432/my_database diff --git a/examples/commands/stores.php b/examples/commands/stores.php index 026292b86..26f140a40 100644 --- a/examples/commands/stores.php +++ b/examples/commands/stores.php @@ -14,6 +14,7 @@ use Doctrine\DBAL\DriverManager; use Doctrine\DBAL\Tools\DsnParser; use MongoDB\Client as MongoDbClient; +use Probots\Pinecone\Client as PineconeClient; 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; @@ -24,6 +25,7 @@ use Symfony\AI\Store\Bridge\MongoDb\Store as MongoDbStore; use Symfony\AI\Store\Bridge\Neo4j\Store as Neo4jStore; use Symfony\AI\Store\Bridge\OpenSearch\Store as OpenSearchStore; +use Symfony\AI\Store\Bridge\Pinecone\Store as PineconeStore; 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; @@ -99,6 +101,10 @@ // env('OPENSEARCH_ENDPOINT'), // 'symfony', // ), + // 'pinecone' => static fn (): PineconeStore => new PineconeStore( + // new PineconeClient(env('PINECONE_API_KEY'), env('PINECONE_HOST')), + // 'symfony', + // ), 'postgres' => static fn (): PostgresStore => PostgresStore::fromDbal( DriverManager::getConnection((new DsnParser())->parse(env('POSTGRES_URI'))), 'my_table', diff --git a/examples/compose.yaml b/examples/compose.yaml index 1423519d0..6c6030306 100644 --- a/examples/compose.yaml +++ b/examples/compose.yaml @@ -146,6 +146,15 @@ services: ports: - '9201:9200' + pinecone: + image: ghcr.io/pinecone-io/pinecone-local:latest + platform: linux/amd64 + environment: + PORT: 5080 + PINECONE_HOST: localhost + ports: + - '5080-5090:5080-5090' + opensearch: image: opensearchproject/opensearch environment: diff --git a/examples/rag/pinecone.php b/examples/rag/pinecone.php index 072dd6b96..36049d986 100644 --- a/examples/rag/pinecone.php +++ b/examples/rag/pinecone.php @@ -29,7 +29,7 @@ require_once dirname(__DIR__).'/bootstrap.php'; // initialize the store -$store = new Store(Pinecone::client(env('PINECONE_API_KEY'), env('PINECONE_HOST'))); +$store = new Store(Pinecone::client(env('PINECONE_API_KEY'), env('PINECONE_HOST')), 'symfony'); // create embeddings and documents $documents = []; diff --git a/src/ai-bundle/config/options.php b/src/ai-bundle/config/options.php index 1a3396e44..ae82918a3 100644 --- a/src/ai-bundle/config/options.php +++ b/src/ai-bundle/config/options.php @@ -800,6 +800,7 @@ ->cannotBeEmpty() ->defaultValue(PineconeClient::class) ->end() + ->stringNode('index_name')->isRequired()->end() ->stringNode('namespace')->end() ->arrayNode('filter') ->scalarPrototype() diff --git a/src/ai-bundle/src/AiBundle.php b/src/ai-bundle/src/AiBundle.php index af38f73cd..312c95b5a 100644 --- a/src/ai-bundle/src/AiBundle.php +++ b/src/ai-bundle/src/AiBundle.php @@ -1519,12 +1519,13 @@ private function processStoreConfig(string $type, array $stores, ContainerBuilde foreach ($stores as $name => $store) { $arguments = [ new Reference($store['client']), + $store['index_name'], $store['namespace'] ?? $name, $store['filter'], ]; if (\array_key_exists('top_k', $store)) { - $arguments[3] = $store['top_k']; + $arguments[4] = $store['top_k']; } $definition = new Definition(PineconeStore::class); @@ -1532,6 +1533,7 @@ private function processStoreConfig(string $type, array $stores, ContainerBuilde ->setLazy(true) ->setArguments($arguments) ->addTag('proxy', ['interface' => StoreInterface::class]) + ->addTag('proxy', ['interface' => ManagedStoreInterface::class]) ->addTag('ai.store'); $container->setDefinition('ai.store.'.$type.'.'.$name, $definition); diff --git a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php index ed2157c22..c79b4d022 100644 --- a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php +++ b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php @@ -2370,48 +2370,13 @@ public function testOpenSearchStoreWithCustomHttpClientCanBeConfigured() } public function testPineconeStoreCanBeConfigured() - { - $container = $this->buildContainer([ - 'ai' => [ - 'store' => [ - 'pinecone' => [ - 'my_pinecone_store' => [], - ], - ], - ], - ]); - - $this->assertTrue($container->hasDefinition('ai.store.pinecone.my_pinecone_store')); - - $definition = $container->getDefinition('ai.store.pinecone.my_pinecone_store'); - $this->assertSame(PineconeStore::class, $definition->getClass()); - - $this->assertTrue($definition->isLazy()); - $this->assertCount(3, $definition->getArguments()); - $this->assertInstanceOf(Reference::class, $definition->getArgument(0)); - $this->assertSame(PineconeClient::class, (string) $definition->getArgument(0)); - $this->assertSame('my_pinecone_store', $definition->getArgument(1)); - $this->assertSame([], $definition->getArgument(2)); - - $this->assertTrue($definition->hasTag('proxy')); - $this->assertSame([['interface' => StoreInterface::class]], $definition->getTag('proxy')); - $this->assertTrue($definition->hasTag('ai.store')); - - $this->assertTrue($container->hasAlias('.Symfony\AI\Store\StoreInterface $my_pinecone_store')); - $this->assertTrue($container->hasAlias('Symfony\AI\Store\StoreInterface $myPineconeStore')); - $this->assertTrue($container->hasAlias('.Symfony\AI\Store\StoreInterface $pinecone_my_pinecone_store')); - $this->assertTrue($container->hasAlias('Symfony\AI\Store\StoreInterface $pineconeMyPineconeStore')); - $this->assertTrue($container->hasAlias('Symfony\AI\Store\StoreInterface')); - } - - public function testPineconeStoreWithCustomNamespaceCanBeConfigured() { $container = $this->buildContainer([ 'ai' => [ 'store' => [ 'pinecone' => [ 'my_pinecone_store' => [ - 'namespace' => 'my_namespace', + 'index_name' => 'my_index', ], ], ], @@ -2424,14 +2389,15 @@ public function testPineconeStoreWithCustomNamespaceCanBeConfigured() $this->assertSame(PineconeStore::class, $definition->getClass()); $this->assertTrue($definition->isLazy()); - $this->assertCount(3, $definition->getArguments()); + $this->assertCount(4, $definition->getArguments()); $this->assertInstanceOf(Reference::class, $definition->getArgument(0)); $this->assertSame(PineconeClient::class, (string) $definition->getArgument(0)); - $this->assertSame('my_namespace', $definition->getArgument(1)); - $this->assertSame([], $definition->getArgument(2)); + $this->assertSame('my_index', $definition->getArgument(1)); + $this->assertSame('my_pinecone_store', $definition->getArgument(2)); + $this->assertSame([], $definition->getArgument(3)); $this->assertTrue($definition->hasTag('proxy')); - $this->assertSame([['interface' => StoreInterface::class]], $definition->getTag('proxy')); + $this->assertSame([['interface' => StoreInterface::class], ['interface' => ManagedStoreInterface::class]], $definition->getTag('proxy')); $this->assertTrue($definition->hasTag('ai.store')); $this->assertTrue($container->hasAlias('.Symfony\AI\Store\StoreInterface $my_pinecone_store')); @@ -2441,14 +2407,14 @@ public function testPineconeStoreWithCustomNamespaceCanBeConfigured() $this->assertTrue($container->hasAlias('Symfony\AI\Store\StoreInterface')); } - public function testPineconeStoreWithCustomClientCanBeConfigured() + public function testPineconeStoreWithCustomIndexNameCanBeConfigured() { $container = $this->buildContainer([ 'ai' => [ 'store' => [ 'pinecone' => [ 'my_pinecone_store' => [ - 'client' => 'foo', + 'index_name' => 'custom_index', 'namespace' => 'my_namespace', ], ], @@ -2461,54 +2427,16 @@ public function testPineconeStoreWithCustomClientCanBeConfigured() $definition = $container->getDefinition('ai.store.pinecone.my_pinecone_store'); $this->assertSame(PineconeStore::class, $definition->getClass()); - $this->assertTrue($definition->isLazy()); - $this->assertCount(3, $definition->getArguments()); - $this->assertInstanceOf(Reference::class, $definition->getArgument(0)); - $this->assertSame('foo', (string) $definition->getArgument(0)); - $this->assertSame('my_namespace', $definition->getArgument(1)); - $this->assertSame([], $definition->getArgument(2)); - - $this->assertTrue($definition->hasTag('proxy')); - $this->assertSame([['interface' => StoreInterface::class]], $definition->getTag('proxy')); - $this->assertTrue($definition->hasTag('ai.store')); - - $this->assertTrue($container->hasAlias('.Symfony\AI\Store\StoreInterface $my_pinecone_store')); - $this->assertTrue($container->hasAlias('Symfony\AI\Store\StoreInterface $myPineconeStore')); - $this->assertTrue($container->hasAlias('.Symfony\AI\Store\StoreInterface $pinecone_my_pinecone_store')); - $this->assertTrue($container->hasAlias('Symfony\AI\Store\StoreInterface $pineconeMyPineconeStore')); - $this->assertTrue($container->hasAlias('Symfony\AI\Store\StoreInterface')); - } - - public function testPineconeStoreWithTopKCanBeConfigured() - { - $container = $this->buildContainer([ - 'ai' => [ - 'store' => [ - 'pinecone' => [ - 'my_pinecone_store' => [ - 'namespace' => 'my_namespace', - 'top_k' => 100, - ], - ], - ], - ], - ]); - - $this->assertTrue($container->hasDefinition('ai.store.pinecone.my_pinecone_store')); - - $definition = $container->getDefinition('ai.store.pinecone.my_pinecone_store'); - $this->assertSame(PineconeStore::class, $definition->getClass()); - $this->assertTrue($definition->isLazy()); $this->assertCount(4, $definition->getArguments()); $this->assertInstanceOf(Reference::class, $definition->getArgument(0)); $this->assertSame(PineconeClient::class, (string) $definition->getArgument(0)); - $this->assertSame('my_namespace', $definition->getArgument(1)); - $this->assertSame([], $definition->getArgument(2)); - $this->assertSame(100, $definition->getArgument(3)); + $this->assertSame('custom_index', $definition->getArgument(1)); + $this->assertSame('my_namespace', $definition->getArgument(2)); + $this->assertSame([], $definition->getArgument(3)); $this->assertTrue($definition->hasTag('proxy')); - $this->assertSame([['interface' => StoreInterface::class]], $definition->getTag('proxy')); + $this->assertSame([['interface' => StoreInterface::class], ['interface' => ManagedStoreInterface::class]], $definition->getTag('proxy')); $this->assertTrue($definition->hasTag('ai.store')); $this->assertTrue($container->hasAlias('.Symfony\AI\Store\StoreInterface $my_pinecone_store')); @@ -7317,15 +7245,18 @@ private function getFullConfig(): array ], 'pinecone' => [ 'my_pinecone_store' => [ + 'index_name' => 'my_index', 'namespace' => 'my_namespace', 'filter' => ['category' => 'books'], 'top_k' => 10, ], 'my_pinecone_store_with_filter' => [ + 'index_name' => 'my_index', 'namespace' => 'my_namespace', 'filter' => ['category' => 'books'], ], 'my_pinecone_store_with_top_k' => [ + 'index_name' => 'my_index', 'namespace' => 'my_namespace', 'filter' => ['category' => 'books'], 'top_k' => 10, diff --git a/src/store/src/Bridge/Pinecone/Store.php b/src/store/src/Bridge/Pinecone/Store.php index deabd8bbb..7bc0ab7e7 100644 --- a/src/store/src/Bridge/Pinecone/Store.php +++ b/src/store/src/Bridge/Pinecone/Store.php @@ -16,25 +16,53 @@ 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; /** * @author Christopher Hertel */ -final class Store implements StoreInterface +final class Store implements ManagedStoreInterface, StoreInterface { /** * @param array $filter */ public function __construct( private readonly Client $pinecone, + private readonly string $indexName, private readonly ?string $namespace = null, private readonly array $filter = [], private readonly int $topK = 3, ) { } + /** + * @param array{ + * dimension?: int, + * metric?: string, + * cloud?: string, + * region?: string, + * } $options + */ + public function setup(array $options = []): void + { + if (false === isset($options['dimension'])) { + throw new InvalidArgumentException('The "dimension" option is required.'); + } + + $this->pinecone + ->control() + ->index($this->indexName) + ->createServerless( + $options['dimension'], + $options['metric'] ?? null, + $options['cloud'] ?? null, + $options['region'] ?? null, + ); + } + public function add(VectorDocument ...$documents): void { $vectors = []; @@ -73,6 +101,14 @@ public function query(Vector $vector, array $options = []): iterable } } + public function drop(array $options = []): void + { + $this->pinecone + ->control() + ->index($this->indexName) + ->delete(); + } + private function getVectors(): VectorResource { return $this->pinecone->data()->vectors(); diff --git a/src/store/src/Bridge/Pinecone/Tests/StoreTest.php b/src/store/src/Bridge/Pinecone/Tests/StoreTest.php index a3fb23bde..d7377f3aa 100644 --- a/src/store/src/Bridge/Pinecone/Tests/StoreTest.php +++ b/src/store/src/Bridge/Pinecone/Tests/StoreTest.php @@ -13,6 +13,8 @@ use PHPUnit\Framework\TestCase; use Probots\Pinecone\Client; +use Probots\Pinecone\Resources\Control\IndexResource; +use Probots\Pinecone\Resources\ControlResource; use Probots\Pinecone\Resources\Data\VectorResource; use Probots\Pinecone\Resources\DataResource; use Saloon\Http\Response; @@ -20,6 +22,7 @@ use Symfony\AI\Store\Bridge\Pinecone\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 @@ -53,10 +56,8 @@ public function testAddSingleDocument() null, ); - $store = new Store($client); - $document = new VectorDocument($uuid, new Vector([0.1, 0.2, 0.3]), new Metadata(['title' => 'Test Document'])); - $store->add($document); + self::createStore($client)->add($document); } public function testAddMultipleDocuments() @@ -94,12 +95,10 @@ public function testAddMultipleDocuments() null, ); - $store = new Store($client); - $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'])); - $store->add($document1, $document2); + self::createStore($client)->add($document1, $document2); } public function testAddWithNamespace() @@ -131,10 +130,8 @@ public function testAddWithNamespace() 'test-namespace', ); - $store = new Store($client, 'test-namespace'); - $document = new VectorDocument($uuid, new Vector([0.1, 0.2, 0.3])); - $store->add($document); + self::createStore($client, namespace: 'test-namespace')->add($document); } public function testAddWithEmptyDocuments() @@ -144,8 +141,7 @@ public function testAddWithEmptyDocuments() $client->expects($this->never()) ->method('data'); - $store = new Store($client); - $store->add(); + self::createStore($client)->add(); } public function testQueryReturnsDocuments() @@ -194,9 +190,7 @@ public function testQueryReturnsDocuments() ) ->willReturn($response); - $store = new Store($client); - - $results = iterator_to_array($store->query(new Vector([0.1, 0.2, 0.3]))); + $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]); @@ -239,9 +233,7 @@ public function testQueryWithNamespaceAndFilter() ) ->willReturn($response); - $store = new Store($client, 'test-namespace', ['category' => 'test'], 5); - - $results = iterator_to_array($store->query(new Vector([0.1, 0.2, 0.3]))); + $results = iterator_to_array(self::createStore($client, namespace: 'test-namespace', filter: ['category' => 'test'], topK: 5)->query(new Vector([0.1, 0.2, 0.3]))); $this->assertCount(0, $results); } @@ -276,9 +268,7 @@ public function testQueryWithCustomOptions() ) ->willReturn($response); - $store = new Store($client); - - $results = iterator_to_array($store->query(new Vector([0.1, 0.2, 0.3]), [ + $results = iterator_to_array(self::createStore($client)->query(new Vector([0.1, 0.2, 0.3]), [ 'namespace' => 'custom-namespace', 'filter' => ['type' => 'document'], 'topK' => 10, @@ -310,10 +300,74 @@ public function testQueryWithEmptyResults() ->method('query') ->willReturn($response); - $store = new Store($client); - - $results = iterator_to_array($store->query(new Vector([0.1, 0.2, 0.3]))); + $results = iterator_to_array(self::createStore($client)->query(new Vector([0.1, 0.2, 0.3]))); $this->assertCount(0, $results); } + + public function testSetup() + { + $indexResource = $this->createMock(IndexResource::class); + $controlResource = $this->createMock(ControlResource::class); + $client = $this->createMock(Client::class); + + $client->expects($this->once()) + ->method('control') + ->willReturn($controlResource); + + $controlResource->expects($this->once()) + ->method('index') + ->with('my-index') + ->willReturn($indexResource); + + $indexResource->expects($this->once()) + ->method('createServerless') + ->with(1536, 'cosine', 'aws', 'us-east-1'); + + self::createStore($client, indexName: 'my-index')->setup([ + 'dimension' => 1536, + 'metric' => 'cosine', + 'cloud' => 'aws', + 'region' => 'us-east-1', + ]); + } + + public function testSetupThrowsExceptionWithoutDimension() + { + $client = $this->createMock(Client::class); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The "dimension" option is required.'); + + self::createStore($client, indexName: 'my-index')->setup([]); + } + + public function testDrop() + { + $indexResource = $this->createMock(IndexResource::class); + $controlResource = $this->createMock(ControlResource::class); + $client = $this->createMock(Client::class); + + $client->expects($this->once()) + ->method('control') + ->willReturn($controlResource); + + $controlResource->expects($this->once()) + ->method('index') + ->with('my-index') + ->willReturn($indexResource); + + $indexResource->expects($this->once()) + ->method('delete'); + + self::createStore($client, indexName: 'my-index')->drop(); + } + + /** + * @param array $filter + */ + private static function createStore(Client $client, string $indexName = 'test-index', ?string $namespace = null, array $filter = [], int $topK = 3): Store + { + return new Store($client, $indexName, $namespace, $filter, $topK); + } }