Construire un moteur de recherche sémantique avec Symfony AI et ChromaDB

La recherche full-text traditionnelle montre ses limites dès qu'un utilisateur formule sa requête différemment des termes exacts présents dans vos données. Cherchez "startups IA en France" et vous risquez de manquer toutes celles décrites comme "intelligence artificielle" ou "machine learning".

La recherche sémantique résout ce problème en comprenant le sens des mots plutôt que leur forme exacte. Dans cet article, nous allons implémenter un moteur de recherche sémantique complet avec Symfony AI, ChromaDB et Google Gemini, capable de trouver des résultats pertinents même quand les termes de recherche ne correspondent pas exactement aux données indexées.

Nous verrons également comment combiner la similarité sémantique avec des filtres sur les métadonnées grâce à un système de scoring hybride qui donne les meilleurs résultats possibles.


Comprendre les concepts clés

Avant de plonger dans le code, prenons le temps de comprendre les fondamentaux.

Qu'est-ce qu'un embedding ?

Un embedding (ou plongement vectoriel) est une représentation numérique d'un texte sous forme de vecteur. Imaginez que chaque texte soit transformé en une liste de plusieurs centaines de nombres décimaux :

"Startup spécialisée en intelligence artificielle"
    ↓ Modèle d'embedding
[0.023, -0.156, 0.891, 0.445, ..., -0.234]  // ~768 dimensions

La magie réside dans le fait que des textes ayant un sens similaire auront des vecteurs proches dans cet espace multidimensionnel. Ainsi, "IA" et "intelligence artificielle" produiront des vecteurs très similaires, même si les mots sont différents.

La similarité vectorielle

Pour mesurer la "proximité" entre deux vecteurs, on utilise généralement la distance cosinus ou la distance euclidienne. Plus la distance est faible, plus les textes sont sémantiquement proches.

Requête: "startup IA santé"
         ↓
    [0.12, 0.45, ...]  ←── Distance: 0.15 (proche!)
                            "MedTech utilisant le machine learning"

    [0.12, 0.45, ...]  ←── Distance: 0.89 (éloigné)
                            "Restaurant italien à Paris"

Le rôle de ChromaDB

ChromaDB est une base de données vectorielle open-source, légère et facile à déployer. Son rôle est de :

  1. Stocker les vecteurs avec leurs métadonnées associées
  2. Indexer efficacement ces vecteurs pour des recherches rapides
  3. Rechercher les K vecteurs les plus proches d'un vecteur requête

Architecture globale

Voici le flux de données de notre système :

┌─────────────────────────────────────────────────────────────────┐
│                        INDEXATION                                │
├─────────────────────────────────────────────────────────────────┤
│  Base de données  →  Loader  →  Vectorizer  →  ChromaDB         │
│  (PostgreSQL)        (texte)    (Gemini)       (vecteurs)       │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│                        RECHERCHE                                 │
├─────────────────────────────────────────────────────────────────┤
│  Requête  →  Vectorizer  →  ChromaDB  →  Re-ranking  →  Résultats│
│  utilisateur  (Gemini)      (query)      (hybride)              │
└─────────────────────────────────────────────────────────────────┘

Configuration de l'environnement

Installer ChromaDB avec Docker

La façon la plus simple de lancer ChromaDB est d'utiliser Docker. Ajoutez ce service à votre docker-compose.yml :

services:
  chromadb:
    image: chromadb/chroma:latest
    ports:
      - "9001:8000"
    volumes:
      - chroma_data:/chroma/chroma
    environment:
      - ANONYMIZED_TELEMETRY=False

volumes:
  chroma_data:

Lancez le service :

docker-compose up -d chromadb

Installer les dépendances PHP

Installez le client ChromaDB pour PHP et le bundle Symfony AI :

composer require codewithkyrian/chromadb-php
composer require symfony/ai

Configuration Symfony AI

Créez ou modifiez le fichier config/packages/ai.yaml :

services:
  # Configuration du client ChromaDB
  Codewithkyrian\ChromaDB\Factory:
    calls:
      - withHost: ['%env(CHROMADB_HOST)%']
      - withPort: ['%env(CHROMADB_PORT)%']
      - withDatabase: ['default_database']
      - withTenant: ['default_tenant']

  Codewithkyrian\ChromaDB\Client:
    factory: ['@Codewithkyrian\ChromaDB\Factory', 'connect']
    lazy: true

