<?xml version="1.0" encoding="utf-8"?>
<?xml-stylesheet type="text/xsl" href="https://ktherage.github.io/xsl/atom.xsl" media="all"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
  <id>https://ktherage.github.io/tags/llm/</id>
  <title>Kévin THÉRAGE | Expert Symfony Developer - LLM</title>
  <subtitle><![CDATA[Kévin THÉRAGE – Expert Symfony Developer. Technical blog on Symfony, PHP, web development with tutorials, best practices and expert advice for developers.]]></subtitle>
  <link href="https://ktherage.github.io/tags/llm/atom.xml" rel="self" type="application/atom+xml" />
  <link href="https://ktherage.github.io/tags/llm/" rel="alternate" type="text/html" />
  <updated>2026-08-10T17:48:12+00:00</updated>
  <author>
    <name>Kévin THÉRAGE</name>
    <uri>https://ktherage.github.io/</uri>
  </author>
  <entry xml:lang="en">
    <id>https://ktherage.github.io/blog/building-a-rag-powered-code-review-assistant/</id>
    <title>Building a RAG-Powered Code Review Assistant with PHP, Ollama, and Qdrant</title>
    <published>2026-08-10T00:00:00+00:00</published>
    <link href="https://ktherage.github.io/blog/building-a-rag-powered-code-review-assistant/" rel="alternate" type="text/html" />
    <content type="html">
      <![CDATA[<p><a href="https://en.wikipedia.org/wiki/Large_language_model" rel="noopener noreferrer">LLMs</a> are great at producing code reviews that sound right. But "sounding right" isn't the same as being useful. A review that tells you to "fix the Code Style" is correct but useless — every project applies a Code Style that may differ.</p>
<p>Symfony has more than <strong>20 years of public code reviews</strong> on <a href="https://github.com/" rel="noopener noreferrer">GitHub</a>. Every merged PR contains comments from <a href="https://github.com/nicolas-grekas" rel="noopener noreferrer">nicolas-grekas</a>, <a href="https://github.com/stof" rel="noopener noreferrer">stof</a>, <a href="https://github.com/dunglas" rel="noopener noreferrer">dunglas</a>, <a href="https://github.com/xabbuh" rel="noopener noreferrer">xabbuh</a>, and dozens of other reviewers from the core team and contributors. It's a goldmine of domain-specific review patterns: which arguments convince, which patterns get rejected, what the community considers good Symfony code.</p>
<p>The problem? No one had built a search engine to exploit it. So I did it 🤣.</p>
<h2 id="the-lore-behind-this-crazy-idea">The lore behind this crazy idea</h2>
<p>This story starts at the <a href="https://live.symfony.com/" rel="noopener noreferrer">Symfony Live in Paris</a>. As you can imagine, this year's edition was very AI-focused. I saw quite a few talks about it and wanted to play around with this novelty, but until then I didn't have a concrete use case.</p>
<p>I attended a talk by <a href="https://github.com/lyrixx" rel="noopener noreferrer">Grégoire Pineau</a> where he explained how, with <a href="https://ai.symfony.com/" rel="noopener noreferrer">Symfony AI</a>, <a href="https://clickhouse.com/" rel="noopener noreferrer">Clickhouse</a> and <a href="https://redirection.io/" rel="noopener noreferrer">redirection.io</a>, he had successfully migrated an e-commerce site while reducing traffic loss.</p>
<p>Later, the <a href="https://symfony.com/blog/new-in-symfony-8-1-http-less-symfony-applications" rel="noopener noreferrer">Console Bundle</a> arrived.</p>
<p>I had already asked an LLM to review the changes I had made <em>(on personal projects of course)</em> and, as you might guess, I got advice like <code translate="no">consider using dependency injection</code>, <code translate="no">maybe extract this logic into a service</code> or <code translate="no">remember to check the code style</code>. These pieces of feedback are technically correct, but above all universally applicable and completely generic. In short, nothing that can't be fixed with good tools and a little rigor.</p>
<p>Coming out of Symfony Live, a somewhat crazy idea came to me.
What if I could ask the same LLM: </p>
<blockquote>
<p>"Review this code the way stof would"</p>
</blockquote>
<p>I'd get ultra-sharp feedback and code that would come out stronger.</p>
<p>Or</p>
<blockquote>
<p>"Review this code the way any Symfony contributor would"</p>
</blockquote>
<p>Then I'd get the whole community's point of view on the code I just created. </p>
<p>Or even</p>
<blockquote>
<p>"Review this code the way any member of the Symfony core team would"</p>
</blockquote>
<p>I'd have a panel of experts at my disposal to explain what's wrong with what I did.</p>
<p>That's what <strong>Symfony Reviewer MCP</strong> does: a <a href="https://en.wikipedia.org/wiki/Retrieval-augmented_generation" rel="noopener noreferrer">semantic search engine (RAG)</a> over all of Symfony's historical code reviews, accessible via the <a href="https://docs.github.com/en/rest" rel="noopener noreferrer">GitHub</a> API, exposed through the <a href="https://en.wikipedia.org/wiki/Model_Context_Protocol" rel="noopener noreferrer">Model Context Protocol (MCP)</a>, all with <a href="https://ai.symfony.com/" rel="noopener noreferrer">Symfony AI</a> and in a <a href="https://symfony.com/blog/new-in-symfony-8-1-http-less-symfony-applications" rel="noopener noreferrer">Symfony HTTP-less</a> application.</p>
<p>I built it in <a href="https://www.php.net/releases/8.5/" rel="noopener noreferrer">PHP 8.5</a> with <a href="https://symfony.com/" rel="noopener noreferrer">Symfony 8.1</a>, using <a href="https://ollama.com/" rel="noopener noreferrer">Ollama</a> locally for vectorization with a model from <a href="https://huggingface.co/" rel="noopener noreferrer">huggingface.co</a> (embeddinggemma-300m, 768 dimensions) and <a href="https://qdrant.tech/" rel="noopener noreferrer">Qdrant</a> as the vector database.</p>
<p>No GPU required — if you accept the trade-off: indexing all historical reviews ran on my machine's CPU <em>for several days</em> 😅. For a one-off project, I found this trade-off largely acceptable and cost-efficient.</p>
<p>How does it work?</p>
<h2 id="global-architecture">Global architecture</h2>
<p>The global architecture is split into two big blocks:</p>
<ol>
<li><strong>RAG generation:</strong> <ol>
<li>Data fetching and caching</li>
<li>Dataset generation and indexing into <a href="https://qdrant.tech/" rel="noopener noreferrer">Qdrant</a></li>
</ol>
</li>
<li><strong>The MCP server</strong></li>
</ol>
<h3 id="rag-generation">RAG generation</h3>
<p>Here is the complete RAG generation pipeline:</p>
<p>Don't worry if this diagram looks dense — I'll walk through every step of the pipeline in the rest of the article, from fetching GitHub reviews to semantic search.</p>
<pre class="mermaid d-flex flex-column m-2 justify-content-center align-items-center">
flowchart TD
    A["GitHub API (symfony/symfony)"] --&gt; B["PullsFetcher (merged PRs only)"]
    B --&gt; C["ReviewsFetcher (comments + replies)"]
    C --&gt; D["DatasetGenerator (var/dataset/pull-{id}.txt)"]
    D --&gt; E["Builder::build"]
    E --&gt; F["Ollama (embeddinggemma-300m)"]
    F --&gt; G["Qdrant (collection: reviews)"]
</pre>
<h4>Fetching the data</h4>
<p>Before all this HTTP decorator machinery matters, you first have to walk the <a href="https://docs.github.com/en/rest" rel="noopener noreferrer">GitHub API</a> and decide what's worth keeping.</p>
<p><code translate="no">PullsFetcher</code> pages through <code translate="no">GET /repos/symfony/symfony/pulls?state=all&amp;per_page=100</code>, keeping only PRs whose <code translate="no">merged_at</code> isn't null — no point training the system on rejected ideas. Rather than blindly paging until hitting an empty page, it first sends a single <code translate="no">HEAD</code> request and reads the total page count directly from the <a href="https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api" rel="noopener noreferrer">Link header</a>. That <code translate="no">HEAD</code> call is exactly why <code translate="no">BLACKLISTED_PATTERN</code> exists: it's a discovery request, not something worth caching for 365 days.</p>
<p><code translate="no">ReviewsFetcher</code> then walks <a href="https://docs.github.com/en/rest/pulls/comments" rel="noopener noreferrer"><code translate="no">GET /repos/symfony/symfony/pulls/{id}/comments</code></a> for each PR and rebuilds the actual conversation tree — parent comments with their replies attached. The catch: GitHub's API doesn't guarantee comment ordering. If a reply arrives before its parent, <code translate="no">ReviewsFetcher</code> parks it in a temporary pool (<code translate="no">$repliesTempPool</code>) instead of dropping it, and reattaches it as soon as the parent is found. A small piece of bookkeeping, but without it, any thread where three people argue about tabs vs. spaces out of order would silently lose replies.</p>
<h5>The HTTP decorator chain: Logging &amp; Caching</h5>
<h6>The architecture</h6>
<p>Both fetchers go through the same small <a href="https://symfony.com/doc/current/http_client.html#decorating-the-client" rel="noopener noreferrer">HTTP decorator chain</a>:</p>
<pre class="mermaid d-flex flex-column m-2 justify-content-center align-items-center">
flowchart TD
    A["GithubHttpClient (scoping + Bearer auth)"] --&gt; B["CachedHttpClient (filesystem cache, 365d TTL)"]
    B --&gt; C["LoggedHttpClient (structured logging)"]
    C --&gt; D["HttpClient::create() (Symfony native)"]
</pre>
<p>Each decorator adds one responsibility:</p>
<pre><code class="language-php hljs php" translate="no"><span class="hljs-keyword">final</span> readonly <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">CachedHttpClient</span> <span class="hljs-keyword">implements</span> <span class="hljs-title">HttpClientInterface</span>, <span class="hljs-title">ResetInterface</span>
</span>{
    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">__construct</span><span class="hljs-params">(
        private HttpClientInterface $client,
        private FilesystemAdapter $cache,
        private LoggerInterface $logger,
        private array $blacklistedPatterns = [],
    )</span> </span>{
    }

    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">request</span><span class="hljs-params">(string $method, string $url, array $options = [])</span>: <span class="hljs-title">ResponseInterface</span>
    </span>{
        $pattern = array_map(<span class="hljs-keyword">static</span> fn (string $pattern): string =&gt; preg_quote($pattern, <span class="hljs-string">'#'</span>), <span class="hljs-keyword">$this</span>-&gt;blacklistedPatterns)
            |&gt; (<span class="hljs-keyword">static</span> fn ($x): string =&gt; implode(<span class="hljs-string">'|'</span>, $x))
            |&gt; (<span class="hljs-keyword">static</span> fn (string $x): string =&gt; \sprintf(<span class="hljs-string">'#^%s$#'</span>, $x))
        ;
        $httpCall = $method.<span class="hljs-string">' '</span>.$url;
        <span class="hljs-keyword">if</span> (preg_match($pattern, $httpCall, $matches)) {
            <span class="hljs-keyword">return</span> <span class="hljs-keyword">$this</span>-&gt;client-&gt;request($method, $url, $options);
        }

        $key = md5($method.$url);
        $cacheItem = <span class="hljs-keyword">$this</span>-&gt;cache-&gt;getItem($key);
        <span class="hljs-keyword">if</span> ($cacheItem-&gt;isHit()) {
            <span class="hljs-keyword">return</span> $cacheItem-&gt;get();
        }

        $response = <span class="hljs-keyword">new</span> CachedResponse(<span class="hljs-keyword">$this</span>-&gt;client-&gt;request($method, $url, $options));

        $cacheItem-&gt;set($response);
        <span class="hljs-keyword">$this</span>-&gt;cache-&gt;save($cacheItem);

        <span class="hljs-keyword">return</span> $response;
    }
}</code></pre>
<h6>The serialization problem along the way</h6>
<p>This chain contains a trap I already documented in my previous article — Symfony's <a href="https://symfony.com/doc/current/http_client.html#information-related-to-the-response" rel="noopener noreferrer"><code translate="no">HttpClient::getInfo()</code></a> contains a <code translate="no">pause_handler</code> key with a <code translate="no">Closure</code>, impossible to serialize. The <code translate="no">CachedResponse</code> class handles this by filtering out non-serializable values:</p>
<pre><code class="language-php hljs php" translate="no"><span class="hljs-keyword">final</span> readonly <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">CachedResponse</span> <span class="hljs-keyword">implements</span> <span class="hljs-title">ResponseInterface</span>
</span>{
    <span class="hljs-keyword">private</span> int $statusCode;

    <span class="hljs-comment">/** <span class="hljs-doctag">@var</span> array&lt;string, list&lt;string&gt;&gt; */</span>
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">array</span> $headers;

    <span class="hljs-keyword">private</span> string $content;

    <span class="hljs-comment">/** <span class="hljs-doctag">@var</span> array&lt;string|int, mixed&gt; */</span>
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">array</span> $toArray;

    <span class="hljs-comment">/** <span class="hljs-doctag">@var</span> array&lt;string|int, mixed&gt; */</span>
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">array</span> $info;

    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">__construct</span><span class="hljs-params">(ResponseInterface $response)</span>
    </span>{
        <span class="hljs-keyword">$this</span>-&gt;statusCode = $response-&gt;getStatusCode();
        <span class="hljs-keyword">$this</span>-&gt;headers = $response-&gt;getHeaders();
        <span class="hljs-keyword">$this</span>-&gt;content = $response-&gt;getContent();
        <span class="hljs-keyword">$this</span>-&gt;toArray = $response-&gt;toArray();

        <span class="hljs-comment">/** <span class="hljs-doctag">@var</span> array&lt;string|int, mixed&gt; $info */</span>
        $info = $response-&gt;getInfo();
        <span class="hljs-keyword">$this</span>-&gt;info = array_filter($info, <span class="hljs-keyword">static</span> fn ($v): bool =&gt; !$v <span class="hljs-keyword">instanceof</span> \Closure);
    }
}</code></pre>
<p>Without this filter, <a href="https://symfony.com/doc/current/components/cache.html" rel="noopener noreferrer"><code translate="no">FilesystemAdapter</code></a> silently fails — the serialization exception is caught by <code translate="no">DefaultMarshaller</code> with <code translate="no">throwOnSerializationFailure</code> set to <code translate="no">false</code>, and the cache key is quietly ignored.</p>
<h4>Dataset generation</h4>
<p>The <code translate="no">BuildCommand</code> class orchestrates the vectorization pipeline:</p>
<pre><code class="language-php hljs php" translate="no"><span class="hljs-comment">#[AsCommand(</span>
    name: <span class="hljs-keyword">self</span>::NAME,
    description: <span class="hljs-string">"build a RAG over Symfony's official Github repository's code review"</span>,
    help: <span class="hljs-string">'This command is a pre-requisites for the MCP server'</span>,
)]
<span class="hljs-keyword">final</span> readonly <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">BuildCommand</span>
</span>{
    <span class="hljs-keyword">public</span> <span class="hljs-keyword">const</span> string NAME = <span class="hljs-string">'mcp:build'</span>;

    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">__construct</span><span class="hljs-params">(
        private LoggerInterface $logger,
        private DatasetGenerator $datasetGenerator,
        private Builder $builder,
    )</span> </span>{
    }

    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">__invoke</span><span class="hljs-params">(
        #[Option<span class="hljs-params">(description: <span class="hljs-string">'Skip dataset generation and uses dataset cache'</span>, name: <span class="hljs-string">'skip-generation'</span>, shortcut: <span class="hljs-string">'G'</span>)</span>]
        bool $skipGeneration = false,
        #[Option<span class="hljs-params">(description: <span class="hljs-string">'Skip build of dataset cache'</span>, name: <span class="hljs-string">'skip-build'</span>, shortcut: <span class="hljs-string">'B'</span>)</span>]
        bool $skipBuild = false,
    )</span>: <span class="hljs-title">int</span> </span>{
        <span class="hljs-keyword">try</span> {
            <span class="hljs-keyword">if</span> (!$skipGeneration) {
                <span class="hljs-keyword">$this</span>-&gt;datasetGenerator-&gt;generate();
            }

            <span class="hljs-keyword">if</span> (!$skipBuild) {
                <span class="hljs-keyword">$this</span>-&gt;builder-&gt;build();
            }

            <span class="hljs-keyword">return</span> Command::SUCCESS;
        } <span class="hljs-keyword">catch</span> (\Throwable $throwable) {
            <span class="hljs-keyword">$this</span>-&gt;logger-&gt;error($throwable-&gt;getMessage());

            <span class="hljs-keyword">return</span> Command::FAILURE;
        }
    }
}</code></pre>
<h5>The base file</h5>
<p>Once the data is fetched, <code translate="no">DatasetGenerator</code> transforms each PR and its reviews into a structured text file:</p>
<pre><code translate="no">[PULL_REQUEST]
    id: 54321
    author: nicolas-grekas
    author_association: MEMBER
    description:
        [HttpKernel] Fix edge case in exception handling

