Het bouwen van een RAG-pipeline voor semantisch codeonderzoek: Een ontwikkelaardagboek en veldnotities

Deel 1: Parsen, chunking en vectorisatie

Enige tijd geleden stelden we ons ten doel om het best mogelijke platform voor semantisch codeonderzoek te bouwen: een RAG-pipeline (Retrieval-Augmented Generation) die LLM-agenten precieze, citeerbare bewijzen uit echte repositories levert, in plaats van simpelweg te vertrouwen op wat een grep-commando toevallig naar boven haalt. De uiteindelijke oplossing werd Air Context. We hebben het werkend gekregen, in productie genomen en onderweg veel waardevolle lessen geleerd. In deze serie posts delen we de zaken die we graag op dag één hadden geweten.

Coding agents zijn ongetwijfeld de grootste technologische sprong voor softwareontwikkeling van dit decennium. Agents en frontier-modellen bewijzen hun bekwaamheid in het omgaan met schijnbaar onoverkomelijke codecomplexiteit om ogenschijnlijk betrouwbare code te produceren.

Naarmate meer ontwikkelprocessen agent-gestuurd worden, worden de efficiëntie van de agent en de kwaliteit van de geproduceerde code steeds belangrijker. De vraag is niet zozeer óf een agent de taak kan voltooien — want met genoeg tijd en tokens zal hij dat ongetwijfeld doen — maar hoeveel tijd, inspanning en sturing er nodig is om resultaten van productieniveau te genereren. Specifiek voor grootschalige codebases besteedt de agent veel tijd aan het zoeken naar de relevante stukken code voor de feature waaraan hij werkt en het toevoegen daarvan aan de context.

Waarom semantisch zoeken essentieel is

Om de juiste codesnippets te vinden, grijpt de agent terug op traditionele tools voor codeonderzoek, zoals trefwoordzoeken en grep. Deze tools zijn echter beperkt, omdat ze vereisen dat de agent vooraf precies weet naar welke tekst hij moet zoeken. Een agent die zoekt naar waar sessietokens worden vernieuwd, kan bijvoorbeeld niet blindelings vertrouwen op het feit dat de code behulpzaam het woord "refresh" bevat.

Om door abstracte domeinen te redeneren, heeft de agent de mogelijkheid nodig om te zoeken naar code op basis van betekenis, ook wel semantisch zoeken genoemd. Dit is waar Retrieval-Augmented Generation (RAG) in beeld komt. Als we de broncode kunnen indexeren op een manier die de semantiek vastlegt en de agent vervolgens in staat stellen om de relevante delen op aanvraag op te halen via een tekstzoekopdracht, creëren we een interface die optimaal gebruikmaakt van de sterktes van de agent.

Van prototype naar productie

Zoals veel goede ideeën in het tijdperk van agents is een native prototype-implementatie extreem eenvoudig. Een goed geëvalueerde oplossing voor productie is dat zeker niet. In deze serie blogposts willen we delen wat er komt kijken bij het maken van een effectief RAG-systeem, evenals de verkeerde afslagen die we hebben genomen tijdens onze reis om Air Context te creëren. We behandelen elke fase, van voorverwerking tot opslag en agent-integratie, waarbij we technische context en advies bieden.

Dit eerste deel van de serie behandelt de beginfasen van de pipeline: parsen en chunking, waarbij ruwe bronbestanden worden verdeeld in eenheden met een correcte scope, en vectorisatie, waarbij deze eenheden worden omgezet in een representatie die semantisch zoeken ondersteunt.

De fijne kneepjes van parsen en chunking

