From 11f0c31876a9e9a647cad72dae20ab7212acca60 Mon Sep 17 00:00:00 2001 From: Guillaume Loulier Date: Tue, 4 Nov 2025 14:48:14 +0100 Subject: [PATCH] [Store] Manticore support --- docs/components/store.rst | 4 + examples/.env | 3 + examples/commands/stores.php | 7 + examples/compose.yaml | 14 ++ examples/rag/manticore.php | 68 ++++++ src/ai-bundle/config/options.php | 14 ++ src/ai-bundle/src/AiBundle.php | 40 ++++ .../DependencyInjection/AiBundleTest.php | 11 + src/store/CHANGELOG.md | 1 + src/store/src/Bridge/Manticore/Store.php | 157 ++++++++++++++ .../tests/Bridge/Manticore/StoreTest.php | 200 ++++++++++++++++++ 11 files changed, 519 insertions(+) create mode 100644 examples/rag/manticore.php create mode 100644 src/store/src/Bridge/Manticore/Store.php create mode 100644 src/store/tests/Bridge/Manticore/StoreTest.php diff --git a/docs/components/store.rst b/docs/components/store.rst index 28a3fa24d..00cb63ef0 100644 --- a/docs/components/store.rst +++ b/docs/components/store.rst @@ -36,6 +36,7 @@ used vector store:: You can find more advanced usage in combination with an Agent using the store for RAG in the examples folder: * `Similarity Search with Cloudflare (RAG)`_ +* `Similarity Search with Manticore (RAG)`_ * `Similarity Search with MariaDB (RAG)`_ * `Similarity Search with Meilisearch (RAG)`_ * `Similarity Search with memory storage (RAG)`_ @@ -63,6 +64,7 @@ Supported Stores * `Chroma`_ (requires `codewithkyrian/chromadb-php` as additional dependency) * `Cloudflare`_ * `InMemory`_ +* `Manticore`_ * `MariaDB`_ (requires `ext-pdo`) * `Meilisearch`_ * `Milvus`_ @@ -128,6 +130,7 @@ This leads to a store implementing two methods:: .. _`Retrieval Augmented Generation`: https://en.wikipedia.org/wiki/Retrieval-augmented_generation .. _`Similarity Search with Cloudflare (RAG)`: https://github.com/symfony/ai/blob/main/examples/rag/cloudflare.php +.. _`Similarity Search with Manticore (RAG)`: https://github.com/symfony/ai/blob/main/examples/rag/manticore.php .. _`Similarity Search with MariaDB (RAG)`: https://github.com/symfony/ai/blob/main/examples/rag/mariadb-gemini.php .. _`Similarity Search with Meilisearch (RAG)`: https://github.com/symfony/ai/blob/main/examples/rag/meilisearch.php .. _`Similarity Search with memory storage (RAG)`: https://github.com/symfony/ai/blob/main/examples/rag/in-memory.php @@ -144,6 +147,7 @@ This leads to a store implementing two methods:: .. _`Azure AI Search`: https://azure.microsoft.com/products/ai-services/ai-search .. _`Chroma`: https://www.trychroma.com/ .. _`Cloudflare`: https://developers.cloudflare.com/vectorize/ +.. _`Manticore`: https://manticoresearch.com/ .. _`MariaDB`: https://mariadb.org/projects/mariadb-vector/ .. _`Pinecone`: https://www.pinecone.io/ .. _`Postgres`: https://www.postgresql.org/about/news/pgvector-070-released-2852/ diff --git a/examples/.env b/examples/.env index e57e3a43a..5ec1b247d 100644 --- a/examples/.env +++ b/examples/.env @@ -171,3 +171,6 @@ POGOCACHE_PASSWORD=symfony # Redis (both store and message store) REDIS_HOST=localhost + +# Manticore (store) +MANTICORE_HOST=http://127.0.0.1:9308 diff --git a/examples/commands/stores.php b/examples/commands/stores.php index e0053eb8b..29b25bfbb 100644 --- a/examples/commands/stores.php +++ b/examples/commands/stores.php @@ -17,6 +17,7 @@ use Symfony\AI\Store\Bridge\ClickHouse\Store as ClickHouseStore; use Symfony\AI\Store\Bridge\Local\CacheStore; use Symfony\AI\Store\Bridge\Local\InMemoryStore; +use Symfony\AI\Store\Bridge\Manticore\Store as ManticoreStore; use Symfony\AI\Store\Bridge\MariaDb\Store as MariaDbStore; use Symfony\AI\Store\Bridge\Meilisearch\Store as MeilisearchStore; use Symfony\AI\Store\Bridge\Milvus\Store as MilvusStore; @@ -44,6 +45,12 @@ env('CLICKHOUSE_DATABASE'), env('CLICKHOUSE_TABLE'), ), + 'manticore' => static fn (): ManticoreStore => new ManticoreStore( + http_client(), + env('MANTICORE_HOST'), + 'symfony', + '_vectors', + ), 'mariadb' => static fn (): MariaDbStore => MariaDbStore::fromDbal( DriverManager::getConnection((new DsnParser())->parse(env('MARIADB_URI'))), 'my_table_for_commands', diff --git a/examples/compose.yaml b/examples/compose.yaml index 5e01b4df3..3dccd929f 100644 --- a/examples/compose.yaml +++ b/examples/compose.yaml @@ -19,6 +19,20 @@ services: ports: - '8123:8123' + manticore: + image: manticoresearch/manticore + ulimits: + nproc: 65535 + nofile: + soft: 65535 + hard: 65535 + memlock: + soft: -1 + hard: -1 + ports: + - '9306:9306' + - '9308:9308' + mariadb: image: mariadb:11.7 environment: diff --git a/examples/rag/manticore.php b/examples/rag/manticore.php new file mode 100644 index 000000000..4a6788961 --- /dev/null +++ b/examples/rag/manticore.php @@ -0,0 +1,68 @@ + + * + * 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\Toolbox\AgentProcessor; +use Symfony\AI\Agent\Toolbox\Tool\SimilaritySearch; +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\Manticore\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(), + host: 'http://127.0.0.1:9308', + table: 'movies', + field: '_movie_vectors', +); + +// Create the table +$store->setup(); + +// 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), + ); +} + +// 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/src/ai-bundle/config/options.php b/src/ai-bundle/config/options.php index fb92b7f7b..635f764e8 100644 --- a/src/ai-bundle/config/options.php +++ b/src/ai-bundle/config/options.php @@ -514,6 +514,20 @@ ->end() ->end() ->end() + ->arrayNode('manticore') + ->useAttributeAsKey('name') + ->arrayPrototype() + ->children() + ->stringNode('endpoint')->cannotBeEmpty()->end() + ->stringNode('table')->cannotBeEmpty()->end() + ->stringNode('field')->end() + ->stringNode('type')->end() + ->stringNode('similarity')->end() + ->integerNode('dimensions')->end() + ->stringNode('quantization')->end() + ->end() + ->end() + ->end() ->arrayNode('meilisearch') ->useAttributeAsKey('name') ->arrayPrototype() diff --git a/src/ai-bundle/src/AiBundle.php b/src/ai-bundle/src/AiBundle.php index df55772ca..25d6a3ea1 100644 --- a/src/ai-bundle/src/AiBundle.php +++ b/src/ai-bundle/src/AiBundle.php @@ -69,6 +69,7 @@ use Symfony\AI\Store\Bridge\Local\DistanceCalculator; use Symfony\AI\Store\Bridge\Local\DistanceStrategy; use Symfony\AI\Store\Bridge\Local\InMemoryStore; +use Symfony\AI\Store\Bridge\Manticore\Store as ManticoreStore; use Symfony\AI\Store\Bridge\Meilisearch\Store as MeilisearchStore; use Symfony\AI\Store\Bridge\Milvus\Store as MilvusStore; use Symfony\AI\Store\Bridge\MongoDb\Store as MongoDbStore; @@ -912,6 +913,45 @@ private function processStoreConfig(string $type, array $stores, ContainerBuilde } } + if ('manticore' === $type) { + foreach ($stores as $name => $store) { + $arguments = [ + new Reference('http_client'), + $store['endpoint'], + $store['table'], + ]; + + if (\array_key_exists('field', $store)) { + $arguments[3] = $store['field']; + } + + if (\array_key_exists('type', $store)) { + $arguments[4] = $store['type']; + } + + if (\array_key_exists('similarity', $store)) { + $arguments[5] = $store['similarity']; + } + + if (\array_key_exists('dimensions', $store)) { + $arguments[6] = $store['dimensions']; + } + + if (\array_key_exists('quantization', $store)) { + $arguments[7] = $store['quantization']; + } + + $definition = new Definition(ManticoreStore::class); + $definition + ->addTag('ai.store') + ->setArguments($arguments); + + $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 ('meilisearch' === $type) { foreach ($stores as $name => $store) { $arguments = [ diff --git a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php index 36056cb12..ba1a461c3 100644 --- a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php +++ b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php @@ -2911,6 +2911,17 @@ private function getFullConfig(): array 'endpoint_url' => 'https://api.cloudflare.com/client/v5/accounts', ], ], + 'manticore' => [ + 'my_manticore_store' => [ + 'endpoint' => 'http://127.0.0.1:9306', + 'table' => 'test', + 'field' => 'foo_vector', + 'type' => 'hnsw', + 'similarity' => 'cosine', + 'dimensions' => 768, + 'quantization' => '1bit', + ], + ], 'meilisearch' => [ 'my_meilisearch_store' => [ 'endpoint' => 'http://127.0.0.1:7700', diff --git a/src/store/CHANGELOG.md b/src/store/CHANGELOG.md index e337546bd..a43ad2cd6 100644 --- a/src/store/CHANGELOG.md +++ b/src/store/CHANGELOG.md @@ -38,6 +38,7 @@ CHANGELOG - ChromaDB - ClickHouse - Cloudflare + - Manticore - MariaDB - Meilisearch - MongoDB diff --git a/src/store/src/Bridge/Manticore/Store.php b/src/store/src/Bridge/Manticore/Store.php new file mode 100644 index 000000000..f5b9f51ff --- /dev/null +++ b/src/store/src/Bridge/Manticore/Store.php @@ -0,0 +1,157 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Store\Bridge\Manticore; + +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; + +/** + * @author Guillaume Loulier + */ +final class Store implements ManagedStoreInterface, StoreInterface +{ + public function __construct( + private readonly HttpClientInterface $httpClient, + private readonly string $host, + private readonly string $table, + private readonly string $field = '_vectors', + private readonly string $type = 'hnsw', + private readonly string $similarity = 'cosine', + private readonly int $dimensions = 1536, + private readonly string $quantization = '8bit', + ) { + } + + public function setup(array $options = []): void + { + if ([] !== $options) { + throw new InvalidArgumentException('No supported options.'); + } + + $this->request('cli', \sprintf( + "CREATE TABLE %s (uuid TEXT, metadata JSON, %s FLOAT_VECTOR KNN_TYPE='%s' KNN_DIMS='%s' HNSW_SIMILARITY='%s' QUANTIZATION='%s')", + $this->table, $this->field, $this->type, $this->dimensions, $this->similarity, $this->quantization, + )); + } + + public function drop(): void + { + $this->request('cli', \sprintf('DROP TABLE %s', $this->table)); + } + + /** + * @throws \Random\RandomException {@see random_int()} + */ + public function add(VectorDocument ...$documents): void + { + $payload = array_map( + fn (VectorDocument $document): array => [ + 'insert' => [ + 'table' => $this->table, + 'id' => random_int(0, \PHP_INT_MAX), + 'doc' => [ + 'uuid' => $document->id->toRfc4122(), + $this->field => $document->vector->getData(), + 'metadata' => json_encode($document->metadata->getArrayCopy()), + ], + ], + ], + $documents, + ); + + $this->request('bulk', function () use ($payload) { + foreach ($payload as $document) { + yield json_encode($document).\PHP_EOL; + } + }); + } + + public function query(Vector $vector, array $options = []): array + { + $documents = $this->request('search', [ + 'table' => $this->table, + 'knn' => [ + 'field' => $this->field, + 'query' => $vector->getData(), + 'k' => $options['k'] ?? 250, + 'ef' => $options['ef'] ?? 1000, + ], + ]); + + return array_map($this->convertToVectorDocument(...), $documents['hits']['hits']); + } + + /** + * @param \Closure|array|string $query + * + * @return array + */ + private function request(string $endpoint, \Closure|array|string $query): array + { + $options = match ($endpoint) { + 'cli' => [ + 'body' => $query, + ], + 'bulk' => [ + 'headers' => [ + 'Content-Type' => 'application/x-ndjson', + ], + 'body' => $query(), + ], + 'search' => [ + 'json' => $query, + ], + default => throw new InvalidArgumentException(\sprintf('The endpoint "%s" is not supported', $endpoint)), + }; + + $response = $this->httpClient->request('POST', \sprintf('%s/%s', $this->host, $endpoint), $options); + + return 'cli' === $endpoint ? [ + 'result' => $response->getContent(), + ] : $response->toArray(); + } + + /** + * @param array{ + * _id: int, + * _score: int, + * _knn_dist: float, + * _source: array, + * } $data + */ + private function convertToVectorDocument(array $data): VectorDocument + { + $payload = $data['_source']; + + if (!\array_key_exists('uuid', $payload)) { + throw new InvalidArgumentException('Missing "uuid" field in the document data.'); + } + + $vector = !\array_key_exists($this->field, $payload) || null === $payload[$this->field] + ? new NullVector() + : new Vector($payload[$this->field]); + + return new VectorDocument( + id: Uuid::fromString($payload['uuid']), + vector: $vector, + metadata: new Metadata($payload['metadata'] ?? []), + score: $data['_knn_dist'] ?? null + ); + } +} diff --git a/src/store/tests/Bridge/Manticore/StoreTest.php b/src/store/tests/Bridge/Manticore/StoreTest.php new file mode 100644 index 000000000..be6bb237f --- /dev/null +++ b/src/store/tests/Bridge/Manticore/StoreTest.php @@ -0,0 +1,200 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Store\Tests\Bridge\Manticore; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Vector\Vector; +use Symfony\AI\Store\Bridge\Manticore\Store; +use Symfony\AI\Store\Document\VectorDocument; +use Symfony\AI\Store\Exception\InvalidArgumentException; +use Symfony\Component\HttpClient\Exception\ClientException; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\JsonMockResponse; +use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\Component\Uid\Uuid; + +final class StoreTest extends TestCase +{ + public function testStoreCannotSetupWithExtraOptions() + { + $store = new Store(new MockHttpClient(), 'http://127.0.0.1:9308', 'bar', 'random'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('No supported options.'); + $this->expectExceptionCode(0); + $store->setup([ + 'foo' => 'bar', + ]); + } + + public function testStoreCannotSetupOnInvalidResponse() + { + $httpClient = new MockHttpClient([ + new MockResponse([], [ + 'http_code' => 400, + ]), + ]); + + $store = new Store($httpClient, 'http://127.0.0.1:9308', 'bar', 'random'); + + $this->expectException(ClientException::class); + $this->expectExceptionMessage('HTTP 400 returned for "http://127.0.0.1:9308/cli".'); + $this->expectExceptionCode(400); + $store->setup(); + } + + public function testStoreCanSetup() + { + $httpClient = new MockHttpClient([ + new MockResponse('Query OK, 0 rows affected (0.006 sec)'.\PHP_EOL, [ + 'http_code' => 200, + ]), + ]); + + $store = new Store($httpClient, 'http://127.0.0.1:9308', 'bar', 'random'); + + $store->setup(); + + $this->assertSame(1, $httpClient->getRequestsCount()); + } + + public function testStoreCannotDropOnInvalidResponse() + { + $mockHttpClient = new MockHttpClient([ + new MockResponse([], [ + 'http_code' => 400, + ]), + ]); + + $store = new Store($mockHttpClient, 'http://127.0.0.1:9308', 'bar', 'random'); + + $this->expectException(ClientException::class); + $this->expectExceptionMessage('HTTP 400 returned for "http://127.0.0.1:9308/cli".'); + $this->expectExceptionCode(400); + $store->drop(); + } + + public function testStoreCanDrop() + { + $httpClient = new MockHttpClient([ + new MockResponse('Query OK, 1 rows affected (0.006 sec)'.\PHP_EOL, [ + 'http_code' => 200, + ]), + ]); + + $store = new Store($httpClient, 'http://127.0.0.1:9308', 'bar', 'random'); + + $store->drop(); + + $this->assertSame(1, $httpClient->getRequestsCount()); + } + + public function testStoreCannotAddOnInvalidResponse() + { + $mockHttpClient = new MockHttpClient([ + new JsonMockResponse([], [ + 'http_code' => 400, + ]), + ]); + + $store = new Store($mockHttpClient, 'http://127.0.0.1:9308', 'bar', 'random'); + + $this->expectException(ClientException::class); + $this->expectExceptionMessage('HTTP 400 returned for "http://127.0.0.1:9308/bulk".'); + $this->expectExceptionCode(400); + $store->add(new VectorDocument(Uuid::v4(), new Vector([0.1, 0.2, 0.3]))); + } + + public function testStoreCanAdd() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse([ + 'items' => [ + [ + 'bulk' => [ + 'table' => 'bar', + '_id' => 1, + 'created' => 1, + 'deleted' => 0, + 'updated' => 0, + 'result' => 'created', + 'status' => 201, + ], + ], + ], + 'current_line' => 4, + 'skipped_lines' => 0, + 'errors' => false, + 'error' => '', + ], [ + 'http_code' => 200, + ]), + ]); + + $store = new Store($httpClient, 'http://127.0.0.1:9308', 'bar', 'random'); + $store->add(new VectorDocument(Uuid::v4(), new Vector([0.1, 0.2, 0.3]))); + + $this->assertSame(1, $httpClient->getRequestsCount()); + } + + public function testStoreCannotQueryOnInvalidResponse() + { + $mockHttpClient = new MockHttpClient([ + new JsonMockResponse([], [ + 'http_code' => 400, + ]), + ]); + + $store = new Store($mockHttpClient, 'http://127.0.0.1:9308', 'bar', 'random'); + + $this->expectException(ClientException::class); + $this->expectExceptionMessage('HTTP 400 returned for "http://127.0.0.1:9308/search".'); + $this->expectExceptionCode(400); + $store->query(new Vector([0.1, 0.2, 0.3])); + } + + public function testStoreCanQuery() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse([ + 'took' => 0, + 'timed_out' => false, + 'hits' => [ + 'total' => 1, + 'total_relation' => 'eq', + 'hits' => [ + [ + '_id' => 1, + '_score' => 1, + '_knn_dist' => 0.12345678, + '_source' => [ + 'uuid' => Uuid::v7()->toRfc4122(), + 'random' => [0.1, 0.2, 0.3], + 'metadata' => [ + 'foo' => 'bar', + ], + ], + ], + ], + ], + ], [ + 'http_code' => 200, + ]), + ]); + + $store = new Store($httpClient, 'http://127.0.0.1:9308', 'bar', 'random'); + $documents = $store->query(new Vector([0.1, 0.2, 0.3])); + + $this->assertCount(1, $documents); + $this->assertSame(1, $httpClient->getRequestsCount()); + } +}