Blog

Mastering AI Engineering: Structured Output met Meerdere Kandidaten

| Colin Kerkhof

Demo Notebook en Dataset

Om je te helpen deze techniek te begrijpen en ermee te experimenteren, hebben we een demo notebook gemaakt die de implementatie van onze oplossing demonstreert met de receipts dataset van Hugging Face. De notebook laat je de twee hoofdonderdelen van onze aanpak zien:

  1. Hoe je het structured output schema definieert en implementeert.
  2. Hoe je meerdere antwoordkandidaten genereert en verwerkt.

De receipts dataset is geschikt voor deze demonstratie omdat:

  • Het echte voorbeelden bevat van het extraheren van gestructureerde data.
  • De afbeeldingen van hoge kwaliteit en goed opgemaakt zijn.
  • De dataset openbaar beschikbaar en eenvoudig toegankelijk is.

Je kunt de hele blog lezen en stap voor stap meedoen, of direct naar de uiteindelijke implementatie in de notebook gaan.

Let op: we werken in de voorbeeldcode met Google Gemini Models, maar de kernlogica werkt voor alle LLM providers die structured outputs kunnen genereren.

De Uitdaging van Consistente AI Outputs

LLM's produceren van nature inconsistente of onvoorspelbare output. Het is ook bekend dat wanneer je de temperature op 0 zet of de output van het model te veel inperkt, de kwaliteit van de output daalt[1][2]. Maar bij het bouwen van productie-waardige applicaties die afhankelijk zijn van AI-gegenereerde content, is die variabiliteit een probleem. Voor zakelijke toepassingen, zoals de offertevergelijkingstool die we onlangs voor een van onze opdrachtgevers bouwden, hebben we betrouwbare, gestructureerde data nodig die consistent kan worden verwerkt door downstream systemen.

Onze Oplossing: Structured Output met Response Sampling

Onze aanpak om betrouwbaardere output uit LLM's te krijgen combineert twee technieken:

  1. Structured Output met Pydantic Models: We definiëren structured output schema's met Pydantic, wat type safety en validatie biedt. Dit is al gangbaar in de industrie.
  2. Generatie van Meerdere Kandidaten met Selectie op Basis van Frequentie: We genereren meerdere outputs en selecteren de meest frequente kandidaat als het 'definitieve' resultaat, met behulp van een hashable BaseModel.

Deze techniek gebruikt response sampling om de meest nauwkeurige output te identificeren. Het werkt vanuit de aanname dat het beste antwoord een hogere kans heeft om in meerdere generaties voor te komen, zelfs wanneer de LLM het niet consistent produceert. Dus, gegeven dat er een beste antwoord is, kunnen we het vinden door het antwoord te selecteren dat het vaakst voorkomt in onze response sample.

Hieronder zie je hoe je dit implementeert.

Structured Output met Pydantic

Voor een goede integratie van LLM's in jouw applicaties is het meestal nodig dat ze goed gedefinieerde, gestructureerde outputs genereren. Daarom willen we structured schema's definiëren voor verschillende AI-taken. Hier is bijvoorbeeld hoe we een schema definiëren om de extractie van informatie uit bonnetjes te structureren:

from pydantic import BaseModel

class ReceiptInfo(BaseModel):
    # Transaction details
    total_amount: float = Field(description="Total amount of the transaction")
    tip: float | None = Field(description="Tip amount if visible")
    tax: float | None = Field(description="Tax amount if visible")
    currency: str | None = Field(description="Three letter code of the currency of the transaction")

Door gebruik te maken van de Field class van Pydantic, kunnen we duidelijke beschrijvingen geven voor elk element van onze output. Die beschrijvingen helpen de mensen die de code gebruiken en dezelfde tekst helpt ook de AI om passende waarden voor elk veld te genereren. We kunnen deze modeldefinities daarom gebruiken als expliciete instructies die het model helpen precies te begrijpen welke informatie het uit de documenten moet halen. Aangezien de meeste LLM's getraind zijn op python code, kun je deze code direct kopiëren en plakken in de prompt om de LLM richting de juiste structured outputs te leiden.

Meerdere Kandidaten Genereren

Zelfs met een relatief strikte instructie zal de output van de LLM vaak variëren wanneer je deze twee keer bevraagt. In ons voorbeeld is het bijvoorbeeld mogelijk dat de LLM het fooibedrag verwart met het belastingbedrag. In plaats van deze variabiliteit op te lossen door de LLM nog verder te beperken, kiezen we voor een alternatieve aanpak: we bevragen het model meerdere keren. Daarbij vertrouwen we op de aanname dat het model, met een correcte prompt, ook eerder het juiste antwoord zal genereren. Met Google's Gemini modellen kun je eenvoudig en efficiënt meerdere antwoorden op dezelfde prompt verkrijgen door het candidate_count attribuut van de GenerateContentConfig op een getal groter dan 1 in te stellen.