Parsen en chunking is een kritieke stap in de voorverwerking van een goede RAG-oplossing, maar wordt vaak over het hoofd gezien. Om de LLM de broncode te laten embedden of indexeren, moeten we deze eerst de ruwe regels code voeren. Dit klinkt triviaal, en dat zou dat waarschijnlijk ook zijn voor kleinschalige demoprojecten. Productiesystemen bevatten echter duizenden bestanden die op hun beurt honderden of zelfs duizenden regels beslaan. Agents hebben dit probleem verergerd, omdat ze geneigd zijn productieve schrijvers te zijn, wat de codebase verder doet groeien. Elk bestand kan talloze klassen, velden en methoden bevatten, met variërende gradaties van onderlinge samenhang.

De juiste chunk-grootte vinden

Zelfs als het mogelijk zou zijn om deze enorme codebestanden in hun geheel in een embedding-model te passen, zou die kostbare prestatie uiteindelijk contraproductief zijn. Omdat het hele bestand als één eenheid zou worden ge-embed, zou het zoeken het hele bestand teruggeven. Dit staat haaks op de doelen van agent-gestuurde code-exploratie en navigatie, die primair gericht zijn op het vinden van een specifieke functie, een symbool of een codesnippet.

Aan de andere kant, als we de andere extreme zouden kiezen en elke afzonderlijke regel code granulair zouden embedden, zouden we voor een ander probleem komen te staan. Deze individuele regels kunnen semantisch insignificant zijn zonder de omringende context. Een generieke functienaam of opmerking rechtvaardigt geen embedding en zal leiden tot verkeerde zoekresultaten. In zekere zin zouden we door de bomen het bos niet meer zien, en zou de agent overbelast worden met meerdere, vaak onbeduidende micro-resultaten.

Het is daarom essentieel om de juiste methode te vinden om de code te "chunken" (op te delen) in groepen met een correct gedefinieerde scope. Elke groep moet voldoende noodzakelijke context bevatten en een gemeenschappelijke semantische betekenis representeren.

Waarom fixed-size chunking tekortschiet

Chunking is een generieke term voor de techniek waarbij inhoud die aan de agent wordt gevoerd, wordt verdeeld in een set chunks. Een naïve aanpak van chunking zou simpelweg zijn om een groot bestand op te splitsen in groepen met een vast aantal regels. Als we die aanpak zouden volgen, zouden we merken dat de resulterende groeperingen semantisch onjuist zijn. Niet-gerelateerde codestukken zouden bij elkaar worden gegroepeerd (bijvoorbeeld een import-statement samen met een deel van een functie), wat leidt tot fouten tijdens het ophalen.

Om dit probleem op te lossen, kunnen we gebruikmaken van het feit dat elk bronbestand een vrij goed gedefinieerde structuur heeft. Neem Java als voorbeeld: imports staan meestal bovenaan het bestand, gevolgd door een klassedefinitie met een optionele doc-comment voorafgaand aan de header. De klasse bevat velden en methoden, die op hun beurt ook eigen doc-comments kunnen hebben. Kennis van de conventies en regels die de klassestructuur definiëren, stelt ons in staat om slimmer te chunken en de juiste balans in omgevingsinformatie te vinden.

Parsen en structuurbewuste chunking

In de afgelopen 26 jaar hebben wij bij JetBrains parsers ontwikkeld die slim genoeg zijn om zich aan te passen aan de diverse eigenaardigheden, onregelmatigheden, conventies en nuances van specifieke talen. Samen met andere tools vormen deze parsers ons interne JetBrains Code Engine-platform waarop Air Context is ontwikkeld. Op het moment van schrijven ondersteunt Air Context parsen en structuurbewuste chunking voor negen belangrijke talen: Kotlin, Java, Python, JavaScript, TypeScript, C#, PHP, Go en Rust. Voor alle andere talen valt onze implementatie simpelweg terug op naïve, regelgebaseerde splitsing om ervoor te zorgen dat elke taal of elk document kan worden geïndexeerd en doorzocht.

