Skip to content

Commit c54e2e0

Browse files
committed
[Store] Add support for Redis
1 parent 345cebe commit c54e2e0

File tree

8 files changed

+780
-1
lines changed

8 files changed

+780
-1
lines changed

src/ai-bundle/config/options.php

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use MongoDB\Client as MongoDbClient;
1616
use Probots\Pinecone\Client as PineconeClient;
1717
use Symfony\AI\Platform\PlatformInterface;
18+
use Symfony\AI\Store\Bridge\Redis\Distance;
1819
use Symfony\AI\Store\StoreInterface;
1920

2021
return static function (DefinitionConfigurator $configurator): void {
@@ -252,10 +253,30 @@
252253
->end()
253254
->end()
254255
->end()
256+
->arrayNode('redis')
257+
->normalizeKeys(false)
258+
->useAttributeAsKey('name')
259+
->arrayPrototype()
260+
->children()
261+
->variableNode('connection_parameters')
262+
->info('see https://github.com/phpredis/phpredis?tab=readme-ov-file#example-1')
263+
->isRequired()
264+
->cannotBeEmpty()
265+
->end()
266+
->scalarNode('index_name')->isRequired()->cannotBeEmpty()->end()
267+
->scalarNode('key_prefix')->defaultValue('vector:')->end()
268+
->enumNode('distance')
269+
->info('Distance metric to use for vector similarity search')
270+
->values(Distance::cases())
271+
->defaultValue(Distance::Cosine)
272+
->end()
273+
->end()
274+
->end()
275+
->end()
255276
->arrayNode('surreal_db')
256277
->normalizeKeys(false)
257278
->useAttributeAsKey('name')
258-
->arrayPrototype()
279+
->arrayPrototype()
259280
->children()
260281
->scalarNode('endpoint')->cannotBeEmpty()->end()
261282
->scalarNode('username')->cannotBeEmpty()->end()

src/ai-bundle/src/AiBundle.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
use Symfony\AI\Store\Bridge\Neo4j\Store as Neo4jStore;
4646
use Symfony\AI\Store\Bridge\Pinecone\Store as PineconeStore;
4747
use Symfony\AI\Store\Bridge\Qdrant\Store as QdrantStore;
48+
use Symfony\AI\Store\Bridge\Redis\Store as RedisStore;
4849
use Symfony\AI\Store\Bridge\SurrealDb\Store as SurrealDbStore;
4950
use Symfony\AI\Store\Document\Vectorizer;
5051
use Symfony\AI\Store\Indexer;
@@ -638,6 +639,28 @@ private function processStoreConfig(string $type, array $stores, ContainerBuilde
638639
}
639640
}
640641