ai:
  # Configuration de la plateforme (Google Gemini)
  platform:
    gemini:
      api_key: '%env(GEMINI_API_KEY)%'

  # Configuration du vectorizer
  vectorizer:
    openai_embeddings:
      model:
        name: 'gemini-embedding-exp-03-07'
        platform: 'ai.platform.gemini'

Ajoutez les variables d'environnement dans votre .env :

CHROMADB_HOST=localhost
CHROMADB_PORT=9001
GEMINI_API_KEY=votre_clé_api_gemini

Créer le Store ChromaDB

Le Store est la couche d'abstraction entre Symfony AI et ChromaDB. Il implémente l'interface StoreInterface du bundle.

<?php

namespace App\Service\AI\Store;

use Codewithkyrian\ChromaDB\Client;
use Symfony\AI\Platform\Vector\Vector;
use Symfony\AI\Store\Document\Metadata;
use Symfony\AI\Store\Document\VectorDocument;
use Symfony\AI\Store\StoreInterface;
use Symfony\Component\Uid\Uuid;

final class ChromaDbStore implements StoreInterface
{
    public function __construct(
        private Client $client,
        private string $collectionName = 'startup_startups',
    ) {}

    /**
     * Ajoute des documents vectorisés dans ChromaDB
     */
    public function add(VectorDocument ...$documents): void
    {
        $ids = [];
        $vectors = [];
        $metadata = [];
        $originalDocuments = [];

        foreach ($documents as $document) {
            $ids[] = (string) $document->id;
            $vectors[] = $document->vector->getData();

            // Séparer le texte original des métadonnées
            $metadataCopy = $document->metadata->getArrayCopy();
            $originalDocuments[] = $metadataCopy[Metadata::KEY_TEXT] ?? '';
            unset($metadataCopy[Metadata::KEY_TEXT]);
            $metadata[] = $metadataCopy;
        }

        // Créer ou récupérer la collection, puis ajouter les documents
        $collection = $this->client->getOrCreateCollection($this->collectionName);
        $collection->add($ids, $vectors, $metadata, $originalDocuments);
    }

    /**
     * Recherche les documents les plus similaires à un vecteur
     */
    public function query(Vector $vector, array $options = []): array
    {
        $collection = $this->client->getOrCreateCollection($this->collectionName);

        $queryResponse = $collection->query(
            queryEmbeddings: [$vector->getData()],
            nResults: $options['nResults'] ?? 100,
            where: $options['where'] ?? null,
        );

        // Transformer la réponse ChromaDB en VectorDocuments
        $documents = [];
        for ($i = 0; $i < count($queryResponse->metadatas[0]); ++$i) {
            $metadata = $queryResponse->metadatas[0][$i];

            // Conserver la distance pour le scoring hybride
            if (isset($queryResponse->distances[0][$i])) {
                $metadata['_similarity_distance'] = $queryResponse->distances[0][$i];
            }

            $documents[] = new VectorDocument(
                id: Uuid::fromString($queryResponse->ids[0][$i]),
                vector: new Vector($queryResponse->embeddings[0][$i]),
                metadata: new Metadata($metadata),
            );
        }

        return $documents;
    }
}

Enregistrez le service avec un alias :

# config/services.yaml
services:
  App\Service\AI\Store\ChromaDbStore:
    arguments:
      $collectionName: 'startup_startups'
    tags:
      - { name: 'symfony.ai.store', alias: 'app.ai.store.chroma_db' }

Charger et indexer les données

Créer le Loader

Le Loader est responsable de charger vos données depuis la base de données et de les transformer en documents textuels prêts à être vectorisés.

<?php

namespace App\Service\AI\Loader;

use App\Entity\Startup;
use App\Repository\StartupRepository;
use Ramsey\Uuid\Uuid;
use Symfony\AI\Store\Document\LoaderInterface;
use Symfony\AI\Store\Document\Metadata;
use Symfony\AI\Store\Document\TextDocument;