De parser stelt ons in staat om bronbestanden op te breken in streams van syntaxis-nodes die informatie dragen over wat ze representeren — comments, witregels, lijsten van modifiers, enzovoort. Het chunking-algoritme consumeert die stream vervolgens en past logica toe om de scope van een gegeven chunk te bepalen. Op basis van het type en de grootte van de node, evenals de descendants, neemt het algoritme een beslissing. Als een node de grootte-drempel overschrijdt maar geen children heeft, valt het terug op primitievere splitsingsstrategieën.

Sommige taal-specifieke constructen worden als één slice behouden, zelfs als ze de voorkeursgrootte overschrijden. Prefixes zoals documentatie, annotaties, zichtbaarheidsmodifiers en trefwoorden worden samen met de declaratie gehouden; suffixes (meestal sluitende syntaxis) blijven geassocieerd met het construct dat ze sluiten. Er is ook sprake van taal-specifieke opschoning, waarbij bijvoorbeeld veelvoorkomende en semantisch betekenisloze Java-annotaties zoals @NotNull of @Override worden verwijderd.

Het algoritme vertoont overeenkomsten met cAST, beschreven door Zhang et al. in 2025. Zowel onze implementatie als cAST behouden de grootste syntaxis-eenheden die passen, en splitsen alleen de eenheden die te groot zijn, terwijl kleinere aangrenzende eenheden worden gegroepeerd om minuscule chunks te voorkomen die meestal niet semantisch betekenisvol zijn. Het grootste verschil is dat we meer taalsemantiek in onze implementatie hebben gecodeerd, zoals het samenhouden van Python-decorators met definities, KDocs naast Kotlin-declaraties, enzovoort.

Na de groepering wordt chunk-normalisatie uitgevoerd, wat het volgende omvat:

  • Het trimmen van leading en trailing whitespaces.
  • Het verwijderen van lege regels.
  • Het verwijderen van algemene inspringing (indentation) terwijl relatieve inspringing behouden blijft.

Na de normalisatieprocedure wordt de chunk, samen met metadata bestaande uit een relatief pad, doorgegeven naar de volgende stap: embedding.

De kwaliteit van chunks evalueren

Het is moeilijk om een concreet antwoord te geven op hoe de input voor het embedding-model eruit moet zien. Chunk-grootte is belangrijk, maar zoals eerder besproken is groter niet altijd beter. Daarnaast kan sommige metadata die naast de code wordt ge-embed nuttig zijn, terwijl andere metadata ruis introduceert die uiteindelijk de zoekkwaliteit vermindert.

We kozen voor een "LLM-as-a-judge" strategie om de chunks als onderdeel van de evaluatie te inspecteren. De judge bekijkt een chunk en het bronbestand en beoordeelt of de grens logisch is. Er wordt gezocht naar onverwachte artefacten, zoals losgekoppelde documentatie, wees-sluitingssyntaxis of fragmenten van code die midden in een betekenisvol construct zijn doorgesneden. Bovendien gaan alle wijzigingen in de verwerkingspipelines voor broncode door een volledige end-to-end retrieval-evaluatie.

Vectorisatie

Nadat we de broncode hebben voorbewerkt, hebben we tekstchunks die hopelijk precies de juiste grootte hebben en correct zijn gegroepeerd voor semantisch ophalen. Onze volgende taak is om deze fragmenten te transformeren op een manier die later semantisch zoeken mogelijk maakt, via een proces genaamd vectorisatie.

Bij vectorisatie leest een embedding-model een stuk tekst en produceert een lijst met getallen van vaste lengte (een vector), wat neerkomt op een punt in een ruimte van enkele duizenden dimensies. Cruciaal is dat het model is getraind zodat teksten met een vergelijkbare betekenis dicht bij elkaar komen te liggen. Traditioneel zoeken zou de connectie kunnen missen, maar hier kunnen een functie die gebufferde schrijfacties flusht en een functie die een wachtrij leegmaakt, dicht bij elkaar eindigen, ondanks dat ze geen gemeenschappelijke trefwoorden delen. De afstand tussen vectoren wordt dus een maatstaf voor verwantschap. Een zoekopdracht wordt omgezet in een positie in dezelfde ruimte, en de resultaten zijn datgene wat zich het dichtst bij die positie bevindt.