De Meest Frequente Kandidaat Selecteren

De kern van onze aanpak is het selecteren van het meest frequente antwoord onder de kandidaten. Je intuïtie zal je vertellen dat de meest pythonic manier om het meest voorkomende item in een lijst te vinden, het gebruik van de collections.Counter class is. De BaseModel van Pydantic is echter standaard niet hashable. Gelukkig kunnen we dit eenvoudig implementeren:

class ExcludeFieldsBaseModel(BaseModel):
    def __hash__(self):
        return hash(self.model_dump_json())

Let op: we gebruiken model_dump_json omdat strings hashable zijn, in tegenstelling tot dictionaries.

Vervolgens werken we onze class van eerder als volgt bij:

class ReceiptInfo(ExcludeFieldsBaseModel)
# ... omitted code from earlier

Hier is hoe we de logica voor kandidaatselectie implementeren:

from collections import Counter

def parse_response(candidates: list[str], target_schema: type[BaseModel]):
    # Parse and validate each candidate response
    validated_responses = []
    for candidate in candidates:
        try:
            result = target_schema.model_validate_json(candidate)
            validated_responses.append(result)
        except JSONDecodeError:
            pass

    if not validated_responses:
        raise ValueError("No valid candidates found")

    candidate_counts = Counter(validated_responses)
    best_answer, count = candidate_counts.most_common(1)[0]
    print(f"consensus score: {count/len(candidates):.2f}")
    return best_answer

Deze implementatie telt de frequentie van elk uniek antwoord en selecteert het meest voorkomende. Het print ook de relatieve frequentie van het meest voorkomende antwoord, wat nuttig kan zijn om de kwaliteit van het antwoord te bepalen.

De Voordelen van Deze Aanpak

Deze selectie op frequentie heeft een paar voordelen:

  1. Verbeterde Betrouwbaarheid: Door outputs te samplen en het meest frequente antwoord te selecteren, verkleinen we de impact van incidentele hallucinaties of fouten van het model.
  2. Kwantificeerbaar Vertrouwen: Het consensuspercentage geeft ons een maatstaf om het vertrouwen in onze resultaten te beoordelen.
  3. Mogelijkheid om Netjes te Falen: Wanneer de consensus laag is, kunnen we ervoor kiezen om het antwoord te markeren voor menselijke beoordeling.

In de demo notebook vind je een voorbeeld van hoe een data-extractietaak eenvoudig kan worden verbeterd met deze methode. Je kunt bijvoorbeeld zien dat deze aanpak hallucinaties vermindert, zoals het genereren van een "denkbeeldige" valuta die niet op de bonnetjes staat.

Voeg reasoning toe

Zoals we eerder schreven, is het niet ongebruikelijk om een LLM te laten redeneren voordat het een antwoord geeft, om de kwaliteit en betrouwbaarheid te verbeteren. De eenvoudigste manier om dit te doen is, in onze ervaring, om een veld aan je output schema toe te voegen met de naam chain_of_thought, als volgt:

class ReceiptInfo(ExcludeFieldsBaseModel):
    chain_of_thought: str | None = Field(description="Your reasoning and thought process before providing this answer")
    # ... other fields

Het is belangrijk om het chain_of_thought veld als eerste in je output schema te plaatsen, omdat dit de LLM aanmoedigt om te redeneren voordat het zijn definitieve antwoord geeft. Hoewel sommigen zouden kunnen aannemen dat deze aanpak achterhaald is met nieuwere reasoning-gerichte modellen, blijft het waardevol voor de meeste general-purpose LLM's, omdat het redeneren onderdeel wordt van het context window en het definitieve antwoord beter onderbouwt. Let op: sommige implementaties (zoals die van Google) kunnen velden in structured outputs automatisch alfabetisch sorteren, in welk geval je de veldnaam mogelijk moet laten beginnen met een teken zoals "0" om ervoor te zorgen dat het als eerste verschijnt.

Het toevoegen van het chain_of_thought veld aan het antwoord introduceert echter een nieuw probleem. Bekijk de volgende voorbeeldantwoorden

test_class1 = ReceiptInfo(
    chain_of_thought="Based on the receipt, I can identify the total amount as 13.000, the tax as 10% and the currency as EUR.",
    total_amount="13000",
    tip=None,
    tax="1300",
    currency="EUR",
)

test_class2 = ReceiptInfo(
    chain_of_thought="Based on the receipt, I can identify the total amount as 13.000, the tax as 1.300 and the currency as EUR.",
    total_amount="13000",
    tip=None,
    tax="1300",
    currency="EUR",
)