readonly class StartupsLoader implements LoaderInterface
{
    public function __construct(
        private StartupRepository $startupRepository,
    ) {}

    public function load(?string $source, array $options = []): iterable
    {
        $batchSize = $options['batch_size'] ?? 100;
        $offset = 0;

        while (true) {
            $startups = $this->startupRepository->createQueryBuilder('p')
                ->setMaxResults($batchSize)
                ->setFirstResult($offset)
                ->getQuery()
                ->getResult();

            if (empty($startups)) {
                break;
            }

            foreach ($startups as $startup) {
                yield new TextDocument(
                    id: $this->generateDeterministicUuid($startup->getId()),
                    content: $this->buildContent($startup),
                    metadata: new Metadata($this->extractMetadata($startup)),
                );
            }

            // Libérer la mémoire entre les batches
            $this->startupRepository->getEntityManager()->clear();
            $offset += $batchSize;
        }
    }

    /**
     * UUID déterministe : même ID pour même startup à chaque indexation
     */
    private function generateDeterministicUuid(int $id): \Symfony\Component\Uid\Uuid
    {
        $uuid = Uuid::uuid5(Uuid::NAMESPACE_DNS, (string) $id);
        return \Symfony\Component\Uid\Uuid::fromString($uuid->toString());
    }

    /**
     * Construit le contenu textuel enrichi pour la vectorisation
     */
    private function buildContent(Startup $startup): string
    {
        $content = [];

        if ($startup->getName()) {
            $content[] = $startup->getName();
        }

        if ($startup->getDescription()) {
            $content[] = $startup->getDescription();
        }

        return implode(". ", $content);
    }

    /**
     * Extrait les métadonnées filtrables
     */
    private function extractMetadata(Startup $startup): array
    {
        $metadata = [
            'startup_id' => $startup->getId(),
            'name' => $startup->getName(),
        ];

        if ($startup->getCountry()) {
            $metadata['country'] = $startup->getCountry();
        }

        if ($startup->getCity()) {
            $metadata['city'] = $startup->getCity();
        }

        return array_filter($metadata, fn($v) => $v !== null && $v !== '');
    }
}

Configurer l'indexer

Ajoutez la configuration de l'indexer dans ai.yaml :

ai:
  indexer:
    startups:
      loader: 'App\Service\AI\Loader\StartupsLoader'
      transformers:
        - 'Symfony\AI\Store\Document\Transformer\TextTrimTransformer'
      vectorizer: 'ai.vectorizer.openai_embeddings'
      store: 'app.ai.store.chroma_db'

Lancez l'indexation :

php bin/console ai:index startups

Implémenter la recherche avec scoring hybride

C'est la partie la plus intéressante de notre système. Nous allons créer un Tool que l'agent IA pourra utiliser pour effectuer des recherches, avec un système de scoring hybride qui combine :

  • 70% : Score de similarité sémantique
  • 30% : Score de pertinence des filtres

Créer le Tool de recherche

<?php

namespace App\Service\AI\Tool;

use Symfony\AI\Agent\Toolbox\Attribute\AsTool;
use Symfony\AI\Store\Document\VectorizerInterface;
use Symfony\AI\Store\StoreInterface;
use Symfony\AI\Store\Document\VectorDocument;
use Symfony\AI\Store\Document\Metadata;

#[AsTool(
    'startup_search_with_filters',
    description: 'Recherche des startups similaires à une requête avec filtres optionnels sur les métadonnées.'
)]
final class StartupSimilaritySearchWithFilters
{
    public function __construct(
        private readonly VectorizerInterface $vectorizer,
        private readonly StoreInterface $store,
    ) {}

    /**
     * @param string $searchTerm Terme de recherche sémantique
     * @param array $filters Filtres sur les métadonnées (ex: ['country' => 'France'])
     * @param int $maxResults Nombre maximum de résultats
     */
    public function __invoke(
        string $searchTerm,
        array $filters = [],
        int $maxResults = 20
    ): string {
        // 1. Vectoriser la requête utilisateur
        $vector = $this->vectorizer->vectorize($searchTerm);

        // 2. Construire les options de requête
        $options = ['nResults' => 100];
        if (!empty($filters)) {
            $options['where'] = $this->buildWhereClause($filters);
        }

        // 3. Rechercher dans ChromaDB
        $documents = $this->store->query($vector, $options);

        if (empty($documents)) {
            return "Aucun résultat trouvé";
        }

        // 4. Re-classer avec le scoring hybride
        $rankedDocuments = $this->reRankDocuments($documents, $filters);

        // 5. Garder les meilleurs résultats
        $topResults = array_slice($rankedDocuments, 0, $maxResults);

        // 6. Formater la réponse
        $result = "Documents trouvés :\n";
        foreach ($topResults as $doc) {
            $metadata = $doc->metadata->getArrayCopy();
            unset($metadata['_similarity_distance'], $metadata['_hybrid_score']);
            $result .= json_encode($metadata, JSON_UNESCAPED_UNICODE) . "\n";
        }

        return $result;
    }