Optimalisatie voor opslag: Kwaliteit per byte

Elke poging om een grote codebase te vectoriseren moet rekening houden met zowel kosten als prestaties. Een enkele embedding is goedkoop, maar een groot repository produceert miljoenen chunks, wat resulteert in miljoenen vectoren die moeten worden opgeslagen, in het geheugen moeten worden gehouden en moeten worden vergeleken met elke inkomende query. Een vector van enkele duizenden dimensies in 32-bit floats weegt ongeveer 16 kilobytes; een paar miljoen chunks tellen dus op tot tientallen gigabytes aan index, nog voordat er sprake is van administratieve overhead. Op deze schaal wordt de toewijzing van bytes per vector kostenbeperkt, en verschuift de hoofdvraag van "hoe nauwkeurig kunnen we zijn?" naar "wat krijgen we per byte?". Met andere woorden: we moeten een manier vinden om de kosten te verlagen terwijl we zoveel mogelijk zoekkwaliteit behouden.

Er zijn twee manieren om de vector-kosten te verlagen:

  1. Minder dimensies behouden. Moderne embedding-modellen zijn zo getraind dat een leidend segment van de vector op zichzelf werkt. De dimensieverlies wordt over verschillende geneste prefix-lengtes tegelijk toegepast, waardoor de grofste structuur in de vroegste dimensies terechtkomt. Dit betekent dat je een vector kunt inkorten en renormaliseren, en hij nog steeds resultaten ophaalt.
  2. Elke dimensie behouden, maar minder per dimensie uitgeven door in te leveren op precisie en dus minder bytes per vector te gebruiken.

Deze twee opties zijn onafhankelijk van elkaar en kunnen worden gecombineerd. De echte vraag is welke mix het beste resultaat geeft voor hetzelfde aantal bytes. Stel dat het budget 512 bytes per vector is. Je zou dit kunnen besteden aan 128 dimensies met volledige 32-bit precisie, of aan alle 4.096 dimensies met slechts één bit per dimensie. Beide passen precies in het budget, maar in tests blijkt dat de tweede optie aanzienlijk beter resultaten ophaalt.

Waarom dimensies belangrijker zijn dan precisie

Om te begrijpen waarom, helpt het om elke dimensie te zien als een kleine vraag die het model heeft geleerd over de tekst te stellen: Gaat dit over foutafhandeling? Raakt het het netwerk? Is het testcode? Er zijn een paar duizenden van dit soort vragen.

Geen enkel individueel antwoord betekent veel op zichzelf. We beschouwen twee chunks als vergelijkbaar wanneer hun antwoorden op veel van deze vragen hetzelfde zijn. Daarom moeten we vectoren beoordelen op de dekking van de vragen, in plaats van op de exactheid van de antwoorden.

Het behouden van alle 4.096 dimensies op één bit bewaart een ruw ja-of-nee antwoord op elke vraag. Het inkorten naar 128 dimensies bewaart zeer precieze antwoorden op slechts drie procent van de vragen en gooit de rest weg. Geen enkele hoeveelheid precisie op de overgebleven dimensies kan de informatie herstellen die de verwijderde dimensies droegen. In zekere zin wint een lange vragenlijst ingevuld met vinkjes het van een korte lijst ingevuld tot op zes decimalen. Dimensies zijn wat je wilt behouden; precisie is wat je kunt betalen om te verliezen en later gemakkelijker te compenseren.

We hebben daarom gekozen om elke dimensie te behouden en de precisiereductie tot het uiterste te drijven door de vectoren te reduceren tot één bit per dimensie, wat 32 keer kleiner is dan dezelfde vector in 32-bit floats. De quantisatie zelf is verrassend eenvoudig: elke component op of boven nul wordt een één, terwijl elke negatieve component een nul wordt, en de magnitudes worden weggegooid.