[REVIEWS]
    [REVIEW_1234]
        replyTo:
        reviewer: stof
        reviewer_association: MEMBER
        file: src/Component/HttpKernel/Event/ExceptionEvent.php
        diff:
            @@ -88,7 +88,7 @@
             public function getThrowable(): ?\Throwable
             {
        comment:
            We should keep the original exception here,
            the wrapper is only for internal use.

        reactions:
            +1: 5
            -1: 0
            laugh: 0
            hooray: 0
            confused: 0
            heart: 0
            rocket: 0
            eyes: 0</code></pre>
<p>These files live in <code translate="no">var/dataset/pull-{id}.txt</code> and serve as the ground truth for vectorization.</p>
<h5>Qdrant integration</h5>
<p>The vector store is wired into the container as <code translate="no">StoreInterface</code>, via Qdrant's <code translate="no">StoreFactory</code>:</p>
<pre><code class="language-php hljs php" translate="no">-&gt;set(StoreInterface::class, Store::class)
    -&gt;autowire()
    -&gt;factory(StoreFactory::create(...))
    -&gt;args([
        <span class="hljs-string">'$collectionName'</span> =&gt; <span class="hljs-string">'reviews'</span>,
        <span class="hljs-string">'$endpoint'</span> =&gt; env(<span class="hljs-string">'QDRANT_DSN'</span>),
        <span class="hljs-string">'$httpClient'</span> =&gt; service(LoggedHttpClient::class),
        <span class="hljs-string">'$embeddingsDimension'</span> =&gt; <span class="hljs-number">768</span>,
        <span class="hljs-string">'$embeddingsDistance'</span> =&gt; <span class="hljs-string">'Cosine'</span>,
    ])

-&gt;set(VectorizerInterface::class, Vectorizer::class)
    -&gt;autowire()
    -&gt;args([
        <span class="hljs-string">'$model'</span> =&gt; <span class="hljs-string">'hf.co/ggml-org/embeddinggemma-300m-qat-q8_0-GGUF:Q8_0'</span>,
    ])</code></pre>
<p>Two details are worth mentioning. </p>
<ol>
<li>The <code translate="no">Vectorizer</code> uses exactly the same <a href="https://ollama.com/" rel="noopener noreferrer">Ollama</a> model as at build time — an embedding model <code translate="no">hf.co/ggml-org/embeddinggemma-300m-qat-q8_0-GGUF:Q8_0</code> producing 768-dimension vectors.
This part is extremely important if you don't want to end up comparing apples with oranges during MCP search. Indeed, a vector generated with a specific model can't be compared with a vector generated with another model.</li>
<li>The store reuses the <code translate="no">LoggedHttpClient</code> decorator, so every Qdrant round-trip benefits from structured logging on top of Symfony's native HTTP client.</li>
</ol>
<h5>Vectorization &amp; storage</h5>
<p>This is where things get hairy — this part alone took me days.</p>
<h6>What happens during vectorization?</h6>
<p>The file from <code translate="no">var/dataset/pull-{id}.txt</code> is read, then sent to <a href="https://ollama.com/" rel="noopener noreferrer">Ollama</a> to ask an embedding model — in my case <code translate="no">hf.co/ggml-org/embeddinggemma-300m-qat-q8_0-GGUF:Q8_0</code> — which generates a 768-dimension vector <em>(a number that depends on the embedding model)</em> before it's sent back to symfony-ai by Ollama, to finally be saved into a vector space in <a href="https://qdrant.tech/" rel="noopener noreferrer">Qdrant</a>.</p>
<pre class="mermaid d-flex flex-column m-2 justify-content-center align-items-center">
sequenceDiagram
    participant AI as Symfony AI (Builder)
    participant Ollama
    participant Model as embeddinggemma-300m
    participant Qdrant

    AI-&gt;&gt;Ollama: vectorize(dataset file content)
    Ollama-&gt;&gt;Model: model inference
    Model--&gt;&gt;Ollama: 768 float values
    Ollama--&gt;&gt;AI: Vector (768 dimensions)
    AI-&gt;&gt;Qdrant: add(VectorDocument)
    Qdrant--&gt;&gt;AI: confirmation
</pre>
<p>Example vector:</p>
<pre><code class="language-bash hljs bash" translate="no">❯ ollama run hf.co/ggml-org/embeddinggemma-300m-qat-q8_0-GGUF:Q8_0 <span class="hljs-string">'Hello world !'</span>
[0.058340553,0.017256556,-0.0023928124,0.062416226,-0.019779362,-0.069838926,0.003351966,0.029903421,0.01617497,0.009088458,-0.024585545,-0.07013172,0.0077750348,0.03643439,-0.02245716,0.02035499,0.005985676,0.008291158,0.013118213,-0.074038,0.014411658,0.011837681,0.027627029,-0.008276582,0.059961967,0.018847544,0.040701613,0.020481525,0.004880007,-0.026033,0.028065553,-0.015691148,-0.06859542,-0.04025022,-0.0046045044,-0.033632968,0.01929922,0.01854895,-0.000545488,-0.37636346,0.05929959,0.0069747632,-0.026434837,0.018491298,-0.00945082,0.0009028575,0.024641853,-0.053274404,-0.043102805,0.0016308841,-0.04315744,-0.0024387056,-0.007468653,-0.03491168,-0.00315068,-0.023274362,-0.0016503683,-0.027008653,-0.0016653507,0.029124975,0.015810343,-0.020706663,0.03261976,-0.015973076,-0.0104695875,0.0027727042,0.03187403,0.25363848,0.016416604,0.027735965,0.009674886,-0.031506274,-0.01176536,-0.060261074,-0.0031871377,-0.01684568,0.029581062,-0.05600014,-0.02623269,0.04637347,-0.04044786,0.0036846441,0.008154481,0.0190515,-0.023374602,-0.011516434,0.01098124,0.008980432,-0.02016589,-0.020812696,0.046533223,0.009594602,-0.033848874,-0.046110246,0.027529772,0.029260637,-0.04092383,0.00048074988,0.01185442,0.0059498977,0.02239185,-0.0014466406,-0.00841961,-0.011190161,0.05701471,-0.015211558,-0.052781906,0.014623615,-0.0012096912,0.029300302,0.0044483966,0.028927691,-0.032093737,0.048145276,0.034030333,0.03062545,0.015509856,-0.008765529,-0.027545273,0.010858431,0.021899927,-0.008150199,0.0034826857,-0.03487983,-0.039671477,0.009751623,-0.019133179,-0.0030401512,-0.010503486,-0.017615957,0.0365356,0.001852526,-0.013896455,0.04652015,-0.049408674,0.0120107075,0.0065035336,0.0004583914,0.0074103116,-0.028435387,-0.0110742645,-0.0012466233,0.0014801751,-0.015979966,-0.028333941,0.0053472416,0.014286039,0.00054261123,0.049599133,-0.026553018,-0.021315206,0.043478087,0.032634746,0.0031313808,-0.0007238099,-0.0033655402,0.02283678,0.012112167,-0.019739753,-0.0080446005,0.018196804,0... (line truncated to 2000 chars)</code></pre>
<h6>Why a sequential approach?</h6>
<p>As explained in the intro, without a dedicated GPU, it's my CPU — more precisely the iGPU integrated into my CPU — that has to do the vectorization work.
Even though the iGPU shares RAM with the CPU and even though 32 GB are available <em>(depending on my system usage, running programs, ...)</em>, RAM speed has nothing to do with graphics card memory, which is far faster and dedicated.
Moreover, the CPU can only process a few operations in parallel, where the GPU executes thousands simultaneously, which makes matrix computations very slow and saturates my machine at 100%.</p>
<p>As a result, only one vectorization at a time is possible.
And so yes, a batch upsert would be faster, but the goal was to build a working pipeline using only local resources.</p>
<p>It's not a limitation of PHP or Qdrant, just a pragmatic trade-off tied to the available hardware.</p>
<h6>File-rename atomicity</h6>
<p>This is the most interesting design decision. Instead of a database table to track processed files, the <code translate="no">Builder</code> uses atomic <code translate="no">rename()</code> calls:</p>
<pre><code translate="no">pull-{id}.txt               → ready to process
processing_pull-{id}.txt    → currently vectorizing
ragged_pull-{id}.txt        → vectorized successfully</code></pre>
<pre><code class="language-php hljs php" translate="no"><span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">build</span><span class="hljs-params">()</span>: <span class="hljs-title">void</span>
</span>{
    <span class="hljs-keyword">$this</span>-&gt;store-&gt;setup(); <span class="hljs-comment">// ManagedStoreInterface</span>

    <span class="hljs-keyword">$this</span>-&gt;recoverOrphanedProcessingFiles();

    <span class="hljs-keyword">foreach</span> (scandir(<span class="hljs-keyword">$this</span>-&gt;datasetDirectory) <span class="hljs-keyword">as</span> $file) {
        <span class="hljs-keyword">if</span> (<span class="hljs-string">'.'</span> === $file || <span class="hljs-string">'..'</span> === $file
            || str_starts_with($file, <span class="hljs-keyword">self</span>::RAGGED_PREFIX)
            || str_starts_with($file, <span class="hljs-keyword">self</span>::PROCESSING_PREFIX)) {
            <span class="hljs-keyword">continue</span>;
        }
        <span class="hljs-keyword">if</span> (!rename(<span class="hljs-keyword">$this</span>-&gt;datasetDirectory.<span class="hljs-string">'/'</span>.$file, <span class="hljs-keyword">$this</span>-&gt;datasetDirectory.<span class="hljs-string">'/'</span>.(<span class="hljs-string">'processing_'</span>.$file))) {
            <span class="hljs-keyword">continue</span>; <span class="hljs-comment">// another process claimed it</span>
        }

        <span class="hljs-keyword">try</span> {
            $content = file_get_contents(<span class="hljs-keyword">$this</span>-&gt;datasetDirectory.<span class="hljs-string">'/processing_'</span>.$file);
            $vector = <span class="hljs-keyword">$this</span>-&gt;vectorizer-&gt;vectorize($content);
            <span class="hljs-keyword">if</span> (<span class="hljs-number">768</span> !== \count($vector-&gt;getData())) {
                <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> \RuntimeException(<span class="hljs-string">'Wrong dimensions'</span>);
            }

            <span class="hljs-keyword">$this</span>-&gt;store-&gt;add(<span class="hljs-keyword">new</span> VectorDocument(
                id: (int) preg_replace(<span class="hljs-string">'/[^0-9]/'</span>, <span class="hljs-string">''</span>, $file),
                vector: $vector,
                metadata: <span class="hljs-keyword">new</span> Metadata([<span class="hljs-string">'content'</span> =&gt; $content]),
            ));

            rename(<span class="hljs-keyword">$this</span>-&gt;datasetDirectory.<span class="hljs-string">'/processing_'</span>.$file, <span class="hljs-keyword">$this</span>-&gt;datasetDirectory.<span class="hljs-string">'/ragged_'</span>.$file);
        } <span class="hljs-keyword">catch</span> (\Throwable $e) {
            rename(<span class="hljs-keyword">$this</span>-&gt;datasetDirectory.<span class="hljs-string">'/processing_'</span>.$file, <span class="hljs-keyword">$this</span>-&gt;datasetDirectory.<span class="hljs-string">'/'</span>.$file); <span class="hljs-comment">// rollback</span>
        }
    }
}</code></pre>
<p>Crash-safe by design: if the script dies mid-way, <code translate="no">recoverOrphanedProcessingFiles()</code> re-queues orphaned <code translate="no">processing_*</code> files on the next run. No locks, no database, no race conditions.</p>
<p>Yes, the code shows that, despite what I said above:</p>
<blockquote>
<p>As a result, only one vectorization at a time is possible.</p>
</blockquote>
<p>Yes, I still tried 🤣.</p>
<h4>Usage</h4>
<p>The CLI exposes two commands:</p>
<pre><code class="language-bash hljs bash" translate="no"><span class="hljs-comment"># Full pipeline: fetch → dataset → vectorize</span>
php bin/console mcp:build

<span class="hljs-comment"># Re-vectorize without re-fetching</span>
php bin/console mcp:build --skip-generation

<span class="hljs-comment"># Re-fetch without re-vectorizing</span>
php bin/console mcp:build --skip-build</code></pre>
<h3 id="the-mcp-server">The MCP server</h3>
<p>For the LLM to have access to these freshly indexed reviews and run its searches by itself, you have to give it access.
Everything happens through the MCP protocol following this simplified call pipeline:</p>
<pre class="mermaid d-flex flex-column m-2 justify-content-center align-items-center">
flowchart TD
    H["MCP Tool Call (review_as_group/person)"] --&gt; I["Retriever (semantic search)"]
    I --&gt; G["Qdrant (collection: reviews)"]
    I --&gt; J["LLM Client (Claude Desktop)"]
</pre>
<p>The generation pipeline (fetch → dataset → vectorize) and the serving pipeline (retrieve → respond) share a single point in common: the <a href="https://qdrant.tech/" rel="noopener noreferrer">Qdrant</a> collection. </p>
<p>The MCP server exposes two <strong>tools</strong> and four <strong>prompts</strong>:</p>
<table>
<thead>
<tr>
<th>Tool</th>
<th>Role</th>
</tr>
</thead>
<tbody>
<tr>
<td><code translate="no">review_as_group</code></td>
<td>Search by affiliation group (MEMBER, CONTRIBUTOR, NONE)</td>
</tr>
<tr>
<td><code translate="no">review_as_person</code></td>
<td>Search by a specific reviewer (nicolas-grekas, stof, etc.)</td>
</tr>
</tbody>
</table>
<table>
<thead>
<tr>
<th>Prompt</th>
<th>Role</th>
</tr>
</thead>
<tbody>
<tr>
<td><code translate="no">review_as_group</code></td>
<td>Formatted message using <code translate="no">review_as_group</code></td>
</tr>
<tr>
<td><code translate="no">review_as_person</code></td>
<td>Formatted message using <code translate="no">review_as_person</code></td>
</tr>
<tr>
<td><code translate="no">get_stofed</code></td>
<td>Forces the reviewer to "stof" — the most prolific reviewer of Symfony Core</td>
</tr>
<tr>
<td><code translate="no">hq_review</code></td>
<td>Multi-review: queries 14 reviewers and synthesizes a markdown report</td>
</tr>
</tbody>
</table>
<h4>Retrieval: MCP Tools</h4>
<p>When a user sends a query through an MCP tool, here is what happens:</p>
<ol>
<li>The tool builds a query combining reviewer, file path, and diff</li>
<li><code translate="no">RetrieverInterface::retrieve()</code> vectorizes the query via Ollama</li>
<li>A cosine similarity search runs on Qdrant</li>
<li>The matching <code translate="no">VectorDocument</code> objects are returned</li>
<li>Their <code translate="no">metadata['content']</code> is extracted and assembled into context</li>
</ol>
<pre class="mermaid d-flex flex-column m-2 justify-content-center align-items-center">
sequenceDiagram
    participant Client as Claude Desktop
    participant MCP as MCP Server (stdio)
    participant Tool as review_as_person
    participant Retriever
    participant Ollama
    participant Qdrant

    Client-&gt;&gt;MCP: call_tool(review_as_person)
    MCP-&gt;&gt;Tool: __invoke(pseudonym, file, diff, limit)
    Tool-&gt;&gt;Retriever: retrieve(query, ['limit' =&gt; limit])
    Retriever-&gt;&gt;Ollama: vectorize(query)
    Ollama--&gt;&gt;Retriever: query vector
    Retriever-&gt;&gt;Qdrant: cosine similarity search
    Qdrant--&gt;&gt;Retriever: closest documents
    Retriever--&gt;&gt;Tool: VectorDocument[]
    Tool--&gt;&gt;MCP: found reviews (text)
    MCP--&gt;&gt;Client: tool result
</pre>
<pre><code class="language-php hljs php" translate="no"><span class="hljs-comment">#[McpTool(</span>
    name: <span class="hljs-keyword">self</span>::NAME,
    description: <span class="hljs-string">'Tool retrieving a `limit` amount of reviews from `pseudonym` github user based on a given git a complete `file` path and `diff`. Results are separated by `\n\n---\n\n`.'</span>,
    annotations: <span class="hljs-keyword">new</span> ToolAnnotations(<span class="hljs-string">'Review matching file diff as github user'</span>, <span class="hljs-keyword">true</span>, <span class="hljs-keyword">false</span>, <span class="hljs-keyword">true</span>, <span class="hljs-keyword">false</span>)
)]
<span class="hljs-keyword">final</span> readonly <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">ReviewAsPersonMatchingFileDiffTool</span>
</span>{
    <span class="hljs-keyword">public</span> <span class="hljs-keyword">const</span> string NAME = <span class="hljs-string">'review_as_person'</span>;

    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">__invoke</span><span class="hljs-params">(string $pseudonym, string $file, string $diff, int $limit)</span>: <span class="hljs-title">string</span>
    </span>{
        $query = <span class="hljs-string">&lt;&lt;&lt;TXT
        reviewer: <span class="hljs-subst">$pseudonym</span>
        file: <span class="hljs-subst">$file</span>
        diff:
        <span class="hljs-subst">{$diff}</span>
        TXT;

        try {
            <span class="hljs-subst">$retrieved</span> = <span class="hljs-subst">$this</span>-&gt;retriever-&gt;retrieve(<span class="hljs-subst">$query</span>, ['limit' =&gt; <span class="hljs-subst">$limit</span>]);

            <span class="hljs-subst">$return</span> = [];
            foreach (<span class="hljs-subst">$retrieved</span> as <span class="hljs-subst">$document</span>) {
                <span class="hljs-subst">$content</span> = <span class="hljs-subst">$document</span>-&gt;getMetadata()['content'] ?? null;
                if (null === <span class="hljs-subst">$content</span> || !\is_string(<span class="hljs-subst">$content</span>)) {
                    continue;
                }
                <span class="hljs-subst">$return</span>[] = <span class="hljs-subst">$content</span>;
            }
        } catch (\Throwable <span class="hljs-subst">$exception</span>) {
            return 'Error retrieving reviews: '.<span class="hljs-subst">$exception</span>-&gt;getMessage();
        }

        if (0 === \count(<span class="hljs-subst">$return</span>)) {
            return 'No reviews found.';
        }

        return implode("\n\n---\n\n", <span class="hljs-subst">$return</span>);
    }
}</span></code></pre>
<h4>The HQ Review prompt</h4>
<p>The <code translate="no">hq_review</code> prompt is the showcase feature. It queries 14 top-tier Symfony reviewers (<a href="https://github.com/GromNaN" rel="noopener noreferrer">GromNaN</a>, <a href="https://github.com/dunglas" rel="noopener noreferrer">dunglas</a>, <a href="https://github.com/welcoMattic" rel="noopener noreferrer">welcoMattic</a>, <a href="https://github.com/nicolas-grekas" rel="noopener noreferrer">nicolas-grekas</a>, <a href="https://github.com/chalasr" rel="noopener noreferrer">chalasr</a>, <a href="https://github.com/stof" rel="noopener noreferrer">stof</a>, <a href="https://github.com/yceruto" rel="noopener noreferrer">yceruto</a>, <a href="https://github.com/mtarld" rel="noopener noreferrer">mtarld</a>, <a href="https://github.com/OskarStark" rel="noopener noreferrer">OskarStark</a>, <a href="https://github.com/xabbuh" rel="noopener noreferrer">xabbuh</a>, <a href="https://github.com/lyrixx" rel="noopener noreferrer">lyrixx</a>, <a href="https://github.com/kbond" rel="noopener noreferrer">kbond</a>, <a href="https://github.com/jderusse" rel="noopener noreferrer">jderusse</a>, <a href="https://github.com/alexandre-daubois" rel="noopener noreferrer">alexandre-daubois</a>), collects their historical feedback on the same file/diff, and asks the LLM to synthesize a markdown report with feedback weighted per reviewer.</p>
<p>The result is a code review that reads like a mini-symposium of Symfony Core maintainers — without requiring their time.</p>
<h4>Usage</h4>
<pre><code class="language-bash hljs bash" translate="no">docker build -t symfony-reviewer-mcp-cli /path/to/Dockerfile
docker run -i --rm --add-host=host.docker.internal:host-gateway -e QDRANT_DSN=http://host.docker.internal:6333 -e OLLAMA_DSN=http://host.docker.internal:11434 symfony-reviewer-mcp-cli</code></pre>
<p>Then configure Claude Desktop (or any MCP client) by adding the server to <code translate="no">claude_desktop_config.json</code>:</p>
<pre><code class="language-json hljs json" translate="no">{
  <span class="hljs-attr">"mcpServers"</span>: {
    <span class="hljs-attr">"symfony-reviewer"</span>: {
      <span class="hljs-attr">"command"</span>: <span class="hljs-string">"docker"</span>,
      <span class="hljs-attr">"args"</span>: [
            <span class="hljs-string">"run"</span>, <span class="hljs-string">"-i"</span>, <span class="hljs-string">"--rm"</span>,
            <span class="hljs-string">"--add-host=host.docker.internal:host-gateway"</span>,
            <span class="hljs-string">"-e"</span>, <span class="hljs-string">"QDRANT_DSN=http://host.docker.internal:6333"</span>,
            <span class="hljs-string">"-e"</span>, <span class="hljs-string">"OLLAMA_DSN=http://host.docker.internal:11434"</span>,
            <span class="hljs-string">"symfony-reviewer-mcp-cli"</span>
        ]
    }
  }
}</code></pre>
<h2 id="environment-variables">Environment variables</h2>
<p>All the configuration goes through <code translate="no">.env</code> variables:</p>
<table>
<thead>
<tr>
<th>Variable</th>
<th>Role</th>
</tr>
</thead>
<tbody>
<tr>
<td><code translate="no">GITHUB_TOKEN</code></td>
<td><a href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens" rel="noopener noreferrer">GitHub personal access token</a></td>
</tr>
<tr>
<td><code translate="no">QDRANT_DSN</code></td>
<td>Qdrant service URL</td>
</tr>
<tr>
<td><code translate="no">OLLAMA_DSN</code></td>
<td>Ollama service URL</td>
</tr>
<tr>
<td><code translate="no">BLACKLISTED_PATTERN</code></td>
<td>JSON array of URL patterns to exclude from the cache</td>
</tr>
<tr>
<td><code translate="no">APP_VERSION</code></td>
<td>Version displayed in the MCP metadata</td>
</tr>
</tbody>
</table>
<h2 id="lessons-learned">Lessons learned</h2>
<p>This project taught me how the MCP protocol works, what a vector database is and how to use it. The architecture presented here is fairly standard for a RAG pipeline, but it was built with Symfony and can serve as a base for other experiments or use cases.</p>
<h3 id="what-worked-well">What worked well</h3>
<ul>
<li><strong>File-rename atomicity</strong>: This pattern is elegant, crash-safe, and requires no infrastructure. Every PHP developer understands <code translate="no">rename()</code>. No Redis locks, no database migrations.</li>
<li><strong>Incremental pipeline</strong>: Re-running <code translate="no">mcp:build</code> with existing files is a no-op. Iteration is fast — you can tweak the vectorization and only process new files.</li>
<li><strong>PHP 8.x features</strong>: Constructor promotion, readonly properties, the pipe operator (<code translate="no">|&gt;</code>), and invokable commands make the code significantly cleaner.</li>
<li><strong><a href="https://ollama.com/" rel="noopener noreferrer">Ollama</a> locally</strong>: embeddinggemma-300m runs on CPU without issues. 768 dimensions is modest enough for fast queries but rich enough for semantic search over code reviews.</li>
</ul>
<h3 id="what-needs-improvement">What needs improvement</h3>
<p>Had I had a more powerful machine with a dedicated GPU or unified memory (👋 Mac owners), I might have been able to change the following:</p>
<ul>
<li><strong>Naive Qdrant interaction</strong>: Documents are added one at a time. A batch upsert would be significantly faster for large volumes.</li>
<li><strong>No incremental RAG updates</strong>: The pipeline is add-only. There's no "builtin" mechanism <em>(it's possible via the Qdrant dashboard)</em> to purge or update existing vectors when PR comments are edited on GitHub. Which is a real/false problem in itself, since you rarely find new comments on already-merged pull requests.</li>
<li><strong>Add your own conventions</strong>: I added Symfony's reviews, but you too can modify and adapt the code to rely on an additional data corpus, like your colleagues' reviews.</li>
</ul>
<h3 id="what-i-d-do-differently">What I'd do differently</h3>
<ol>
<li><strong>Batch vectorization</strong>: Group documents and vectorize in batches for higher throughput</li>
<li><strong>Async fetching</strong>: The data fetching phase is sequential per PR. Concurrent requests would significantly cut the initial build time.</li>
<li><strong>GitHub webhook</strong>: Instead of periodically rebuilding, listen for merged PR events and update the dataset incrementally</li>
<li><strong>Embedding model evaluation</strong>: 768 dimensions works well, but I should compare different models: smaller ones (like <a href="https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2" rel="noopener noreferrer">all-MiniLM-L6-v2</a>, 384 dimensions) to improve performance, or much larger ones to assess the potential gain in search quality.</li>
<li><strong>Wider context: PR comments and full diff</strong>: the current dataset only keeps review comments attached to a specific diff. It ignores general PR comments (issue comments, description) and above all the PR's full diff — a reviewer never judges an isolated line, they judge it in the context of the whole change. Injecting both would give the model far more material to understand why a review was worded the way it was.</li>
<li><strong>Exploit the <code translate="no">metadata</code> field for GitHub's raw JSON</strong>: <a href="https://github.com/symfony/ai-store" rel="noopener noreferrer"><code translate="no">symfony/ai-store</code></a> attaches a <code translate="no">Metadata</code> object (<code translate="no">Symfony\AI\Store\Document\Metadata</code>) to every <code translate="no">VectorDocument</code>, which travels all the way to the chosen store. With Qdrant, this <code translate="no">Metadata</code> maps exactly to the notion of <em>payload</em>: an arbitrary JSON object attached to each point, natively indexable and filterable — for example filtering by <code translate="no">reviewer_association</code>, by date, or by reaction count, without re-parsing the dataset text. Today, only <code translate="no">content</code> (the assembled dataset file text) is stored there; I would have added GitHub's raw API response (PR + review + reactions), to keep an exploitable trace independent of the generated text format.</li>
<li><strong>Exploit the GitHub JSON reactions</strong>: Reactions would actually lend themselves to more than a simple filter: they could weight the search score itself, not just be returned in the <code translate="no">content</code>. Since version 1.14, Qdrant offers a <a href="https://qdrant.tech/documentation/search/hybrid-queries/" rel="noopener noreferrer"><em>Formula Query</em></a> that lets you compose a final score from the initial similarity score and payload fields, in a single re-ranking formula. A review with ten <code translate="no">+1</code> would then rank above an isolated review with zero reactions, at equal vector similarity — a way to surface the opinions the community itself validated. A feature worth exploring.</li>
</ol>
<h2 id="does-it-work">Does it work?</h2>
<p>The big question: "are the reviews produced actually better?".</p>
<p>Answering Yes would be partially wrong. Indeed, today my evaluation remains essentially based on a general feeling rather than on real, tangible, quantifiable data.
The generated answers seem to me far more in the tone of a review that would have been made on Symfony's GitHub repository, and therefore closer to its conventions, than the answers obtained with a context-free LLM.
Finally, since an LLM is "probabilistic" by nature, I don't believe it's relevant to assert, based on my own observations and personal feeling alone, that the functioning of a tool is established.</p>
<p>Here is a example of a review done on that project to give you an idea :</p>
<pre><code class="language-markdown hljs markdown" translate="no">---