642+
if ('redis' === $type) {
643+
foreach ($stores as $name => $store) {
644+
$connectionDefinition = new Definition(\Redis::class);
645+
$connectionDefinition->setArguments([$store['connection_parameters']]);
646+
647+
$arguments = [
648+
$connectionDefinition,
649+
$store['index_name'],
650+
$store['key_prefix'],
651+
$store['distance'],
652+
];
653+
654+
$definition = new Definition(RedisStore::class);
655+
$definition
656+
->addTag('ai.store')
657+
->setArguments($arguments)
658+
;
659+
660+
$container->setDefinition('ai.store.'.$type.'.'.$name, $definition);
661+
}
662+
}
663+
641664
if ('surreal_db' === $type) {
642665
foreach ($stores as $name => $store) {
643666
$arguments = [

src/ai-bundle/tests/DependencyInjection/AiBundleTest.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,15 @@ private function getFullConfig(): array
259259
'distance' => 'Cosine',
260260
],
261261
],
262+
'redis' => [
263+
'my_redis_store' => [
264+
'connection_parameters' => [
265+
'host' => '1.2.3.4',
266+
'port' => 6379,
267+
],
268+
'index_name' => 'my_vector_index',
269+
],
270+
],
262271
'surreal_db' => [
263272
'my_surreal_db_store' => [
264273
'endpoint' => 'http://127.0.0.1:8000',

src/store/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ CHANGELOG
4545
- Qdrant
4646
- SurrealDB
4747
- Neo4j
48+
- Redis
4849
* Add Retrieval Augmented Generation (RAG) support:
4950
- Document embedding storage
5051
- Similarity search for relevant documents

src/store/composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"symfony/ai-platform": "@dev",
3333
"symfony/clock": "^6.4 || ^7.1",
3434
"symfony/http-client": "^6.4 || ^7.1",
35+
"symfony/polyfill-php83": "^1.32",
3536
"symfony/uid": "^6.4 || ^7.1"
3637
},
3738
"require-dev": {
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Store\Bridge\Redis;
13+
14+
use OskarStark\Enum\Trait\Comparable;
15+
16+
/**
17+
* @author Grégoire Pineau <[email protected]>
18+
*/
19+
enum Distance: string
20+
{
21+
use Comparable;
22+
23+
case Cosine = 'COSINE';
24+
case L2 = 'L2';
25+
case Ip = 'IP';
26+
27+
public function getRedisMetric(): string
28+
{
29+
return $this->value;
30+
}
31+
}

src/store/src/Bridge/Redis/Store.php

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Store\Bridge\Redis;
13+
14+
use Symfony\AI\Platform\Vector\Vector;
15+
use Symfony\AI\Platform\Vector\VectorInterface;
16+
use Symfony\AI\Store\Document\Metadata;
17+
use Symfony\AI\Store\Document\VectorDocument;
18+
use Symfony\AI\Store\Exception\RuntimeException;
19+
use Symfony\AI\Store\InitializableStoreInterface;
20+
use Symfony\AI\Store\VectorStoreInterface;
21+
use Symfony\Component\Uid\Uuid;
22+
23+
/**
24+
* @author Grégoire Pineau <[email protected]>
25+
*/
26+
class Store implements VectorStoreInterface, InitializableStoreInterface
27+
{
28+
public function __construct(
29+
private readonly \Redis $redis,
30+
private readonly string $indexName,
31+
private readonly string $keyPrefix = 'vector:',
32+
private readonly Distance $distance = Distance::Cosine,
33+
) {
34+
}
35+
36+
/**
37+
* @param array{vector_size?: positive-int, index_method?: string, extra_schema?: list<string>} $options
38+
*
39+
* - For Mistral: ['vector_size' => 1024]
40+
* - For OpenAI: ['vector_size' => 1536]
41+
* - For Gemini: ['vector_size' => 3072] (default)
42+
* - For other models: adjust vector_size accordingly
43+
*/
44+
public function initialize(array $options = []): void
45+
{
46+
$vectorSize = $options['vector_size'] ?? 3072;
47+
$indexMethod = $options['index_method'] ?? 'FLAT'; // Or 'HNSW' for approximate search
48+
$distanceMetric = $this->distance->getRedisMetric();
49+
$extraSchema = $options['extra_schema'] ?? [];
50+
51+
// Create the index with vector field for JSON documents
52+
try {
53+
$this->redis->rawCommand(
54+
'FT.CREATE', $this->indexName, 'ON', 'JSON',
55+
'PREFIX', '1', $this->keyPrefix,
56+
'SCHEMA',
57+
'$.id', 'AS', 'id', 'TEXT',
58+
// '$.metadata', 'AS', 'metadata', 'TEXT',
59+
'$.embedding', 'AS', 'embedding', 'VECTOR', $indexMethod, '6', 'TYPE', 'FLOAT32', 'DIM', $vectorSize, 'DISTANCE_METRIC', $distanceMetric,
60+
...$extraSchema,
61+
);
62+
} catch (\RedisException $e) {
63+
// Index might already exist, check if it's a "already exists" error
64+
if (!str_contains($e->getMessage(), 'Index already exists')) {
65+
throw new RuntimeException(\sprintf('Failed to create Redis index: "%s".', $e->getMessage()), 0, $e);
66+
}
67+
}
68+
}
69+
70+
public function add(VectorDocument ...$documents): void
71+
{
72+
$pipeline = $this->redis->multi(\Redis::PIPELINE);
73+
74+
foreach ($documents as $document) {
75+
$key = $this->keyPrefix.$document->id->toRfc4122();
76+
$data = [
77+
'id' => $document->id->toRfc4122(),
78+
'metadata' => $document->metadata->getArrayCopy(),
79+
'embedding' => $document->vector->getData(),
80+
];
81+
82+
$pipeline->rawCommand('JSON.SET', $key, '$', json_encode($data, \JSON_THROW_ON_ERROR));
83+
}
84+
85+
$pipeline->exec();
86+
}
87+
88+
/**
89+
* @param array<string, mixed> $options
90+
*
91+
* @return VectorDocument[]
92+
*/
93+
public function query(Vector $vector, array $options = []): array
94+
{
95+
$limit = $options['limit'] ?? 5;
96+
$maxScore = $options['maxScore'] ?? null;
97+
$whereFilter = $options['where'] ?? '*';
98+
99+
$query = "({$whereFilter}) => [KNN {$limit} @embedding \$query_vector AS vector_score]";
100+
101+
try {
102+
$results = $this->redis->rawCommand(
103+
'FT.SEARCH',
104+
$this->indexName,
105+
$query,
106+
'PARAMS', 2, 'query_vector', $this->toRedisVector($vector),
107+
'RETURN', 4, '$.id', '$.metadata', '$.embedding', 'vector_score',
108+
'SORTBY', 'vector_score', 'ASC',
109+
'LIMIT', 0, $limit,
110+
'DIALECT', 2
111+
);
112+
} catch (\RedisException $e) {
113+
throw new RuntimeException(\sprintf('Failed to execute query: "%s".', $e->getMessage()), 0, $e);
114+
}
115+
116+
if (!\is_array($results) || \count($results) < 2) {
117+
return [];
118+
}
119+
120+
$documents = [];
121+
$numResults = $results[0];
122+
123+
// Parse results (skip first element which is the count)
124+
for ($i = 1; $i <= $numResults; $i += 2) {
125+
// $docKey = $results[$i];
126+
$docData = $results[$i + 1];
127+
128+
// Convert flat array to associative array
129+
$data = [];
130+
for ($j = 0; $j < \count($docData); $j += 2) {
131+
$fieldName = $docData[$j];
132+
$fieldValue = $docData[$j + 1] ?? null;
133+
134+
if (\is_string($fieldValue) && json_validate($fieldValue)) {
135+
$fieldValue = json_decode($fieldValue, true);
136+
}
137+
138+
$data[$fieldName] = $fieldValue;
139+
}
140+
141+
if (!isset($data['$.id'], $data['vector_score'])) {
142+
continue;
143+
}
144+
145+
$score = (float) $data['vector_score'];
146+
147+
// Apply max score filter if specified
148+
if (null !== $maxScore && $score > $maxScore) {
149+
continue;
150+
}
151+
152+
$documents[] = new VectorDocument(
153+
id: Uuid::fromString($data['$.id']),
154+
vector: new Vector($data['$.embedding'] ?? []),
155+
metadata: new Metadata($data['$.metadata'] ?? []),
156+
score: $score,
157+
);
158+
}
159+
160+
return $documents;
161+
}
162+
163+
private function toRedisVector(VectorInterface $vector): string
164+
{
165+
$data = $vector->getData();
166+
$bytes = '';
167+
foreach ($data as $value) {
168+
$bytes .= pack('f', $value);
169+
}
170+
171+
return $bytes;
172+
}
173+
}

0 commit comments

Comments
 (0)