Door de representatie te veranderen, verandert ook de metriek. Cosinus-overeenkomst (cosine similarity) heeft de magnitudes nodig die we net hebben weggegooid, dus worden binaire vectoren in plaats daarvan vergeleken via de Hamming-afstand, wat simpelweg het aantal posities is waar twee bitpatronen verschillen.

Bij volledige lengte blijft de berekening eenvoudig. Een 4.096-bit vector wordt opgeslagen als 64 woorden van 64 bits. Het vergelijken van twee vectoren betekent dat elk paar woorden wordt ge-XOR'd, wat een 1 achterlaat waar de twee vectoren verschillen, waarna de 1-en worden geteld. Een CPU voert dit uit in een enkele instructie per woord, waardoor een volledige vergelijking ongeveer honderd instructies kost, terwijl cosinus-overeenkomst op de oorspronkelijke floats duizenden vermenigvuldigingen nodig had.

De keuze voor de metriek was geen aparte beslissing. We kozen voor één-bit precisie voor de opslagbesparing, en zodra elke component een sign-bit is, is Hamming de enige vergelijking die nog zinvol is. De keuze voor de precisie bepaalde dus de metriek.

Binaire quantisatie kost wel enkele punten aan recall ten opzichte van de niet-gequantiseerde vector. We accepteerden deze kost omdat een redenerende agent de resultaten consumeert. Een codezoekopdracht voor een agent heeft meer behoefte aan de juiste "buurt" dan aan een perfect geordende top 10. Wanneer de agent vraagt waar sessietokens worden vernieuwd, is het belangrijk dat de relevante handvol bestanden verschijnt in de eerste dozijn resultaten. Of de beste chunk nu op plek twee of vijf staat, maakt niets uit, omdat de agent de kandidaten sowieso opent en leest.

De beperkingen van binaire quantisatie

De afweging die we maakten had een subtieler gevolg. Binaire quantisatie offert niet alleen nauwkeurigheid op; het comprimeert ook het bereik van de similarity-scores. Bij full-precision vectoren kan een niet-gerelateerd paar een score krijgen nabij nul, terwijl bijna-duplicaten scoren nabij één. Sign-bits werken anders. Ongeveer de helft van de bits van twee volledig ongerelateerde vectoren komt door puur toeval overeen, terwijl een sterk gerelateerd paar overeenstemming kan hebben voor twee derde. Elke score in de index landt dus in die smalle band.

De rangschikking overleeft deze compressie, aangezien relevante resultaten nog steeds hoger scoren dan irrelevante. Echter, drempelwaarde-bepaling (thresholding) overleeft dit niet. Denk aan een functie die proactief gerelateerde code aanbiedt terwijl je typt. De moeilijkste eis is weten wanneer het systeem stil moet blijven. Hiervoor is een bruikbaar gat nodig tussen "gerelateerde" en "niet-gerelateerde" scores. Binaire vectoren laten dit gat niet over. Daarom behouden we voor gevallen waarin een absoluut relevantieoordeel nodig is in plaats van een relatieve ordening 16-bit floats en betalen we voor de opslag.

Embedding-scope

Hoewel indexering en zoeken hetzelfde model gebruiken, kunnen de twee taken niet verschillender zijn. Indexering is beperkt door throughput, waarbij miljoenen chunks asynchroon worden afgehandeld. De GPU verwerkt ongeveer 32 chunks per batch voordat deze verzadigd raakt. Zoeken daarentegen moet snel en responsief zijn; gebruikers haken af als ze niet binnen enkele seconden resultaten krijgen. Daarom optimaliseren we deze modellen verschillend: de één om het aantal chunks per seconde te maximaliseren, de ander om de tijd tot het eerste resultaat te minimaliseren.