<span class="hljs-section">### 1. fabpot</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`src/HTTP/CachedHttpClient.php`</span>, <span class="hljs-code">`src/HTTP/CachedResponse.php`</span>

<span class="hljs-quote">&gt; Thanks for the PR! A few things before we can merge:</span>
<span class="hljs-quote">&gt;
&gt; * Why is the cache key based on the URL only? For `POST` requests the body matters, otherwise all `/api/embed` calls will return the same result. This is a real bug, not an optimization issue.</span>
<span class="hljs-quote">&gt; * Please add a test covering the "non-buffered" response case — `getContent()` then `toArray()` will fail on `EventSourceHttpClient` responses.</span>
<span class="hljs-quote">&gt; * Do we need a `CHANGELOG` entry for the `CachedHttpClient`? I think we can keep it internal.</span>
<span class="hljs-quote">&gt;
&gt; Otherwise the approach is clean. Once the cache key is fixed, we can squash the commits.</span>

---

<span class="hljs-section">### 2. nicolas-grekas</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`src/HTTP/CachedResponse.php`</span>, <span class="hljs-code">`src/Kernel.php`</span>

<span class="hljs-quote">&gt; Reading the response body twice is going to blow up the moment the response is not buffered. `toArray()` internally calls `getContent()` — so snapshot the content once and `json_decode` it, don't call both.</span>
<span class="hljs-quote">&gt;
&gt; Also, the decorated chain is wrong: `service(LoggedHttpClient::class)` no longer resolves to the logger once you decorate it with `CachedHttpClient`. Qdrant and Ollama are being routed through the GitHub cache without intent. Decorate a dedicated alias, e.g. `cached.github.http_client`, and keep `HttpClientInterface` as the plain chain.</span>
<span class="hljs-quote">&gt;
&gt; One more: `getContent(false)` is being snapshotted eagerly in the constructor — that defeats lazy streaming for large GitHub responses. Buffer lazily.</span>