De geëxtraheerde informatie uit het bonnetje is in beide antwoorden hetzelfde, maar het gepresenteerde redeneren niet. Met onze huidige code zouden deze antwoorden dus niet als gelijk worden beschouwd, terwijl dat wel zou moeten. Om dit op te lossen, hebben we een manier nodig om het chain_of_thought veld te negeren.

Er zijn verschillende oplossingen voor dit probleem:

1. Lieg tegen je LLM

Aangezien Pydantic's BaseModel.model_validate_json extra keys negeert, kun je je LLM vragen om de key te genereren zonder die ooit te gebruiken.

2. Sluit het Attribuut Uit

Je kunt het attribuut uitsluiten in de Field functie Field(..., exclude=True, wat ervoor zorgt dat het attribuut wordt genegeerd in elke model_dump(_json) en __eq__ call. Het lastige hier is dat het vanaf dat moment vrijwel onmogelijk wordt om dat attribuut te model_dump'en, vanwege de exclusie-instellingen van Pydantic. Dit betekent dat als je het wilt gebruiken voor logging-doeleinden, je het expliciet zult moeten extraheren. Hoewel dit prima zou werken in het simpele voorbeeld, kan het een stuk lastiger worden bij het nesten van Pydantic models.

3. Gebruik onze Implementatie van ExcludableFieldsBaseModel

We hebben een eigen implementatie van Pydantic's BaseModel gebouwd die de private property _exclude_fields toevoegt, waar je kunt definiëren welke velden genegeerd moeten worden bij het vergelijken van twee BaseModels. Door een eigen __hash__ en __eq__ methode te implementeren, zorgen we ervoor dat alle geneste ExcludeFieldsBaseModel's ook hun respectievelijke _exclude_fields uitsluiten, zodat ze correct gebruikt kunnen worden in een Counter (of andere vergelijkingscontext).

In onze aanpak stellen we een private attribuut in met de naam _exclude_fields, dat we gebruiken in de __hash__ en __eq__ calls. Onze Example class wordt dan:

class ExcludeFieldsBaseModel(BaseModel):
    _exclude_fields: Iterable[str] = set()

    def __hash__(self) -> int:
        temporary_hash = 0
        for attr in [*self.model_fields, *self.model_computed_fields]:
            if attr in self._exclude_fields:
                continue
            attr_value = getattr(self, attr)

            # Convert non-hashable objects to tuple to make sure it is hashable
            # Python handles underlying object hash, which calls this method again
            # if it is another ExcludeFieldsBaseModel, thereby excluding the fields
            # of the nested objects
            if isinstance(attr_value, dict):
                attr_value = tuple(attr_value.items())
            elif isinstance(attr_value, list):
                attr_value = tuple(attr_value)

            # The hash-summing makes it order insensitive
            temporary_hash += hash(attr_value)

        # Hash it again to make sure it is a valid hash
        return hash(temporary_hash)

    def __eq__(self, other: "ExcludeFieldsBaseModel") -> bool:
        for attr in [*self.model_fields, *self.model_computed_fields]:
            if attr in self._exclude_fields:
                continue
            if getattr(self, attr) != getattr(other, attr):
                return False
        return True

# ... omitted code in between ...

class ReceiptInfo(ExcludeFieldsBaseModel):
    # ... other fields ...
    _exclude_fields = {"chain_of_thought"}

Je kunt nu het _exclude_fields attribuut gebruiken in elk model waarin je velden wilt uitsluiten. Zorg er alleen voor dat elke 'parent' class ook ExcludeFieldsBaseModel gebruikt, zodat deze de hash en de gelijkheid correct berekent.

Naast de chain of thought kun je nu ook andere niet-deterministische velden uitsluiten van de uiteindelijke vergelijking en meerderheidsstemming. Bijvoorbeeld:

class ReceiptInfo(ExcludeFieldsBaseModel):
    # ... other field ...
    sample_item: str = Field(description="An example of an item on this receipt.")

    _exclude_fields = {"chain_of_thought", "sample_item"}

Afweging tussen Kwaliteit en Kosten

Bij het implementeren van deze aanpak met meerdere kandidaten moet je modelkwaliteit, kosten en latency tegen elkaar afwegen. In onze applicaties zagen we een paar dingen:

  1. Afweging Modelkwaliteit vs. Kwantiteit: Hoewel modellen van hogere kwaliteit zoals Gemini-1.5-Pro nauwkeurigere individuele antwoorden kunnen produceren, kunnen meerdere samples van een goedkoper model een vergelijkbare betrouwbaarheid bereiken tegen lagere kosten. "gemini-2.0-flash-lite" kost bijvoorbeeld 0,30 per miljoen output tokens tegenover 5,00 voor gemini-1.5-pro, een verschil van 16x. Belangrijk daarbij is dat je alleen betaalt voor de extra output tokens wanneer je een multi-candidate response gebruikt.
  2. De Optimale Sample Size Vinden: Door te experimenteren ontdekten we dat het aantal benodigde kandidaten om betrouwbaarheid te bereiken een afnemend rendement kent. Dus, hoewel het optimaal lijkt om minder samples van een model van hogere kwaliteit te gebruiken, maakt het kostenverschil meerdere samples van een goedkoper model vaak economischer.
  3. Latency-overwegingen: Wanneer responstijd belangrijk is, moet je meerdere kandidaten parallel draaien. Dat voegt weinig latency toe en verbetert de betrouwbaarheid flink, hoewel het de rekenlast wel verhoogt. De meeste LLM providers ondersteunen gelijktijdige requests, waardoor deze aanpak haalbaar is voor real-time applicaties.
  4. Consensusdrempels: Stel een passende consensusdrempel in op basis van de eisen van je applicatie. Voor het extraheren van financiële data eisen we minimaal 70% consensus. Onder die grens markeren we het antwoord voor menselijke beoordeling. Voor minder kritische applicaties kan een lagere drempel acceptabel zijn.

Onze aanbeveling is om te beginnen met een model van gemiddelde kwaliteit en 5 kandidaten en vervolgens aan te passen op basis van je specifieke behoeften, kostenbeperkingen en betrouwbaarheidseisen. Houd je consensusscores bij over verschillende documenttypes om te identificeren waar de aanpak werkt of verfijning nodig heeft.

Geleerde Lessen en Best Practices

Door ons werk met deze technieken hebben we verschillende best practices geïdentificeerd:

  1. Wees Specifiek in Schemadefinities: Gebruik beschrijvende veldnamen en geef gedetailleerde veldbeschrijvingen om de AI goed te sturen.
  2. Balanceer het Aantal Kandidaten met Modelkwaliteit: Goedkopere en 'minder intelligente' modellen kunnen worden ingezet in plaats van sterkere, duurdere modellen als ze vaker worden gesampled.
  3. Neem Reasoning-velden op: Aparte velden voor het redeneren en het eigenlijke antwoord helpen de AI om zijn gedachten te ordenen voordat het het antwoord geeft dat je zoekt.
  4. Log en Monitor Consensuspercentages: Houd bij hoe vaak kandidaten het eens zijn om problematische queries of schema's te identificeren.

Conclusie

Structured output met selectie van meerdere kandidaten is een praktische aanpak voor het bouwen van betrouwbaardere AI-applicaties. Door meerdere kandidaten te genereren en het meest frequente antwoord te selecteren, verbeteren we de betrouwbaarheid van en het vertrouwen in onze AI-outputs.

Deze technieken speelden een belangrijke rol in verschillende oplossingen die we aan onze opdrachtgevers hebben geleverd, waarmee complexe documenten met hoge betrouwbaarheid en nauwkeurigheid verwerkt konden worden. Naarmate AI zich verder ontwikkelt, blijven dit soort engineering-aanpakken nuttig om de kloof tussen AI-mogelijkheden en productie-eisen te overbruggen.

Door deze patronen in je eigen AI-applicaties toe te passen, kun je de outputkwaliteit en betrouwbaarheid flink verbeteren, waardoor AI een praktischere oplossing wordt voor complexe problemen in de praktijk.

  1. Zoals beschreven in "The Curious Case of Text DeGeneration", kan greedy output sampling leiden tot repetitieve en kwalitatief lage outputs. https://arxiv.org/pdf/1904.09751
  2. Onderzoek door TrustGraph toonde aan dat het gebruik van temperature 0 niet altijd optimale resultaten oplevert: https://dev.to/trustgraph/what-does-llm-temperature-actually-mean-18b

Colin Kerkhof

Auteur

Colin Kerkhof

Ik ben AI-engineer en bouw productieklare AI-systemen.

LinkedIn

Nieuwsbrief

Blijf op de hoogte

Meld je aan voor onze nieuwsbrief en ontvang onze kijk op alles wat speelt in data en AI.

Contact

Wat wil jij met AI bereiken?

Neem contact op met ons team via contact@mozaik.ai of gebruik het formulier hieronder.

Of kom langs op ons kantoor: Pakhuis De Hoop, Breestraat 59, Amersfoort.

Pakhuis De Hoop, Breestraat 59, Amersfoort

We gebruiken je gegevens alleen om op je bericht te reageren. Lees meer in ons privacybeleid.