Skip to content

Commit 19623bb

Browse files
committed
Add postgres_hybrid store config and tests
1 parent 954d44d commit 19623bb

File tree

6 files changed

+196
-73
lines changed

6 files changed

+196
-73
lines changed

examples/CLAUDE.md

Lines changed: 0 additions & 71 deletions
This file was deleted.

examples/rag/postgres-hybrid.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use Symfony\AI\Store\Document\Metadata;
1919
use Symfony\AI\Store\Document\TextDocument;
2020
use Symfony\AI\Store\Document\Vectorizer;
21+
use Symfony\AI\Store\Exception\RuntimeException;
2122
use Symfony\AI\Store\Indexer;
2223
use Symfony\Component\Uid\Uuid;
2324

@@ -32,7 +33,7 @@
3233
$pdo = $connection->getNativeConnection();
3334

3435
if (!$pdo instanceof PDO) {
35-
throw new RuntimeException('Unable to get native PDO connection from Doctrine DBAL');
36+
throw new RuntimeException('Unable to get native PDO connection from Doctrine DBAL.');
3637
}
3738

3839
$store = new HybridStore(

src/ai-bundle/config/options.php

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -738,6 +738,54 @@
738738
->end()
739739
->end()
740740
->end()
741+
->arrayNode('postgres_hybrid')
742+
->info('PostgreSQL Hybrid Search combining pgvector (semantic) and Full-Text Search (lexical) using RRF')
743+
->useAttributeAsKey('name')
744+
->arrayPrototype()
745+
->children()
746+
->stringNode('connection')->cannotBeEmpty()->end()
747+
->stringNode('dsn')->cannotBeEmpty()->end()
748+
->stringNode('username')->end()
749+
->stringNode('password')->end()
750+
->stringNode('table_name')->isRequired()->end()
751+
->stringNode('vector_field')->defaultValue('embedding')->end()
752+
->stringNode('content_field')->defaultValue('content')->end()
753+
->floatNode('semantic_ratio')
754+
->info('Ratio between semantic (vector) and keyword (FTS) search. 0.0 = pure FTS, 0.5 = balanced, 1.0 = pure semantic')
755+
->defaultValue(1.0)
756+
->min(0.0)
757+
->max(1.0)
758+
->end()
759+
->enumNode('distance')
760+
->info('Distance metric to use for vector similarity search')
761+
->enumFqcn(PostgresDistance::class)
762+
->defaultValue(PostgresDistance::L2)
763+
->end()
764+
->stringNode('language')
765+
->info('PostgreSQL text search configuration (e.g., "simple", "english", "french"). Default: "simple" (multilingual)')
766+
->defaultValue('simple')
767+
->end()
768+
->integerNode('rrf_k')
769+
->info('RRF (Reciprocal Rank Fusion) constant. Higher = more equal weighting. Default: 60 (Supabase)')
770+
->defaultValue(60)
771+
->min(1)
772+
->end()
773+
->floatNode('default_max_score')
774+
->info('Default maximum distance threshold for filtering results (optional)')
775+
->defaultNull()
776+
->end()
777+
->stringNode('dbal_connection')->cannotBeEmpty()->end()
778+
->end()
779+
->validate()
780+
->ifTrue(static fn ($v) => !isset($v['dsn']) && !isset($v['dbal_connection']) && !isset($v['connection']))
781+
->thenInvalid('Either "dsn", "dbal_connection", or "connection" must be configured.')
782+
->end()
783+
->validate()
784+
->ifTrue(static fn ($v) => (int) isset($v['dsn']) + (int) isset($v['dbal_connection']) + (int) isset($v['connection']) > 1)
785+
->thenInvalid('Only one of "dsn", "dbal_connection", or "connection" can be configured.')
786+
->end()
787+
->end()
788+
->end()
741789
->end()
742790
->end()
743791
->arrayNode('message_store')

src/ai-bundle/src/AiBundle.php

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
use Symfony\AI\Store\Bridge\MongoDb\Store as MongoDbStore;
8080
use Symfony\AI\Store\Bridge\Neo4j\Store as Neo4jStore;
8181
use Symfony\AI\Store\Bridge\Pinecone\Store as PineconeStore;
82+
use Symfony\AI\Store\Bridge\Postgres\HybridStore;
8283
use Symfony\AI\Store\Bridge\Postgres\Store as PostgresStore;
8384
use Symfony\AI\Store\Bridge\Qdrant\Store as QdrantStore;
8485
use Symfony\AI\Store\Bridge\Redis\Store as RedisStore;
@@ -1366,6 +1367,81 @@ private function processStoreConfig(string $type, array $stores, ContainerBuilde
13661367
}
13671368
}
13681369