---

<span class="hljs-section">### 3. stof</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`src/Kernel.php`</span>

<span class="hljs-quote">&gt; Service wiring nit: decoration replaces the decorated id, so `$httpClient =&gt; service(LoggedHttpClient::class)` gives you the `CachedHttpClient`, not the logging client. That's a scope leak — the Qdrant store is now coupled to a cache tuned for the GitHub API (see `BLACKLISTED_PATTERN`).</span>
<span class="hljs-quote">&gt;
&gt; I'd define a dedicated `logged.http_client` service for the store/platform and only decorate `cached.github.http_client` for the GitHub fetchers. Also check the `-&gt;decorate(..., priority: 1)` priorities — with equal priorities the order of application is by declaration order, which is fragile to read.</span>
<span class="hljs-quote">&gt;
&gt; And the `json:BLACKLISTED_PATTERN` env var — document its format in the README.</span>

---

<span class="hljs-section">### 4. weaverryan</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`src/MCP/Tools/ReviewAsPersonMatchingFileDiffTool.php`</span>

<span class="hljs-quote">&gt; Hey! Love the ergonomics of this tool — the `review_as_person` name makes the intent super clear. Great job composing the query with the pseudonym, file, and diff.</span>
<span class="hljs-quote">&gt;
&gt; One DX thought: when no reviews are found we return `No reviews found.` — that's good. But maybe give the caller a hint that they can reduce `limit` or widen the diff? Small thing, ignore if you want.</span>
<span class="hljs-quote">&gt;
&gt; Also, the metadata `content` check with the warning log is nice defensive coding. Keep it up! 🎉</span>

