diff --git a/src/ai-bundle/config/options.php b/src/ai-bundle/config/options.php index a344f643..ef6505b1 100644 --- a/src/ai-bundle/config/options.php +++ b/src/ai-bundle/config/options.php @@ -15,6 +15,7 @@ use MongoDB\Client as MongoDbClient; use Probots\Pinecone\Client as PineconeClient; use Symfony\AI\Platform\PlatformInterface; +use Symfony\AI\Store\Bridge\Redis\Distance; use Symfony\AI\Store\StoreInterface; return static function (DefinitionConfigurator $configurator): void { @@ -252,10 +253,30 @@ ->end() ->end() ->end() + ->arrayNode('redis') + ->normalizeKeys(false) + ->useAttributeAsKey('name') + ->arrayPrototype() + ->children() + ->variableNode('connection_parameters') + ->info('see https://github.com/phpredis/phpredis?tab=readme-ov-file#example-1') + ->isRequired() + ->cannotBeEmpty() + ->end() + ->scalarNode('index_name')->isRequired()->cannotBeEmpty()->end() + ->scalarNode('key_prefix')->defaultValue('vector:')->end() + ->enumNode('distance') + ->info('Distance metric to use for vector similarity search') + ->values(Distance::cases()) + ->defaultValue(Distance::Cosine) + ->end() + ->end() + ->end() + ->end() ->arrayNode('surreal_db') ->normalizeKeys(false) ->useAttributeAsKey('name') - ->arrayPrototype() + ->arrayPrototype() ->children() ->scalarNode('endpoint')->cannotBeEmpty()->end() ->scalarNode('username')->cannotBeEmpty()->end() diff --git a/src/ai-bundle/src/AiBundle.php b/src/ai-bundle/src/AiBundle.php index 4230044a..55f25314 100644 --- a/src/ai-bundle/src/AiBundle.php +++ b/src/ai-bundle/src/AiBundle.php @@ -45,6 +45,7 @@ use Symfony\AI\Store\Bridge\Neo4j\Store as Neo4jStore; use Symfony\AI\Store\Bridge\Pinecone\Store as PineconeStore; use Symfony\AI\Store\Bridge\Qdrant\Store as QdrantStore; +use Symfony\AI\Store\Bridge\Redis\Store as RedisStore; use Symfony\AI\Store\Bridge\SurrealDb\Store as SurrealDbStore; use Symfony\AI\Store\Document\Vectorizer; use Symfony\AI\Store\Indexer; @@ -638,6 +639,28 @@ private function processStoreConfig(string $type, array $stores, ContainerBuilde } } + if ('redis' === $type) { + foreach ($stores as $name => $store) { + $connectionDefinition = new Definition(\Redis::class); + $connectionDefinition->setArguments([$store['connection_parameters']]); + + $arguments = [ + $connectionDefinition, + $store['index_name'], + $store['key_prefix'], + $store['distance'], + ]; + + $definition = new Definition(RedisStore::class); + $definition + ->addTag('ai.store') + ->setArguments($arguments) + ; + + $container->setDefinition('ai.store.'.$type.'.'.$name, $definition); + } + } + if ('surreal_db' === $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 1e6d4131..492e8f08 100644 --- a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php +++ b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php @@ -259,6 +259,15 @@ private function getFullConfig(): array 'distance' => 'Cosine', ], ], + 'redis' => [ + 'my_redis_store' => [ + 'connection_parameters' => [ + 'host' => '1.2.3.4', + 'port' => 6379, + ], + 'index_name' => 'my_vector_index', + ], + ], 'surreal_db' => [ 'my_surreal_db_store' => [ 'endpoint' => 'http://127.0.0.1:8000', diff --git a/src/store/CHANGELOG.md b/src/store/CHANGELOG.md index 0fa347f2..7f087562 100644 --- a/src/store/CHANGELOG.md +++ b/src/store/CHANGELOG.md @@ -45,6 +45,7 @@ CHANGELOG - Qdrant - SurrealDB - Neo4j + - Redis * Add Retrieval Augmented Generation (RAG) support: - Document embedding storage - Similarity search for relevant documents diff --git a/src/store/composer.json b/src/store/composer.json index 9b89bb86..b314d2b6 100644 --- a/src/store/composer.json +++ b/src/store/composer.json @@ -32,6 +32,7 @@ "symfony/ai-platform": "@dev", "symfony/clock": "^6.4 || ^7.1", "symfony/http-client": "^6.4 || ^7.1", + "symfony/polyfill-php83": "^1.32", "symfony/uid": "^6.4 || ^7.1" }, "require-dev": { diff --git a/src/store/src/Bridge/Redis/Distance.php b/src/store/src/Bridge/Redis/Distance.php new file mode 100644 index 00000000..60591af4 --- /dev/null +++ b/src/store/src/Bridge/Redis/Distance.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Store\Bridge\Redis; + +use OskarStark\Enum\Trait\Comparable; + +/** + * @author Grégoire Pineau + */ +enum Distance: string +{ + use Comparable; + + case Cosine = 'COSINE'; + case L2 = 'L2'; + case Ip = 'IP'; + + public function getRedisMetric(): string + { + return $this->value; + } +} diff --git a/src/store/src/Bridge/Redis/Store.php b/src/store/src/Bridge/Redis/Store.php new file mode 100644 index 00000000..ca8ae192 --- /dev/null +++ b/src/store/src/Bridge/Redis/Store.php @@ -0,0 +1,173 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Store\Bridge\Redis; + +use Symfony\AI\Platform\Vector\Vector; +use Symfony\AI\Platform\Vector\VectorInterface; +use Symfony\AI\Store\Document\Metadata; +use Symfony\AI\Store\Document\VectorDocument; +use Symfony\AI\Store\Exception\RuntimeException; +use Symfony\AI\Store\InitializableStoreInterface; +use Symfony\AI\Store\VectorStoreInterface; +use Symfony\Component\Uid\Uuid; + +/** + * @author Grégoire Pineau + */ +class Store implements VectorStoreInterface, InitializableStoreInterface +{ + public function __construct( + private readonly \Redis $redis, + private readonly string $indexName, + private readonly string $keyPrefix = 'vector:', + private readonly Distance $distance = Distance::Cosine, + ) { + } + + /** + * @param array{vector_size?: positive-int, index_method?: string, extra_schema?: list} $options + * + * - For Mistral: ['vector_size' => 1024] + * - For OpenAI: ['vector_size' => 1536] + * - For Gemini: ['vector_size' => 3072] (default) + * - For other models: adjust vector_size accordingly + */ + public function initialize(array $options = []): void + { + $vectorSize = $options['vector_size'] ?? 3072; + $indexMethod = $options['index_method'] ?? 'FLAT'; // Or 'HNSW' for approximate search + $distanceMetric = $this->distance->getRedisMetric(); + $extraSchema = $options['extra_schema'] ?? []; + + // Create the index with vector field for JSON documents + try { + $this->redis->rawCommand( + 'FT.CREATE', $this->indexName, 'ON', 'JSON', + 'PREFIX', '1', $this->keyPrefix, + 'SCHEMA', + '$.id', 'AS', 'id', 'TEXT', + // '$.metadata', 'AS', 'metadata', 'TEXT', + '$.embedding', 'AS', 'embedding', 'VECTOR', $indexMethod, '6', 'TYPE', 'FLOAT32', 'DIM', $vectorSize, 'DISTANCE_METRIC', $distanceMetric, + ...$extraSchema, + ); + } catch (\RedisException $e) { + // Index might already exist, check if it's a "already exists" error + if (!str_contains($e->getMessage(), 'Index already exists')) { + throw new RuntimeException(\sprintf('Failed to create Redis index: "%s".', $e->getMessage()), 0, $e); + } + } + } + + public function add(VectorDocument ...$documents): void + { + $pipeline = $this->redis->multi(\Redis::PIPELINE); + + foreach ($documents as $document) { + $key = $this->keyPrefix.$document->id->toRfc4122(); + $data = [ + 'id' => $document->id->toRfc4122(), + 'metadata' => $document->metadata->getArrayCopy(), + 'embedding' => $document->vector->getData(), + ]; + + $pipeline->rawCommand('JSON.SET', $key, '$', json_encode($data, \JSON_THROW_ON_ERROR)); + } + + $pipeline->exec(); + } + + /** + * @param array $options + * + * @return VectorDocument[] + */ + public function query(Vector $vector, array $options = []): array + { + $limit = $options['limit'] ?? 5; + $maxScore = $options['maxScore'] ?? null; + $whereFilter = $options['where'] ?? '*'; + + $query = "({$whereFilter}) => [KNN {$limit} @embedding \$query_vector AS vector_score]"; + + try { + $results = $this->redis->rawCommand( + 'FT.SEARCH', + $this->indexName, + $query, + 'PARAMS', 2, 'query_vector', $this->toRedisVector($vector), + 'RETURN', 4, '$.id', '$.metadata', '$.embedding', 'vector_score', + 'SORTBY', 'vector_score', 'ASC', + 'LIMIT', 0, $limit, + 'DIALECT', 2 + ); + } catch (\RedisException $e) { + throw new RuntimeException(\sprintf('Failed to execute query: "%s".', $e->getMessage()), 0, $e); + } + + if (!\is_array($results) || \count($results) < 2) { + return []; + } + + $documents = []; + $numResults = $results[0]; + + // Parse results (skip first element which is the count) + for ($i = 1; $i <= $numResults; $i += 2) { + // $docKey = $results[$i]; + $docData = $results[$i + 1]; + + // Convert flat array to associative array + $data = []; + for ($j = 0; $j < \count($docData); $j += 2) { + $fieldName = $docData[$j]; + $fieldValue = $docData[$j + 1] ?? null; + + if (\is_string($fieldValue) && json_validate($fieldValue)) { + $fieldValue = json_decode($fieldValue, true); + } + + $data[$fieldName] = $fieldValue; + } + + if (!isset($data['$.id'], $data['vector_score'])) { + continue; + } + + $score = (float) $data['vector_score']; + + // Apply max score filter if specified + if (null !== $maxScore && $score > $maxScore) { + continue; + } + + $documents[] = new VectorDocument( + id: Uuid::fromString($data['$.id']), + vector: new Vector($data['$.embedding'] ?? []), + metadata: new Metadata($data['$.metadata'] ?? []), + score: $score, + ); + } + + return $documents; + } + + private function toRedisVector(VectorInterface $vector): string + { + $data = $vector->getData(); + $bytes = ''; + foreach ($data as $value) { + $bytes .= pack('f', $value); + } + + return $bytes; + } +} diff --git a/src/store/tests/Bridge/Redis/StoreTest.php b/src/store/tests/Bridge/Redis/StoreTest.php new file mode 100644 index 00000000..0fc02661 --- /dev/null +++ b/src/store/tests/Bridge/Redis/StoreTest.php @@ -0,0 +1,520 @@ + + * + * 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\Redis; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Vector\Vector; +use Symfony\AI\Store\Bridge\Redis\Distance; +use Symfony\AI\Store\Bridge\Redis\Store; +use Symfony\AI\Store\Document\Metadata; +use Symfony\AI\Store\Document\VectorDocument; +use Symfony\AI\Store\Exception\RuntimeException; +use Symfony\Component\Uid\Uuid; + +#[CoversClass(Store::class)] +final class StoreTest extends TestCase +{ + public function testAddSingleDocument() + { + $redis = $this->createMock(\Redis::class); + $pipeline = $this->createMock(\Redis::class); + + $store = new Store($redis, 'test_index', 'vector:'); + + $redis->expects($this->once()) + ->method('multi') + ->with(\Redis::PIPELINE) + ->willReturn($pipeline); + + $uuid = Uuid::v4(); + $expectedKey = 'vector:'.$uuid->toRfc4122(); + $expectedData = [ + 'id' => $uuid->toRfc4122(), + 'metadata' => ['title' => 'Test Document'], + 'embedding' => [0.1, 0.2, 0.3], + ]; + + $pipeline->expects($this->once()) + ->method('rawCommand') + ->with('JSON.SET', $expectedKey, '$', json_encode($expectedData, \JSON_THROW_ON_ERROR)); + + $pipeline->expects($this->once()) + ->method('exec'); + + $document = new VectorDocument($uuid, new Vector([0.1, 0.2, 0.3]), new Metadata(['title' => 'Test Document'])); + $store->add($document); + } + + public function testAddMultipleDocuments() + { + $redis = $this->createMock(\Redis::class); + $pipeline = $this->createMock(\Redis::class); + + $store = new Store($redis, 'test_index', 'vector:'); + + $redis->expects($this->once()) + ->method('multi') + ->with(\Redis::PIPELINE) + ->willReturn($pipeline); + + $uuid1 = Uuid::v4(); + $uuid2 = Uuid::v4(); + + $pipeline->expects($this->exactly(2)) + ->method('rawCommand') + ->willReturnCallback(function (string $command, string $key, string $path, string $data): void { + static $callCount = 0; + ++$callCount; + + $this->assertSame('JSON.SET', $command); + $this->assertSame('$', $path); + + $decodedData = json_decode($data, true); + if (1 === $callCount) { + $this->assertSame([], $decodedData['metadata']); + $this->assertSame([0.1, 0.2, 0.3], $decodedData['embedding']); + } else { + $this->assertSame(['title' => 'Second'], $decodedData['metadata']); + $this->assertSame([0.4, 0.5, 0.6], $decodedData['embedding']); + } + }); + + $pipeline->expects($this->once()) + ->method('exec'); + + $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'])); + + $store->add($document1, $document2); + } + + public function testQueryWithoutMaxScore() + { + $redis = $this->createMock(\Redis::class); + $store = new Store($redis, 'test_index', 'vector:'); + + $uuid = Uuid::v4(); + + $redis->expects($this->once()) + ->method('rawCommand') + ->with( + 'FT.SEARCH', + 'test_index', + '(*) => [KNN 5 @embedding $query_vector AS vector_score]', + 'PARAMS', 2, 'query_vector', $this->isType('string'), + 'RETURN', 4, '$.id', '$.metadata', '$.embedding', 'vector_score', + 'SORTBY', 'vector_score', 'ASC', + 'LIMIT', 0, 5, + 'DIALECT', 2 + ) + ->willReturn([ + 1, // number of results + 'vector:'.$uuid->toRfc4122(), // document key + [ + '$.id', $uuid->toRfc4122(), + '$.metadata', json_encode(['title' => 'Test Document']), + '$.embedding', json_encode([0.1, 0.2, 0.3]), + 'vector_score', '0.95', + ], + ]); + + $results = $store->query(new Vector([0.1, 0.2, 0.3])); + + $this->assertCount(1, $results); + $this->assertInstanceOf(VectorDocument::class, $results[0]); + $this->assertEquals($uuid, $results[0]->id); + $this->assertSame(0.95, $results[0]->score); + $this->assertSame(['title' => 'Test Document'], $results[0]->metadata->getArrayCopy()); + } + + public function testQueryChangedDistanceMethodWithoutMaxScore() + { + $redis = $this->createMock(\Redis::class); + $store = new Store($redis, 'test_index', 'vector:', Distance::L2); + + $uuid = Uuid::v4(); + + $redis->expects($this->once()) + ->method('rawCommand') + ->with( + 'FT.SEARCH', + 'test_index', + '(*) => [KNN 5 @embedding $query_vector AS vector_score]', + 'PARAMS', 2, 'query_vector', $this->isType('string'), + 'RETURN', 4, '$.id', '$.metadata', '$.embedding', 'vector_score', + 'SORTBY', 'vector_score', 'ASC', + 'LIMIT', 0, 5, + 'DIALECT', 2 + ) + ->willReturn([ + 1, + 'vector:'.$uuid->toRfc4122(), + [ + '$.id', $uuid->toRfc4122(), + '$.metadata', json_encode(['title' => 'Test Document']), + '$.embedding', json_encode([0.1, 0.2, 0.3]), + 'vector_score', '0.95', + ], + ]); + + $results = $store->query(new Vector([0.1, 0.2, 0.3])); + + $this->assertCount(1, $results); + $this->assertInstanceOf(VectorDocument::class, $results[0]); + $this->assertEquals($uuid, $results[0]->id); + $this->assertSame(0.95, $results[0]->score); + $this->assertSame(['title' => 'Test Document'], $results[0]->metadata->getArrayCopy()); + } + + public function testQueryWithMaxScore() + { + $redis = $this->createMock(\Redis::class); + $store = new Store($redis, 'test_index', 'vector:'); + + $redis->expects($this->once()) + ->method('rawCommand') + ->willReturn([ + 1, + 'vector:some-uuid', + [ + '$.id', 'some-uuid', + '$.metadata', json_encode(['title' => 'Test Document']), + '$.embedding', json_encode([0.1, 0.2, 0.3]), + 'vector_score', '0.95', // Score higher than maxScore, should be filtered + ], + ]); + + $results = $store->query(new Vector([0.1, 0.2, 0.3]), ['maxScore' => 0.8]); + + $this->assertCount(0, $results); // Should be filtered out due to maxScore + } + + public function testQueryWithCustomLimit() + { + $redis = $this->createMock(\Redis::class); + $store = new Store($redis, 'test_index', 'vector:'); + + $redis->expects($this->once()) + ->method('rawCommand') + ->with( + 'FT.SEARCH', + 'test_index', + '(*) => [KNN 10 @embedding $query_vector AS vector_score]', + 'PARAMS', 2, 'query_vector', $this->isType('string'), + 'RETURN', 4, '$.id', '$.metadata', '$.embedding', 'vector_score', + 'SORTBY', 'vector_score', 'ASC', + 'LIMIT', 0, 10, + 'DIALECT', 2 + ) + ->willReturn([0]); // No results + + $results = $store->query(new Vector([0.7, 0.8, 0.9]), ['limit' => 10]); + + $this->assertCount(0, $results); + } + + public function testQueryWithCustomWhereExpression() + { + $redis = $this->createMock(\Redis::class); + $store = new Store($redis, 'test_index', 'vector:'); + + $redis->expects($this->once()) + ->method('rawCommand') + ->with( + 'FT.SEARCH', + 'test_index', + '(@metadata_category:products) => [KNN 5 @embedding $query_vector AS vector_score]', + 'PARAMS', 2, 'query_vector', $this->isType('string'), + 'RETURN', 4, '$.id', '$.metadata', '$.embedding', 'vector_score', + 'SORTBY', 'vector_score', 'ASC', + 'LIMIT', 0, 5, + 'DIALECT', 2 + ) + ->willReturn([0]); // No results + + $results = $store->query(new Vector([0.1, 0.2, 0.3]), ['where' => '@metadata_category:products']); + + $this->assertCount(0, $results); + } + + public function testQueryWithCustomWhereExpressionAndMaxScore() + { + $redis = $this->createMock(\Redis::class); + $store = new Store($redis, 'test_index', 'vector:'); + + $redis->expects($this->once()) + ->method('rawCommand') + ->with( + 'FT.SEARCH', + 'test_index', + '(@metadata_active:true) => [KNN 5 @embedding $query_vector AS vector_score]', + 'PARAMS', 2, 'query_vector', $this->isType('string'), + 'RETURN', 4, '$.id', '$.metadata', '$.embedding', 'vector_score', + 'SORTBY', 'vector_score', 'ASC', + 'LIMIT', 0, 5, + 'DIALECT', 2 + ) + ->willReturn([ + 1, + 'vector:some-uuid', + [ + '$.id', 'some-uuid', + '$.metadata', json_encode(['active' => true]), + '$.embedding', json_encode([0.1, 0.2, 0.3]), + 'vector_score', '0.95', // Higher than maxScore + ], + ]); + + $results = $store->query(new Vector([0.1, 0.2, 0.3]), [ + 'where' => '@metadata_active:true', + 'maxScore' => 0.8, + ]); + + $this->assertCount(0, $results); + } + + public function testQueryWithNullMetadata() + { + $redis = $this->createMock(\Redis::class); + $store = new Store($redis, 'test_index', 'vector:'); + + $uuid = Uuid::v4(); + + $redis->expects($this->once()) + ->method('rawCommand') + ->willReturn([ + 1, + 'vector:'.$uuid->toRfc4122(), + [ + '$.id', $uuid->toRfc4122(), + '$.metadata', null, // Null metadata + '$.embedding', json_encode([0.1, 0.2, 0.3]), + 'vector_score', '0.85', + ], + ]); + + $results = $store->query(new Vector([0.1, 0.2, 0.3])); + + $this->assertCount(1, $results); + $this->assertSame([], $results[0]->metadata->getArrayCopy()); + } + + public function testQueryFailureThrowsRuntimeException() + { + $redis = $this->createMock(\Redis::class); + $store = new Store($redis, 'test_index', 'vector:'); + + $redis->expects($this->once()) + ->method('rawCommand') + ->willThrowException(new \RedisException('Search failed')); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Failed to execute query: "Search failed".'); + + $store->query(new Vector([0.1, 0.2, 0.3])); + } + + public function testInitialize() + { + $redis = $this->createMock(\Redis::class); + $store = new Store($redis, 'test_index', 'vector:'); + + $redis->expects($this->once()) + ->method('rawCommand') + ->with( + 'FT.CREATE', 'test_index', 'ON', 'JSON', + 'PREFIX', '1', 'vector:', + 'SCHEMA', + '$.id', 'AS', 'id', 'TEXT', + '$.embedding', 'AS', 'embedding', 'VECTOR', 'FLAT', '6', 'TYPE', 'FLOAT32', 'DIM', 3072, 'DISTANCE_METRIC', 'COSINE' + ); + + $store->initialize(); + } + + public function testInitializeWithCustomVectorSize() + { + $redis = $this->createMock(\Redis::class); + $store = new Store($redis, 'test_index', 'vector:'); + + $redis->expects($this->once()) + ->method('rawCommand') + ->with( + 'FT.CREATE', 'test_index', 'ON', 'JSON', + 'PREFIX', '1', 'vector:', + 'SCHEMA', + '$.id', 'AS', 'id', 'TEXT', + '$.embedding', 'AS', 'embedding', 'VECTOR', 'FLAT', '6', 'TYPE', 'FLOAT32', 'DIM', 768, 'DISTANCE_METRIC', 'COSINE' + ); + + $store->initialize(['vector_size' => 768]); + } + + public function testInitializeWithCustomIndexMethod() + { + $redis = $this->createMock(\Redis::class); + $store = new Store($redis, 'test_index', 'vector:', Distance::L2); + + $redis->expects($this->once()) + ->method('rawCommand') + ->with( + 'FT.CREATE', 'test_index', 'ON', 'JSON', + 'PREFIX', '1', 'vector:', + 'SCHEMA', + '$.id', 'AS', 'id', 'TEXT', + '$.embedding', 'AS', 'embedding', 'VECTOR', 'HNSW', '6', 'TYPE', 'FLOAT32', 'DIM', 1024, 'DISTANCE_METRIC', 'L2' + ); + + $store->initialize([ + 'vector_size' => 1024, + 'index_method' => 'HNSW', + ]); + } + + public function testInitializeWithExtraSchema() + { + $redis = $this->createMock(\Redis::class); + $store = new Store($redis, 'test_index', 'vector:'); + + $redis->expects($this->once()) + ->method('rawCommand') + ->with( + 'FT.CREATE', 'test_index', 'ON', 'JSON', + 'PREFIX', '1', 'vector:', + 'SCHEMA', + '$.id', 'AS', 'id', 'TEXT', + '$.embedding', 'AS', 'embedding', 'VECTOR', 'FLAT', '6', 'TYPE', 'FLOAT32', 'DIM', 3072, 'DISTANCE_METRIC', 'COSINE', + '$.metadata.title', 'AS', 'title', 'TEXT' + ); + + $store->initialize([ + 'extra_schema' => ['$.metadata.title', 'AS', 'title', 'TEXT'], + ]); + } + + public function testInitializeIndexAlreadyExists() + { + $redis = $this->createMock(\Redis::class); + $store = new Store($redis, 'test_index', 'vector:'); + + $redis->expects($this->once()) + ->method('rawCommand') + ->willThrowException(new \RedisException('Index already exists')); + + // Should not throw an exception when index already exists + $store->initialize(); + } + + public function testInitializeFailureThrowsRuntimeException() + { + $redis = $this->createMock(\Redis::class); + $store = new Store($redis, 'test_index', 'vector:'); + + $redis->expects($this->once()) + ->method('rawCommand') + ->willThrowException(new \RedisException('Connection failed')); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Failed to create Redis index: "Connection failed".'); + + $store->initialize(); + } + + public function testToRedisVectorConversion() + { + $redis = $this->createMock(\Redis::class); + $store = new Store($redis, 'test_index', 'vector:'); + + // Test the private toRedisVector method indirectly through query + $redis->expects($this->once()) + ->method('rawCommand') + ->with( + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + 'query_vector', + $this->callback(function ($vectorBytes) { + // Vector [0.1, 0.2, 0.3] packed as 32-bit floats + $expected = pack('f', 0.1).pack('f', 0.2).pack('f', 0.3); + + return $vectorBytes === $expected; + }), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything() + ) + ->willReturn([0]); + + $store->query(new Vector([0.1, 0.2, 0.3])); + } + + public function testQueryEmptyResults() + { + $redis = $this->createMock(\Redis::class); + $store = new Store($redis, 'test_index', 'vector:'); + + $redis->expects($this->once()) + ->method('rawCommand') + ->willReturn([0]); // No results + + $results = $store->query(new Vector([0.1, 0.2, 0.3])); + + $this->assertCount(0, $results); + } + + public function testQueryInvalidResults() + { + $redis = $this->createMock(\Redis::class); + $store = new Store($redis, 'test_index', 'vector:'); + + $redis->expects($this->once()) + ->method('rawCommand') + ->willReturn(null); // Invalid results + + $results = $store->query(new Vector([0.1, 0.2, 0.3])); + + $this->assertCount(0, $results); + } + + public function testQueryMissingRequiredFields() + { + $redis = $this->createMock(\Redis::class); + $store = new Store($redis, 'test_index', 'vector:'); + + $redis->expects($this->once()) + ->method('rawCommand') + ->willReturn([ + 1, + 'vector:some-uuid', + [ + '$.metadata', json_encode(['title' => 'Test Document']), + '$.embedding', json_encode([0.1, 0.2, 0.3]), + // Missing $.id and vector_score + ], + ]); + + $results = $store->query(new Vector([0.1, 0.2, 0.3])); + + $this->assertCount(0, $results); // Should skip documents without required fields + } +}