Skip to content
Efficience IT
FR
·7 min read·AIRecently updated

RAG with Symfony AI: Index Your Business Database

By Louis-Arnaud Catoire

Updated on

RAG with Symfony AI: Index Your Business Database

You have a Symfony application in production, a well-filled Doctrine (in French) database, and you are wondering how to make all of it queryable by an LLM. Not through a classic REST API. Through natural language: "which similar tickets have already been resolved?", "which client had this problem in January?"

The answer fits in three letters: RAG (Retrieval-Augmented Generation). And since the arrival of Symfony AI, we finally have the building blocks to do it cleanly in the Symfony ecosystem.

This article shows you, concretely, how to index your Doctrine database in a vector store and build a working RAG pipeline. No empty theory. Code, architecture choices, and pitfalls to avoid.

Why RAG Changes the Game for Business Apps

LLMs are powerful, but they do not know your data. Your product catalog, your support tickets, your client contracts: none of it exists for GPT-4 or Claude.

You have two options:

  • Fine-tuning: retrain the model on your data. Heavy, slow, and obsolete as soon as your database changes.
  • RAG: inject the relevant context into the prompt at query time. No retraining, always fresh data, controlled workload.

RAG works in three steps:

  1. Indexing: turn your data into vectors (embeddings) and store them
  2. Search: convert the user's question into a vector and find the closest documents
  3. Generation: send the documents found to the LLM as context and get an answer grounded in your data

It is simple conceptually. Implementing it in a Symfony project requires a few structuring choices.

The Target Architecture

Here is the overall diagram of what we are going to build:

Utilisateur → Question
                ↓
        Embedding de la question
                ↓
        Recherche de similarité (vector store)
                ↓
        Documents pertinents récupérés
                ↓
        Prompt = template + contexte + question
                ↓
        Appel LLM → Réponse enrichie

This type of pipeline fits naturally into a hexagonal architecture (in French) where the domain stays isolated from the AI infrastructure. On the stack side:

  • Symfony 7 with Symfony AI for orchestration
  • Doctrine ORM as the data source
  • pgvector or Qdrant as the vector store
  • OpenAI / Mistral for embeddings and generation

Installing Symfony AI

Symfony AI provides the necessary abstractions: embedding models, vector stores, processing chains.

composer require symfony/ai

Configuration lives in the config/packages/ai.yaml file. You declare your platforms (OpenAI, Mistral, Ollama) and your stores there.

symfony_ai:
    platform:
        openai:
            api_key: '%env(OPENAI_API_KEY)%'
    store:
        my_store:
            type: pgvector
            dsn: '%env(DATABASE_URL)%'
            table: embeddings

Need support on your project?

Let's talk

Choosing What to Index

The first classic mistake is wanting to index everything. Your User table with its hashed passwords, your audit logs, your join tables: none of it has any value for an LLM.

Focus on data with high semantic value:

  • Product descriptions
  • Support ticket content (title + body + resolution)
  • Internal documentation articles
  • Customer comments
  • Technical sheets

For each entity, you define a method that produces the text to vectorize:

class SupportTicket
{
    // ...

    public function toEmbeddingText(): string
    {
        return sprintf(
            "Ticket #%d - %s\nStatut: %s\nDescription: %s\nRésolution: %s",
            $this->id,
            $this->title,
            $this->status,
            $this->description,
            $this->resolution ?? 'Non résolu'
        );
    }
}

This method is the contract between your Doctrine model and the indexing pipeline. It determines what the LLM "sees" of your entity.

The Indexing Command

We create a Symfony command that reads the Doctrine entities and pushes the embeddings into the vector store:

#[AsCommand(name: 'app:index-tickets')]
class IndexTicketsCommand extends Command
{
    public function __construct(
        private EntityManagerInterface $em,
        private EmbeddingModelInterface $embeddingModel,
        private VectorStoreInterface $vectorStore,
    ) {
        parent::__construct();
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $tickets = $this->em->getRepository(SupportTicket::class)->findAll();

        $documents = [];
        foreach ($tickets as $ticket) {
            $documents[] = new Document(
                id: (string) $ticket->getId(),
                content: $ticket->toEmbeddingText(),
                metadata: [
                    'entity' => SupportTicket::class,
                    'id' => $ticket->getId(),
                    'status' => $ticket->getStatus(),
                ],
            );
        }

        $this->embeddingModel->embedDocuments($documents);
        $this->vectorStore->addDocuments($documents);

        $output->writeln(sprintf('%d tickets indexés.', count($documents)));

        return Command::SUCCESS;
    }
}

The metadata matters: it lets you filter results by entity type, status, or date. You do not want to surface closed tickets when the user is looking for an open problem.

Choosing Your Vector Store

PostgreSQL with pgvector

If your application already runs on PostgreSQL, containerized with Docker (in French), this is the most pragmatic choice. pgvector adds a vector type and similarity operators directly in your existing database.

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE embeddings (
    id UUID PRIMARY KEY,
    content TEXT NOT NULL,
    metadata JSONB,
    embedding vector(1536)
);

CREATE INDEX ON embeddings USING ivfflat (embedding vector_cosine_ops);

Advantages: no extra infrastructure, unified backups, ACID transactions. For most use cases (fewer than a million documents), pgvector is more than enough.

Qdrant

If you need top performance on large volumes or advanced features (payload filtering, sharding), Qdrant is an excellent choice. It is a dedicated vector store designed specifically for similarity search. For the classic full-text search that often accompanies semantic search, our comparison of Elasticsearch and Algolia (in French) details the tradeoffs between a self-hosted engine and a SaaS solution.

symfony_ai:
    store:
        my_store:
            type: qdrant
            host: '%env(QDRANT_HOST)%'
            collection: support_tickets

The choice depends on your context: pgvector to keep things simple, Qdrant to scale.

The Search Pipeline

When a user asks a question, here is what happens:

class TicketSearchService
{
    public function __construct(
        private EmbeddingModelInterface $embeddingModel,
        private VectorStoreInterface $vectorStore,
        private ChatModelInterface $chatModel,
    ) {}

    public function search(string $userQuery): string
    {
        $queryVector = $this->embeddingModel->embedText($userQuery);

        $results = $this->vectorStore->similaritySearch(
            vector: $queryVector,
            limit: 5,
            metadata: ['status' => 'resolved'],
        );

        $context = implode("\n\n---\n\n", array_map(
            fn (Document $doc) => $doc->content,
            $results,
        ));

        $messages = [
            new SystemMessage($this->buildPrompt($context)),
            new UserMessage($userQuery),
        ];

        $response = $this->chatModel->chat($messages);

        return $response->content;
    }

    private function buildPrompt(string $context): string
    {
        return <<<PROMPT
        Tu es un assistant support pour notre application.
        Utilise UNIQUEMENT les informations suivantes pour répondre.
        Si tu ne trouves pas la réponse dans le contexte, dis-le clairement.

        Contexte :
        {$context}
        PROMPT;
    }
}

The key point: the prompt explicitly tells the LLM to rely only on the context provided. Without this instruction, the model will hallucinate answers from its general knowledge.

Structuring Search in a Hexagonal Architecture

In a serious Symfony application, you do not want to couple your domain to a specific vector store. This is the core principle of hexagonal architecture applied to Symfony (in French): isolating the domain from the infrastructure. We define a port:

interface SemanticSearchPort
{
    /** @return array<Document> */
    public function search(string $query, int $limit = 5, array $filters = []): array;
}

And the adapter that uses Symfony AI:

class SymfonyAiSemanticSearchAdapter implements SemanticSearchPort
{
    public function __construct(
        private EmbeddingModelInterface $embeddingModel,
        private VectorStoreInterface $vectorStore,
    ) {}