---

<span class="hljs-section">### 5. derrabus</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`src/HTTP/CachedHttpClient.php`</span>

<span class="hljs-quote">&gt; Two things:</span>
<span class="hljs-quote">&gt;
&gt; 1. `$key = md5($method.$url)` — please include the serialized options/body. Hash collisions here are silent correctness bugs, not just perf issues.</span>
<span class="hljs-quote">&gt; 2. The `preg_match` on a compiled regex built via pipe chains is clever but hard to read. Since the blacklist is a list of exact strings, why not use `in_array` or a simple `str_starts_with` on the pattern list? Keep it simple.</span>
<span class="hljs-quote">&gt;
&gt; Also, the code is `final readonly` — good. But it implements `ResetInterface`; make sure the decorated inner `reset()` is reachable in `withOptions()` clones (it is, since you forward to the scoped client — just double-check the cache/lifecycle after cloning).</span>

---

<span class="hljs-section">### 6. xabbuh</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`src/HTTP/CachedResponse.php`</span>

<span class="hljs-quote">&gt; I have concerns about the snapshot in the constructor:</span>
<span class="hljs-quote">&gt;
&gt; * Calling `$response-&gt;getContent()` eagerly downloads and stores the whole payload. For the GitHub fetchers this is fine, but a general-purpose cache should stream lazily.</span>
<span class="hljs-quote">&gt; * More importantly, `getContent()` followed by `toArray()` breaks for responses that disabled buffering (the Ollama `EventSourceHttpClient` forces `buffer =&gt; false`). This is an exploitable/observable crash — at minimum it should throw a clear `TransportException` or read once.</span>
<span class="hljs-quote">&gt; * `getInfo()` filtering out closures is a nice touch, but the returned array is shallow — nested closures could still leak. Use a recursive filter or `json_encode/decode` the info array.</span>