    /**
     * Re-classe les documents avec un scoring hybride
     * Score final = (similarité × 0.7) + (pertinence filtres × 0.3)
     */
    private function reRankDocuments(array $documents, array $filters): array
    {
        if (empty($filters)) {
            return $documents;
        }

        // Calculer min/max des distances pour normalisation
        $distances = array_map(
            fn($doc) => $doc->metadata['_similarity_distance'] ?? 1.0,
            $documents
        );
        $minDistance = min($distances);
        $maxDistance = max($distances);
        $distanceRange = $maxDistance - $minDistance ?: 1.0;

        $scoredDocuments = [];

        foreach ($documents as $document) {
            $metadata = $document->metadata->getArrayCopy();

            // Score de similarité normalisé (0 à 1, inversé car distance)
            $distance = $metadata['_similarity_distance'] ?? 1.0;
            $similarityScore = 1.0 - (($distance - $minDistance) / $distanceRange);

            // Score de pertinence des filtres
            $filterScore = $this->calculateFilterScore($metadata, $filters);

            // Score hybride final
            $hybridScore = ($similarityScore * 0.7) + ($filterScore * 0.3);

            $metadata['_hybrid_score'] = $hybridScore;

            $scoredDocuments[] = new VectorDocument(
                id: $document->id,
                vector: $document->vector,
                metadata: new Metadata($metadata),
            );
        }

        // Trier par score hybride décroissant
        usort($scoredDocuments, function($a, $b) {
            return ($b->metadata['_hybrid_score'] ?? 0) <=> ($a->metadata['_hybrid_score'] ?? 0);
        });

        return $scoredDocuments;
    }

    /**
     * Calcule le score de pertinence par rapport aux filtres (0 à 1)
     */
    private function calculateFilterScore(array $metadata, array $filters): float
    {
        if (empty($filters)) {
            return 1.0;
        }

        $totalScore = 0.0;
        $filterCount = 0;

        foreach ($filters as $field => $filterValue) {
            $filterCount++;
            $metadataValue = $metadata[$field] ?? null;

            if ($metadataValue === null) {
                continue;
            }

            if (is_array($filterValue)) {
                // Filtre de plage (min/max)
                $totalScore += $this->scoreRangeMatch($metadataValue, $filterValue);
            } else {
                // Correspondance exacte
                $totalScore += ($metadataValue === $filterValue) ? 1.0 : 0.0;
            }
        }

        return $filterCount > 0 ? $totalScore / $filterCount : 1.0;
    }

    /**
     * Score pour les filtres de plage
     */
    private function scoreRangeMatch($value, array $range): float
    {
        $min = $range['min'] ?? null;
        $max = $range['max'] ?? null;

        if ($min !== null && $max !== null) {
            if ($value < $min || $value > $max) {
                return 0.0;
            }
            // Score basé sur la proximité au centre de la plage
            $center = ($min + $max) / 2;
            $rangeSize = $max - $min;
            $distanceFromCenter = abs($value - $center);
            return 1.0 - ($distanceFromCenter / ($rangeSize / 2)) * 0.2;
        }

        if ($min !== null) {
            return $value >= $min ? 1.0 : 0.0;
        }

        if ($max !== null) {
            return $value <= $max ? 1.0 : 0.0;
        }

        return 1.0;
    }

    /**
     * Construit la clause WHERE pour ChromaDB
     */
    private function buildWhereClause(array $filters): array
    {
        $conditions = [];

        foreach ($filters as $field => $value) {
            if (is_array($value) && (isset($value['min']) || isset($value['max']))) {
                // Filtre de plage
                if (isset($value['min'])) {
                    $conditions[] = [$field => ['$gte' => $value['min']]];
                }
                if (isset($value['max'])) {
                    $conditions[] = [$field => ['$lte' => $value['max']]];
                }
            } else {
                // Égalité simple
                $conditions[] = [$field => ['$eq' => $value]];
            }
        }

        if (count($conditions) > 1) {
            return ['$and' => $conditions];
        }

        return $conditions[0] ?? [];
    }
}

Comprendre le scoring hybride

Le scoring hybride est la clé d'une recherche performante. Voici pourquoi :

Problème avec la similarité seule : Une requête "startups IA en France" pourrait retourner une startup américaine très pertinente en IA avant une startup française moins bien décrite.