We kozen voor een instruction-following model, getraind met een bewuste asymmetrie tussen de twee zijden van retrieval. Een query is een korte vraag in natuurlijke taal, terwijl een document een chunk code is. Een document wordt tijdens indexering zoals het is ge-embed. Een query wordt omhuld met een instructie die de retrieval-taak beschrijft, zoals: "gegeven deze zoekopdracht, vind de code die deze beantwoordt". Dit vertelt het model welke rol de tekst speelt.

Om de twee zijden gemakkelijker op elkaar aan te laten sluiten, embedden we elke chunk samen met het bestandspad. Het pad levert metadata die de chunk alleen mist: in welke module het leeft en wat het bestand is. In een monorepo wordt het pad echter een probleem. De IntelliJ IDEA monorepo bevat meer dan een miljoen bestanden. Het mediane bronbestand bevindt zich negen mappen diep achter een pad van 91 tekens.

De meeste van deze tekens worden gebruikt voor structurele nesting en bieden geen nuttige informatie. Een reeks segmenten zoals src/org/jetbrains/kotlin/idea/k2 herhaalt de package-hiërarchie, wat een compiler nodig heeft maar een zoekopdracht niet. Om dit te compenseren, wordt een pad afgekapt voordat het het model bereikt, waarbij de regel is dat beide uiteinden overleven. De leidende segmenten vertellen in welke module je bent, terwijl de laatste twee (de directe parent en de bestandsnaam) vertellen wat het bestand is. Het midden wordt weggelaten met … indien nodig.

Dezelfde discipline geldt wanneer een gebruiker een zoekopdracht beperkt tot een submap. In plaats van een metadata-filter te gebruiken, wordt de scope direct in de querytekst verwerkt, in exact dezelfde vorm en met dezelfde afkortingsfunctie als de geïndexeerde chunks. Hierdoor landt de query-vector in dezelfde regio als de chunks die hij moet matchen.

Bescherming van broncode

Een laatste ontwerpoverweging was privacy en beveiliging. De broncode van een bedrijf is vaak de kern van zijn intellectueel eigendom. Het blootstellen hiervan aan cloudmodellen van derden verhoogt het risico op het onbedoeld lekken van gevoelige data.

Om deze zorgen weg te nemen, hebben we besloten aan de volgende praktijken te voldoen:

  • Geen opslag van code in onze systemen: Een chunk bevat een cluster-referentie, een item-type, een bestandspad, start- en eindoffsets, een referentie naar een vector en een optioneel metadata-veld. Er wordt geen inhoud of kopie van de broncode zelf opgeslagen. Wat een zoekopdracht teruggeeft zijn coördinaten; de snippet die je ziet wordt op jouw machine samengesteld vanuit jouw lokale checkout.
  • Geen data gebruiken voor training: Elke code-index die Air Context bouwt, wordt ge-embed door een open-weight embedding-model dat draait op GPU's die wij beheren. Geen enkel embedding-verzoek verlaat onze infrastructuur. We kunnen dus garanderen dat geen van de data wordt gebruikt om andere modellen te trainen.

Deze zelfopgelegde beperkingen hebben geen invloed op de retrieval-kwaliteit. We hebben de open-weight kandidaten getest tegen de hosted embedding-API's van grote providers op onze eigen benchmarks, en onze eigen oplossing kwam als winnaar uit de bus.

Tussentijdse samenvatting

In dit blogbericht hebben we de eerste fasen van de retrieval-pipeline behandeld: de reis van ruwe bronbestanden naar compacte vectoren die klaar zijn om doorzocht te worden.

We hebben nu miljoenen binaire vectoren en een manier om er meer te produceren. De problemen die we nog niet hebben opgelost zijn: hoe we ze efficiënt kunnen opslaan, hoe we een systeem kunnen creëren dat een query in milliseconden kan beantwoorden, hoe we onze resultaten continu kunnen evalueren en hoe we de agent daadwerkelijk onze RAG-apparatuur kunnen laten gebruiken. Deze onderwerpen vormen de kern van de volgende delen in deze serie.