---

<span class="hljs-section">### 7. Tobion</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`src/HTTP/CachedHttpClient.php`</span>

<span class="hljs-quote">&gt; The pipeline operator chains in `request()` are over-engineered for building a regex. `implode('|', array_map(preg_quote(...), $this-&gt;blacklistedPatterns))` is enough. As written, an empty blacklist produces the pattern `#^$#` which would match an empty call string — harmless, but misleading.</span>
<span class="hljs-quote">&gt;
&gt; More importantly: cache invalidation. There is none — responses are cached for a year (`defaultLifetime`). GitHub data changes; the fetchers need a way to bust the cache (e.g. include a version/tag in the key or a TTL per-URL). Otherwise reviews fetched once are served stale forever.</span>

---

<span class="hljs-section">### 8. mpdude</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`src/RAG/Builder.php`</span> (via <span class="hljs-code">`Store`</span>)

<span class="hljs-quote">&gt; The Qdrant indexing loop swallows exceptions and logs `Index failed` — but the build then *continues* (I saw `Indexing document continues`). If a document fails vectorization, subsequent documents are still sent. That means the collection is only partially populated, and `review_as_person` will silently return "No reviews found" or partial results.</span>
<span class="hljs-quote">&gt;
&gt; Please make the build fail-fast or at least surface a summary count at the end ("indexed X / failed Y") so operators know the dataset is incomplete. Right now nothing tells us that only ~1106 of 8691 documents made it in.</span>