1370+
if ('postgres_hybrid' === $type) {
1371+
foreach ($stores as $name => $store) {
1372+
$definition = new Definition(HybridStore::class);
1373+
1374+
// Handle connection (PDO service reference, DBAL connection, or DSN)
1375+
if (\array_key_exists('connection', $store)) {
1376+
// Direct PDO service reference
1377+
$serviceId = ltrim($store['connection'], '@');
1378+
$connection = new Reference($serviceId);
1379+
$arguments = [
1380+
$connection,
1381+
$store['table_name'],
1382+
];
1383+
} elseif (\array_key_exists('dbal_connection', $store)) {
1384+
// DBAL connection - extract native PDO
1385+
$connection = (new Definition(\PDO::class))
1386+
->setFactory([new Reference($store['dbal_connection']), 'getNativeConnection']);
1387+
$arguments = [
1388+
$connection,
1389+
$store['table_name'],
1390+
];
1391+
} else {
1392+
// Create new PDO instance from DSN
1393+
$pdo = new Definition(\PDO::class);
1394+
$pdo->setArguments([
1395+
$store['dsn'],
1396+
$store['username'] ?? null,
1397+
$store['password'] ?? null],
1398+
);
1399+
1400+
$arguments = [
1401+
$pdo,
1402+
$store['table_name'],
1403+
];
1404+
}
1405+
1406+
// Add optional parameters
1407+
if (\array_key_exists('vector_field', $store)) {
1408+
$arguments[2] = $store['vector_field'];
1409+
}
1410+
1411+
if (\array_key_exists('content_field', $store)) {
1412+
$arguments[3] = $store['content_field'];
1413+
}
1414+
1415+
if (\array_key_exists('semantic_ratio', $store)) {
1416+
$arguments[4] = $store['semantic_ratio'];
1417+
}
1418+
1419+
if (\array_key_exists('distance', $store)) {
1420+
$arguments[5] = $store['distance'];
1421+
}
1422+
1423+
if (\array_key_exists('language', $store)) {
1424+
$arguments[6] = $store['language'];
1425+
}
1426+
1427+
if (\array_key_exists('rrf_k', $store)) {
1428+
$arguments[7] = $store['rrf_k'];
1429+
}
1430+
1431+
if (\array_key_exists('default_max_score', $store)) {
1432+
$arguments[8] = $store['default_max_score'];
1433+
}
1434+
1435+
$definition
1436+
->addTag('ai.store')
1437+
->setArguments($arguments);
1438+
1439+
$container->setDefinition('ai.store.'.$type.'.'.$name, $definition);
1440+
$container->registerAliasForArgument('ai.store.'.$type.'.'.$name, StoreInterface::class, $name);
1441+
$container->registerAliasForArgument('ai.store.'.$type.'.'.$name, StoreInterface::class, $type.'_'.$name);
1442+
}
1443+
}
1444+
13691445
if ('supabase' === $type) {
13701446
foreach ($stores as $name => $store) {
13711447
$arguments = [

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

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,75 @@ public function testPostgresStoreWithDifferentConnectionCanBeConfigured()
540540
$this->assertInstanceOf(Reference::class, $definition->getArgument(0));
541541
}
542542

543+
public function testPostgresHybridStoreWithDsnCanBeConfigured()
544+
{
545+
$container = $this->buildContainer([
546+
'ai' => [
547+
'store' => [
548+
'postgres_hybrid' => [
549+
'hybrid_db' => [
550+
'dsn' => 'pgsql:host=localhost;port=5432;dbname=testdb',
551+
'username' => 'app',
552+
'password' => 'mypass',
553+
'table_name' => 'hybrid_vectors',
554+
'semantic_ratio' => 0.7,
555+
'language' => 'english',
556+
],
557+
],
558+
],
559+
],
560+
]);
561+
562+
$this->assertTrue($container->hasDefinition('ai.store.postgres_hybrid.hybrid_db'));
563+
$definition = $container->getDefinition('ai.store.postgres_hybrid.hybrid_db');
564+
$this->assertInstanceOf(Definition::class, $definition->getArgument(0));
565+
$this->assertSame('hybrid_vectors', $definition->getArgument(1));
566+
}
567+
568+
public function testPostgresHybridStoreWithDbalConnectionCanBeConfigured()
569+
{
570+
$container = $this->buildContainer([
571+
'ai' => [
572+
'store' => [
573+
'postgres_hybrid' => [
574+
'hybrid_db' => [
575+
'dbal_connection' => 'my_connection',
576+
'table_name' => 'hybrid_vectors',
577+
'rrf_k' => 100,
578+
],
579+
],
580+
],
581+
],
582+
]);
583+
584+
$this->assertTrue($container->hasDefinition('ai.store.postgres_hybrid.hybrid_db'));
585+
$definition = $container->getDefinition('ai.store.postgres_hybrid.hybrid_db');
586+
$this->assertInstanceOf(Definition::class, $definition->getArgument(0));
587+
$this->assertSame('hybrid_vectors', $definition->getArgument(1));
588+
$this->assertSame(100, $definition->getArgument(7));
589+
}
590+
591+
public function testPostgresHybridStoreWithConnectionReferenceCanBeConfigured()
592+
{
593+
$container = $this->buildContainer([
594+
'ai' => [
595+
'store' => [
596+
'postgres_hybrid' => [
597+
'hybrid_db' => [
598+
'connection' => '@my_pdo_service',
599+
'table_name' => 'hybrid_vectors',
600+
],
601+
],
602+
],
603+
],
604+
]);
605+
606+
$this->assertTrue($container->hasDefinition('ai.store.postgres_hybrid.hybrid_db'));
607+
$definition = $container->getDefinition('ai.store.postgres_hybrid.hybrid_db');
608+
$this->assertInstanceOf(Reference::class, $definition->getArgument(0));
609+
$this->assertSame('my_pdo_service', (string) $definition->getArgument(0));
610+
}
611+
543612
public function testConfigurationWithUseAttributeAsKeyWorksWithoutNormalizeKeys()
544613
{
545614
// Test that configurations using useAttributeAsKey work correctly

src/store/src/Bridge/Postgres/HybridStore.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
*
3636
* @author Ahmed EBEN HASSINE <ahmedbhs123@gmail.com>
3737
*/
38-
final readonly class HybridStore implements ManagedStoreInterface, StoreInterface
38+
final class HybridStore implements ManagedStoreInterface, StoreInterface
3939
{
4040
/**
4141
* @param string $vectorFieldName Name of the vector field

0 commit comments

Comments
 (0)