    public function search(string $query, int $limit = 5, array $filters = []): array
    {
        $vector = $this->embeddingModel->embedText($query);

        return $this->vectorStore->similaritySearch(
            vector: $vector,
            limit: $limit,
            metadata: $filters,
        );
    }
}

Tomorrow, if you switch from pgvector to Qdrant (or the reverse), you change the adapter. Your domain knows nothing about it.

Chunking Strategies for Long Texts

An embedding has a maximum token size (8191 for OpenAI's text-embedding-3-small). If your entities contain long fields, such as technical documentation, blog posts, or reports, you need to split them.

class TextChunker
{
    public function chunk(string $text, int $maxTokens = 500, int $overlap = 50): array
    {
        $words = explode(' ', $text);
        $chunks = [];
        $position = 0;

        while ($position < count($words)) {
            $chunk = array_slice($words, $position, $maxTokens);
            $chunks[] = implode(' ', $chunk);
            $position += $maxTokens - $overlap;
        }

        return $chunks;
    }
}

The overlap matters: it ensures that information straddling two chunks is not lost. An overlap of 50 to 100 tokens is a good default.

Each chunk becomes a separate document in the vector store, with the same metadata as the source entity plus a position index:

$chunks = $chunker->chunk($entity->getLongDescription());

foreach ($chunks as $index => $chunk) {
    $documents[] = new Document(
        id: sprintf('%s-chunk-%d', $entity->getId(), $index),
        content: $chunk,
        metadata: [
            'entity' => get_class($entity),
            'id' => $entity->getId(),
            'chunk_index' => $index,
        ],
    );
}

Keeping the Index Up to Date with Doctrine Events

An initial indexing run is good. An index that updates itself is better. We use Doctrine events:

use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Events;

#[AsDoctrineListener(event: Events::postPersist)]
#[AsDoctrineListener(event: Events::postUpdate)]
#[AsDoctrineListener(event: Events::postRemove)]
class EmbeddingIndexerListener
{
    public function __construct(
        private MessageBusInterface $bus,
    ) {}

    public function onEntityChange(PostPersistEventArgs|PostUpdateEventArgs $args): void
    {
        $entity = $args->getObject();

        if (!$entity instanceof EmbeddableInterface) {
            return;
        }

        $this->bus->dispatch(new ReindexEntityMessage(
            entityClass: get_class($entity),
            entityId: $entity->getId(),
        ));
    }

    public function onEntityRemove(PostRemoveEventArgs $args): void
    {
        $entity = $args->getObject();

        if (!$entity instanceof EmbeddableInterface) {
            return;
        }

        $this->bus->dispatch(new RemoveEmbeddingMessage(
            entityClass: get_class($entity),
            entityId: $entity->getId(),
        ));
    }
}

We go through Symfony Messenger so the HTTP request is not blocked. The embedding is computed asynchronously. Our case study on integrating Symfony AI into a legacy project (in French) shows how this type of architecture fits into an existing codebase.

The EmbeddableInterface interface serves as a marker:

interface EmbeddableInterface
{
    public function getId(): int|string;
    public function toEmbeddingText(): string;
}

Any entity that implements this interface will be indexed automatically on every change.

Performance: Batch Indexing and Messenger

The initial indexing of thousands of entities should not happen document by document. Symfony AI supports batching:

$batchSize = 100;
$batches = array_chunk($documents, $batchSize);

foreach ($batches as $batch) {
    $this->embeddingModel->embedDocuments($batch);
    $this->vectorStore->addDocuments($batch);
    $this->em->clear();
}

The $this->em->clear() call is essential to free memory between batches. Without it, Doctrine keeps every entity in memory and your process blows up on a large dataset. The Doctrine ORM 3.0 improvements to entity management also make this kind of batch processing more robust.

For asynchronous indexing, the Messenger handler:

#[AsMessageHandler]
class ReindexEntityHandler
{
    public function __construct(
        private EntityManagerInterface $em,
        private EmbeddingModelInterface $embeddingModel,
        private VectorStoreInterface $vectorStore,
    ) {}

    public function __invoke(ReindexEntityMessage $message): void
    {
        $entity = $this->em->find(
            $message->entityClass,
            $message->entityId,
        );

        if (!$entity instanceof EmbeddableInterface) {
            return;
        }

        $document = new Document(
            id: sprintf('%s-%s', $message->entityClass, $message->entityId),
            content: $entity->toEmbeddingText(),
            metadata: [
                'entity' => $message->entityClass,
                'id' => $message->entityId,
            ],
        );

        $this->embeddingModel->embedDocuments([$document]);
        $this->vectorStore->addDocuments([$document]);
    }
}

A Concrete Example: A Support Ticket System

Let's take a real case. You run a support application with thousands of resolved tickets. Agents spend time checking whether a similar problem has already been handled.

With RAG in place, the agent types: "The client cannot export their invoices to PDF since the March update".

The pipeline:

  1. The question is turned into a vector
  2. pgvector finds the 5 most semantically similar tickets
  3. Among them: a ticket resolved 3 months ago, "PDF export broken after wkhtmltopdf version upgrade"
  4. The LLM summarizes: "A similar problem was resolved in December (ticket #4521). The cause was a wkhtmltopdf version incompatibility after an update. Solution: pin the version to 0.12.6 in the Dockerfile."

The agent has their answer in 3 seconds instead of 15 minutes of manual searching.

class SupportAgentController extends AbstractController
{
    #[Route('/support/ask', methods: ['POST'])]
    public function ask(
        Request $request,
        TicketSearchService $searchService,
    ): JsonResponse {
        $question = $request->getPayload()->getString('question');
        $answer = $searchService->search($question);

        return $this->json(['answer' => $answer]);
    }
}

The Prompt Template

The prompt is the centerpiece. A bad prompt with good documents gives bad results:

Tu es un assistant technique pour l'équipe support de {company}.

Règles :
- Réponds UNIQUEMENT à partir des documents fournis ci-dessous
- Si l'information n'est pas dans les documents, réponds "Je n'ai pas trouvé d'information pertinente dans la base"
- Cite les numéros de tickets quand c'est pertinent
- Sois concis et actionnable

Documents de contexte :
{context}

Question de l'agent :
{question}

Negative instructions ("do not do this") are as important as positive ones. Without the instruction to refuse when the context is insufficient, the LLM will invent a plausible but false answer.

Limits to Anticipate

Vector Store Size

An embedding of dimension 1536 takes up about 6 KB. For 100,000 documents, that is roughly 600 MB. pgvector handles that without trouble. Beyond a million documents, consider Qdrant or a well-configured IVFFlat index.

Relevance

RAG is not magic. If your source data is poorly structured (empty fields, texts that are too short, duplicates), results will be mediocre. The quality of the toEmbeddingText() method is decisive.

Latency

An embedding call takes 50-200 ms. A similarity search on pgvector with 100,000 documents takes 10-50 ms. The LLM call takes 1-3 seconds. The bottleneck is always the LLM, not RAG.

Next Steps

RAG is a first step. Once the pipeline is in place, you can:

  • Add reranking to refine similarity results
  • Implement multi-source RAG (Doctrine + PDF files + external APIs)
  • Build a conversational agent with conversation history
  • Set up guardrails to limit answers to authorized data

The Symfony AI ecosystem is still young but progressing fast. Combined with the maturity of Doctrine and the richness of the Symfony ecosystem (Messenger, Security, Cache), you have everything you need to build robust AI applications in PHP. This is exactly the type of project where our artificial intelligence expertise (in French) applies every day. On the storage side, our Doctrine / PostgreSQL optimization (in French) offering covers pgvector integration and index tuning so that similarity queries scale.

RAG with Symfony AI and Doctrine is exactly the kind of topic we love digging into at Efficience IT. If you want to discuss your use case, contact us (in French).

Going Further

Have a project in mind?

Our team replies within 48 hours to look at your needs and suggest a suitable approach.

Contact us

Related articles