---

<span class="hljs-section">### 9. WouterJ</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`src/Kernel.php`</span>

<span class="hljs-quote">&gt; The container config reads really well — the decoration chain is easy to follow. Nice use of `env('json:BLACKLISTED_PATTERN')` and `StoreFactory::create(...)`.</span>
<span class="hljs-quote">&gt;
&gt; Minor: the `logged.http_client` vs `cached.github.http_client` distinction is muddied because both decorators use `priority: 1` and decorate each other's ids. I'd give them explicit service aliases (`github.logged.http_client`, etc.) so the intent is obvious. Also the unused `'stream_handler'` monolog handler and the commented-out `http` transport block could be cleaned up before merge.</span>

---

<span class="hljs-section">### 10. alexislefebvre</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`tests/HTTP/CachedHttpClientTest.php`</span>

<span class="hljs-quote">&gt; Nice test coverage — you test blacklist skipping, persistence across instances, `withOptions` cloning, and `reset`. 👍</span>
<span class="hljs-quote">&gt;
&gt; Missing cases I'd love to see:</span>
<span class="hljs-quote">&gt;
&gt; 1. A `POST` request with a body — assert that different bodies don't collide in the cache (this would catch the `md5(method.url)` bug).</span>
<span class="hljs-quote">&gt; 2. A non-buffered/streaming response (`MockResponse` with `buffer =&gt; false` is hard to fake; but at least an SSE-like response) going through `CachedResponse` without throwing.</span>
<span class="hljs-quote">&gt; 3. The cache should not be hit for `POST`/`PUT` (or should include the body in the key) — please encode that expectation in a test.</span>