Problème avec les filtres seuls : Vous ne pouvez pas capturer la nuance sémantique. "Machine learning" et "IA" ne matcheraient pas.

Solution hybride :

  • 70% du score vient de la similarité sémantique → garantit la pertinence du contenu
  • 30% du score vient des filtres → booste les résultats qui correspondent aux critères explicites
Exemple :
Requête: "startup IA France"
Filtres: {country: "France"}

Document A (France, très pertinent IA):
  - Similarité: 0.95 → 0.95 × 0.7 = 0.665
  - Filtre (France=France): 1.0 → 1.0 × 0.3 = 0.3
  - Score final: 0.965 ✓ Premier

Document B (USA, extrêmement pertinent IA):
  - Similarité: 0.99 → 0.99 × 0.7 = 0.693
  - Filtre (USA≠France): 0.0 → 0.0 × 0.3 = 0.0
  - Score final: 0.693 ✗ Second

Exposer via une API

Service de recherche

Créez un service qui orchestre l'agent IA :

<?php

namespace App\Service\AI\Search;

use Symfony\AI\Agent\Agent;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

final class SearchService
{
    public function __construct(
        #[Autowire('@ai.agent.startups')]
        private readonly Agent $startupsAgent,
    ) {}

    public function search(string $query): SearchResult
    {
        $startTime = microtime(true);

        try {
            $messages = new MessageBag(
                Message::ofUser("Recherche: " . $query)
            );

            $result = $this->startupsAgent->call($messages);
            $totalTime = microtime(true) - $startTime;

            return new SearchResult(
                query: $query,
                formattedContent: $result->getContent(),
                totalTime: $totalTime,
                success: true
            );
        } catch (\Throwable $e) {
            return SearchResult::failed(
                $query,
                $e->getMessage(),
                microtime(true) - $startTime
            );
        }
    }
}

Configurer l'agent

Ajoutez la configuration de l'agent dans ai.yaml :

ai:
  agent:
    startups:
      model: 'gemini-2.5-flash'
      prompt: |
        Tu es un assistant de recherche de startups.

        Utilise l'outil startup_search_with_filters pour rechercher.

        Filtres disponibles :
        - country, city : localisation

        Effectue TOUJOURS une recherche, même sans filtres parfaits.
      tools:
        - 'app.ai.tool.startup_similarity'

services:
  app.ai.tool.startup_similarity:
    class: App\Service\AI\Tool\StartupSimilaritySearchWithFilters
    arguments:
      $vectorizer: '@ai.vectorizer.openai_embeddings'
      $store: '@app.ai.store.chroma_db'

Contrôleur API

<?php

namespace App\Controller\AI;

use App\Service\AI\Search\SearchService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;

class AiSearchController extends AbstractController
{
    #[Route('/api/v2/ai-search', methods: ['POST'])]
    public function search(
        Request $request,
        SearchService $searchService
    ): JsonResponse {
        $data = json_decode($request->getContent(), true);
        $query = $data['query'] ?? '';

        if (strlen($query) < 3) {
            return $this->json(['error' => 'Query too short'], 400);
        }

        $result = $searchService->search($query);

        return $this->json([
            'success' => $result->success,
            'content' => $result->formattedContent,
            'execution_time' => $result->totalTime,
            'query' => $query,
        ]);
    }
}

Conclusion

Nous avons construit un moteur de recherche sémantique complet avec :

  1. ChromaDB pour le stockage et la recherche vectorielle
  2. Google Gemini pour la génération des embeddings
  3. Symfony AI pour l'orchestration et les agents
  4. Un système de scoring hybride qui combine similarité sémantique et pertinence des filtres

Cette architecture offre plusieurs avantages :

  • Recherche naturelle : Les utilisateurs peuvent chercher avec leurs propres mots
  • Flexibilité : Les filtres permettent d'affiner les résultats
  • Performance : ChromaDB est optimisé pour les recherches vectorielles
  • Extensibilité : Facile d'ajouter de nouvelles sources de données ou de nouveaux filtres

Pour aller plus loin

  • Expérimentez avec les poids du scoring hybride (70/30) selon vos données
  • Ajoutez un cache pour les requêtes fréquentes
  • Implémentez un feedback utilisateur pour améliorer le ranking
  • Explorez d'autres modèles d'embedding selon vos besoins linguistiques

Ressources


EpickOne - Votre expertise Symfony et API Platform à Toulouse. Contactez-nous pour optimiser vos projets avec les dernières innovations IA !