Veto und Abstain
Ein Router ohne Schwellwert routet auch Unsinn, die Ablehnung ist ein Feature.
Meilenstein „mach mir ein butterbrot“ wird ehrlich abgelehnt statt geroutet, und ein Counter-Example zieht seinen Agenten aus der Liste.
Der Router aus Kapitel 03 hat einen Fehler, den man nicht sieht, solange man ihn
mit sinnvollen Anfragen füttert: Er antwortet immer. Ein Retrieval liefert
Top-k, und Top-k ist auch dann gefüllt, wenn nichts passt. Frag ihn nach einem
Butterbrot, und er schickt dich zum docs-finder, weil der von allen drei am
wenigsten unpassend ist.
Das ist derselbe Kalibrierungsfehler wie bei einem Sprachmodell, das auf „kannst du das?“ grundsätzlich ja sagt. Nur an einer Stelle, an der man ihn reparieren kann - hier steht eine Zahl, kein Modell.
Das Ziel
Ein Router ohne Schwellwert routet auch Unsinn. Die Ablehnung ist kein Mangel, sondern das Feature, das den Router überhaupt erst benutzbar macht: Nur wer nein sagen kann, dessen Ja etwas bedeutet.
Schritt 1: Der Veto-Index
Die counter_examples aus deinen Cards liegen bisher ungenutzt herum. Sie
kommen in einen eigenen Index - der Grund steht in Kapitel 02, und er ist
wichtig genug für eine Wiederholung: „Ich mache keine Web-Suche“ enthält das
Wort Web-Suche. Im Positiv-Index läge damit ausgerechnet der Satz, der eine
Anfrage ausschließen soll, ganz nah an dieser Anfrage.
Getrennt indexiert dreht sich die Wirkung um. Ein Treffer im Veto-Index heißt nicht „passt gut“, sondern „der nicht“.
# tiny/concierge.py (erweitert; zusaetzlich noetig: import json, from pathlib import Path)
THETA_VETO = 0.72 # ab hier gilt ein Counter-Example als Ausschluss
THETA_ACCEPT = 0.62 # darunter: Abstain
DELTA_AMBIG = 0.04 # Abstand, unter dem zwei Agenten gleichwertig sind
class Concierge:
def __init__(self, cards: dict[str, Card], gap_log: Path | None = None) -> None:
self.cards = cards
self.gap_log = gap_log or Path("gaps.jsonl")
self.positiv = Index()
self.veto = Index()
for card in cards.values():
self.positiv.hinzufuegen(card.id, card.examples)
self.veto.hinzufuegen(card.id, card.counter_examples)
self.positiv.bauen()
self.veto.bauen()
def route(self, ziel: str) -> Entscheidung:
treffer = self.positiv.suchen(ziel, limit=20)
veto_treffer = self.veto.suchen(ziel, limit=10)
# Binaer, nicht als Punktabzug: wer hier ueber der Schwelle liegt,
# ist raus - egal wie gut sein bester Positivtreffer war.
gesperrt = {t.agent_id for t in veto_treffer if t.score >= THETA_VETO}
beste: dict[str, float] = {}
for t in treffer:
if t.agent_id in gesperrt:
continue
beste[t.agent_id] = max(beste.get(t.agent_id, 0.0), t.score)
rangliste = sorted(beste.items(), key=lambda p: -p[1])
if not rangliste or rangliste[0][1] < THETA_ACCEPT:
self._luecke_merken(ziel, rangliste)
return Entscheidung("abstain", None, rangliste[0][1] if rangliste else 0.0, [])
if len(rangliste) > 1 and rangliste[0][1] - rangliste[1][1] < DELTA_AMBIG:
return Entscheidung("ambiguous", rangliste[0][0], rangliste[0][1], rangliste[1:3])
return Entscheidung("match", rangliste[0][0], rangliste[0][1], rangliste[1:3])
def _luecke_merken(self, ziel: str, rangliste: list[tuple[str, float]]) -> None:
eintrag = {
"ziel": ziel,
"bester_kandidat": rangliste[0][0] if rangliste else None,
"bester_score": round(rangliste[0][1], 4) if rangliste else 0.0,
}
with self.gap_log.open("a", encoding="utf-8") as datei:
datei.write(json.dumps(eintrag, ensure_ascii=False) + "\n")
Der gesperrt-Filter steht vor der Punktevergabe, nicht danach. Ein Veto,
das man später wieder wegrechnen kann, ist kein Veto.
Schritt 2: Drei Antworten statt einer
Der Router gibt jetzt drei verschiedene Dinge zurück, und der Unterschied ist für den aufrufenden Agenten wichtig:
| Art | Bedeutung | Was der Host tut |
|---|---|---|
match | genau einer passt | delegieren |
ambiguous | zwei liegen gleichauf | nachfragen, oder beide Karten zeigen |
abstain | keiner passt | ehrlich sagen, dass es dafür niemanden gibt |
ambiguous ist die Antwort, die man am liebsten wegoptimiert. Tu es nicht. Sie
sagt etwas Wahres über deine Registry: Zwei Cards sind zu ähnlich geschnitten,
und keine Schwellwertfummelei ändert daran etwas. DELTA_AMBIG ist damit
weniger ein Router-Parameter als ein Messgerät für deinen Zuschnitt.
Schritt 3: Die Schwellen kalibrieren
Die drei Zahlen oben sind Startwerte, keine Wahrheiten. Sie hängen an deinem
Embedding-Modell: Manche Modelle drücken Ähnlichkeiten in ein enges Band
zwischen 0,6 und 0,9, andere spreizen von 0,1 bis 0,95. Ein THETA_ACCEPT von
0,62 kann bei dem einen Modell alles durchlassen und bei dem anderen alles
abweisen.
Also durchprobieren. Erweitere dein Testset aus Kapitel 03 um sechs Anfragen, die nichts treffen sollen:
UNGEDECKT = [
"mach mir ein butterbrot",
"wie wird das wetter morgen in hamburg",
"uebersetz das mal ins italienische",
"ruf den kunden an und frag nach",
"wie viel ist 17 prozent von 4200",
"loesch das ganze projekt",
]
# scripts/04_schwellen_sweep.py
import numpy as np
from tiny import concierge as c
from scripts.router_eval import TESTSET, UNGEDECKT # Testsets ausgelagert
def bewerten(concierge, theta_accept: float) -> tuple[float, float]:
c.THETA_ACCEPT = theta_accept
treffer = sum(concierge.route(a).agent_id == e for a, e in TESTSET)
ablehnungen = sum(concierge.route(a).art == "abstain" for a in UNGEDECKT)
return treffer / len(TESTSET), ablehnungen / len(UNGEDECKT)
def main() -> None:
concierge = c.concierge_laden()
print(f"{'theta':>7}{'Recall@1':>11}{'Abstain':>10}{'F1':>8}")
for theta in np.arange(0.40, 0.86, 0.02):
recall, abstain = bewerten(concierge, float(theta))
f1 = 0.0 if recall + abstain == 0 else 2 * recall * abstain / (recall + abstain)
print(f"{theta:>7.2f}{recall:>11.2f}{abstain:>10.2f}{f1:>8.2f}")
if __name__ == "__main__":
main()
Was du siehst, ist eine Kurve mit einem Buckel: Bei niedrigem Schwellwert
routest du alles richtig und allen Unsinn mit. Bei hohem lehnst du jeden
Unsinn ab und die Hälfte der echten Anfragen gleich mit. Der beste F1-Wert
markiert die Stelle dazwischen. Setz THETA_ACCEPT dorthin und schreib die Zahl
mitsamt Datum und Modellnamen in dein README - sie gilt nur für dieses
Embedding-Modell.
Schritt 4: Die Ablehnungen sind die Bauliste
Der gaps.jsonl-Schreiber oben sieht nach Buchhaltung aus. Er ist der
unterschätzteste Teil der ganzen Architektur.
Jede Zeile darin ist eine Anfrage, die ein echter Nutzer gestellt hat und für die dein System niemanden hatte. Nach zwei Wochen Betrieb sieht die Datei so aus:
47x "termin verschieben mit ..." -> Kandidat: calendar-writer
31x "wie war der umsatz im ..." -> Kandidat: reporting
12x "uebersetz das mal ins italienische" -> Kandidat: translator
Das ist keine Fehlerliste, das ist eine nach echter Nachfrage priorisierte
Produkt-Roadmap. Ein System, das jede Anfrage irgendwohin routet, hätte diese
Information nie erzeugt - die 47 Terminanfragen wären als schlechte Antworten
des docs-finder verschwunden.
Das Zusammenfassen dieser Zeilen zu Themen ist ein eigenes kleines Projekt und gehört nicht in diesen Kurs (siehe den Ausblick in Kapitel 08). Die Datei schreiben musst du trotzdem jetzt - später hast du die Daten nicht mehr.
Der Meilenstein
python -c "
from tiny.concierge import concierge_laden
c = concierge_laden()
for anfrage in ['mach mir ein butterbrot',
'zieh mir die preisliste von deren website',
'google mal wer da geschaeftsfuehrer ist']:
e = c.route(anfrage)
print(f'{e.art:<10} {e.agent_id or \"-\":<14} {e.score:.3f} {anfrage}')
"
abstain - 0.412 mach mir ein butterbrot
match web-reader 0.751 zieh mir die preisliste von deren website
abstain - 0.583 google mal wer da geschaeftsfuehrer ist
Zwei Dinge müssen stimmen. Das Butterbrot wird abgelehnt, nicht geroutet.
Und die dritte Zeile ist die interessantere: „google mal“ ist ein
Counter-Example des web-reader. Ohne Veto-Index hätte er hier gewonnen - er
kann ja irgendwas mit Webseiten. Mit Veto-Index ist er raus, und weil ihn keiner
ersetzt, endet die Anfrage ehrlich im gaps.jsonl.
Schau danach in die Datei:
cat gaps.jsonl
Zwei Zeilen. Deine ersten beiden Kandidaten für Agent Nummer vier und fünf.