---

<span class="hljs-section">### 11. Nyholm</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`src/HTTP/LoggedHttpClient.php`</span>, <span class="hljs-code">`src/HTTP/CachedHttpClient.php`</span>

<span class="hljs-quote">&gt; As the http-client component maintainer: don't re-implement caching. Symfony's `HttpClient` supports a `cache` option natively via the `http_cache` from the contracts, and it handles cache keys, headers, `Vary`, and ETags properly. Rolling your own `md5(method.url)` cache key is a regression waiting to happen (it already broke on POST bodies).</span>
<span class="hljs-quote">&gt;
&gt; If you keep the custom decorator, at least delegate to `CacheItemPoolInterface` semantics and include the request payload + relevant headers in the key. And please make `stream()` forward correctly — it does, but note that cached responses can never stream, which may surprise callers.</span>
<span class="hljs-quote">&gt;
&gt; Also: `FileSystemAdapter` on a single Docker container is fine, but for multi-instance deploys you'll want a shared pool (Redis). Worth a comment.</span>

---

<span class="hljs-section">### 12. jderusse</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`src/RAG/Builder.php`</span>, <span class="hljs-code">`src/HTTP/CachedHttpClient.php`</span>

<span class="hljs-quote">&gt; The elephant in the room: the per-document vectorization loop is serial. 8691 documents, ~10s each — that's ~24h to build the RAG, and with the cache bug most embeddings were identical (same URL → same key). That's why retrieval feels broken.</span>
<span class="hljs-quote">&gt;
&gt; Fixes I'd push for:</span>
<span class="hljs-quote">&gt; * Parallelize vectorization with Symfony's `AsyncResponse` / `stream()` over batches.</span>
<span class="hljs-quote">&gt; * Include the body in the cache key (obviously).</span>
<span class="hljs-quote">&gt; * Index the docs that failed (`Index failed` ×636) with retry/backoff.</span>
<span class="hljs-quote">&gt;
&gt; Also `Builder` should checkpoint progress so a crash doesn't restart from zero.</span>

---

<span class="hljs-section">### 13. chalasr</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`src/MCP/Tools/ReviewAsPersonMatchingFileDiffTool.php`</span>, <span class="hljs-code">`src/Command/ServeCommand.php`</span>

<span class="hljs-quote">&gt; Tool ergonomics are good — `limit` is explicit, errors are caught and surfaced. But: the error path returns `Error retrieving reviews: {message}` as a *successful* tool result. For an MCP server, real failures should be proper exceptions/tool errors, not strings, otherwise the client can't distinguish "no data" from "server broken".</span>
<span class="hljs-quote">&gt;
&gt; Also, the query string embeds the raw diff with no size guard — a huge diff will blow the embedding context window. Truncate or chunk the diff.</span>
<span class="hljs-quote">&gt;
&gt; And the serve command: make sure `APP_DEBUG` is off in prod and there's a graceful shutdown on SIGTERM.</span>

---

<span class="hljs-section">### 14. yceruto</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`src/Kernel.php`</span>, <span class="hljs-code">`src/HTTP/CachedHttpClient.php`</span>

<span class="hljs-quote">&gt; The routing of the HTTP decorators deserves attention: `LoggedHttpClient::class` is decorated by `CachedHttpClient`, so every consumer referencing it — including the Qdrant store — ends up behind the GitHub cache. That coupling is accidental.</span>
<span class="hljs-quote">&gt;
&gt; I'd restructure like this:</span>
<span class="hljs-quote">&gt; ```</span>
<span class="hljs-quote">&gt; HttpClientInterface          # plain</span>
<span class="hljs-quote">&gt;  └─ logged.http_client       # logging only (for Qdrant/Ollama)</span>
<span class="hljs-quote">&gt;  └─ cached.github.http_client # cache + github token (for GitHub fetchers)</span>
<span class="hljs-quote">&gt; ```</span>
<span class="hljs-quote">&gt; Two separate chains, no cross-decorating. Then the cache key issue (URL-only, no body) also only affects GitHub GETs, which is safe.</span>
<span class="hljs-quote">&gt;
&gt; After that, the 14 `review_as_person` calls will stop returning the `buffering is disabled` error and start returning real reviews.</span>

---</code></pre>
<h2 id="to-conclude">To conclude</h2>
<div style="width:100%;height:0;padding-bottom:56%;position:relative;">
    <iframe src="https://giphy.com/embed/NRiRXQTwbijNba2l2l" width="100%" height="100%" style="position:absolute" frameborder="0" class="giphy-embed" allowfullscreen></iframe>
</div>
<p><a href="https://giphy.com/gifs/The-Animal-Crackers-Movie-baking-try-it-NRiRXQTwbijNba2l2l">via GIPHY</a></p>
<p>Try it yourself — the <a href="https://github.com/ktherage/symfony-review-mcp" rel="noopener noreferrer">project</a> is designed to be self-contained and independent. The data is publicly accessible, and you can spin up a Qdrant and Ollama instance easily with <a href="https://www.docker.com/" rel="noopener noreferrer">Docker</a>.</p>
<p>To install it:</p>
<pre><code class="language-bash hljs bash" translate="no">git <span class="hljs-built_in">clone</span> https://github.com/ktherage/symfony-review-mcp
<span class="hljs-built_in">cd</span> symfony-review-mcp
docker compose run --rm cli composer install
docker compose run --rm cli bin/console mcp:build
docker compose up -d</code></pre>
<p>The most surprising thing I learned building this project: PHP is a perfectly viable language for RAG pipelines. Symfony's HttpClient, Cache, and Console components, combined with the <a href="https://github.com/symfony/ai" rel="noopener noreferrer"><code translate="no">symfony/ai-*</code></a> packages, handle everything from HTTP decoration to vector database operations. </p>
<p>You don't need Python to do semantic search.</p>
<p>Sometimes the best tool for the job is the one you already master.</p>]]>
    </content>
  </entry>
</feed>
