Who picks the tool
-->
Direkt zum Hauptbereich
Angefangen hat das Ganze als schlichter „PDF-Finder". Eine Handvoll E-Books, eine Upload-Maske, eine Volltextsuche, mehr sollte es nicht sein. Aber Sammlungen wachsen, und irgendwann ist ein Archiv so groß, dass man es nicht mehr überblickt. Die Volltextsuche fand nur etwas, wenn ich die exakten Begriffe kannte. Frage ich mich „Wie funktioniert Quantenverschränkung?", das Buch schreibt aber von „verschränkten Zuständen", dann findet die Suche nichts.
Was fehlt, ist also keine bessere Suche über Wörter. Was fehlt, ist eine Suche über Bedeutung.
Diese Erkenntnis kommt schnell. Spannender ist der Weg von dort zu einem System, das mir eine Frage in natürlicher Sprache beantwortet, mit Quellenangabe, aus meinen eigenen Dokumenten und komplett auf meinem Rechner. Um diesen Weg geht es hier, und um die Design-Entscheidungen, an denen er sich entschieden hat.
Das Verfahren dahinter heißt RAG, kurz für Retrieval-Augmented Generation. Kurz gesagt holt man die relevanten Textstellen aus den eigenen Dokumenten und gibt sie einem Sprachmodell als Kontext. Das Modell formuliert daraus eine Antwort und verweist auf die Fundstellen.
Der Reiz steckt im Wort „augmented". Das Modell soll nicht aus seinem trüben Trainingsgedächtnis fabulieren, sondern aus dem, was tatsächlich in meinen Büchern steht. Also keine Halluzination, sondern belegbare Antworten. Und weil ich das Ganze lokal mit Ollama betreiben wollte, verlässt kein einziges Byte die Maschine. Kein Cloud-Dienst, keine API-Kosten. Wer will, stellt per Konfiguration auf OpenAI um, dazu später mehr.
Der Stack in Kürze:
Die eigentliche Arbeit passiert aber nicht im Stack, sondern in der Pipeline. Zur Query-Zeit, also pro Frage, sieht sie so aus:
![]() |
| Die RAG-Pipeline zur Query-Zeit: Frage, Query Rewriting, hybrides Retrieval aus semantischer und Volltextsuche, RRF-Fusion, LLM-Re-Ranking, Antwort |
Ein RAG-System ist immer nur so gut wie das, was es findet. Die Generierung ist der leichte Teil. Die eigentliche Arbeit steckt im Retrieval.
Der naheliegende Weg ist reine Vektorsuche: Frage einbetten, den nächstgelegenen Textstücken im Vektorraum nachgehen. Das ist stark bei Bedeutung und schwach bei allem Exakten, also bei Eigennamen, Codes, seltenen Fachtermini oder Versionsnummern. Reine Volltextsuche ist genau umgekehrt, pedantisch beim Wortlaut und blind für Bedeutung.
Man muss sich gar nicht entscheiden. Beides läuft parallel:
websearch_to_tsquery / ts_rank.Jede Seite liefert eine eigene Rangliste. Fusioniert werden sie mit Reciprocal Rank Fusion (RRF), einem angenehm undramatischen Verfahren, das Ränge statt roher Scores kombiniert und sich damit die fragile Normalisierung zweier völlig verschiedener Score-Skalen spart. Danach sorgt ein Diversitäts-Cap von höchstens zwei Treffern je Buch dafür, dass nicht ein einziges gesprächiges Dokument die ganze Ergebnisliste kapert.
Zwei kleine Details brachten überraschend viel Recall. Der HNSW-Index läuft mit erhöhtem ef_search (100 statt Default rund 40), denn der Default deckelte still die Zahl echter Kandidaten. Und beide Signale laufen nebenläufig als parallele DB-Queries, sodass die Latenz der langsameren Query entspricht und nicht der Summe. Gespeichert wird mehrsprachig (bge-m3, 1024 Dimensionen), sodass eine deutsche Frage auch eine englische Passage findet.
Das ist das Fundament. Alles Weitere sind Schichten darauf. Interessant ist dabei, wie unabhängig diese Schichten voneinander bleiben.
Die erste ist history-aware Query Rewriting. Im Chat stellt man Folgefragen. „Und wie funktioniert das?" ist ohne den Gesprächsverlauf bedeutungslos, eingebettet findet dieser Satz nichts. Also schreibt das LLM die Frage vor dem Retrieval anhand des Verlaufs zu einer eigenständigen Suchanfrage um. Aus „Und wie funktioniert das?" wird „Wie funktioniert Quantenverschränkung?".
Der Kniff ist die Trennung. Umgeschrieben wird nur für das Retrieval. Die Antwort selbst formuliert das Modell weiter aus Originalfrage und Verlauf, damit sie natürlich bleibt. Beim ersten Turn, wenn es keinen Verlauf gibt, oder bei einem Fehler bleibt einfach die Rohfrage stehen. Das Rewriting kann also nie schaden, nur helfen.
# Common.RAG.QueryRewriter
def rewrite(question, []), do: question # erster Turn: kein LLM-Call
def rewrite(question, history) do
case Provider.chat(messages(question, history), []) do
{:ok, rewritten} -> sanitize(rewritten, question)
{:error, _} -> question # Fallback: Rohfrage
end
end
Die zweite ist LLM-basiertes Re-Ranking. Die Fusion liefert gute Kandidaten, aber die Reihenfolge ist nur ungefähr nach Relevanz sortiert. Der Lehrbuch-Ansatz wäre ein Cross-Encoder, ein Spezialmodell, das Frage und Textstelle gemeinsam liest und einen Relevanz-Score ausgibt. Präzise, aber weder Ollama noch OpenAI bieten einen Rerank-Endpoint. Ein echter Cross-Encoder würde also entweder einen dritten Cloud-Dienst mit neuem API-Key bedeuten oder schwere lokale Infrastruktur, etwa ein Modell von rund 1 GB über Bumblebee/Nx.
Ich habe die pragmatische Variante gewählt: einen listwise Re-Ranker über das ohnehin vorhandene chat/2. Das LLM bekommt die Frage plus nummerierte Kandidaten und gibt eine nach Relevanz sortierte Reihenfolge zurück. Kein neuer Dienst, kein neuer Key, und es läuft sofort auf Ollama und OpenAI.
# Common.RAG.Reranker, vereinfacht
def rerank(question, chunks, k) do
case Provider.chat(messages(question, chunks), json: true) do
{:ok, response} -> chunks |> reorder(response) |> Enum.take(k)
{:error, _} -> Enum.take(chunks, k) # Fallback: Eingangsreihenfolge
end
end
Dafür wird ein größerer Pool von 20 Kandidaten geholt und auf die besten k (8) heruntersortiert. Ungenannte Kandidaten hängen hinten an, ungültige Antworten fallen sauber auf die Eingangsreihenfolge zurück. Damit ist Re-Ranking nie schlechter als kein Re-Ranking.
Ist das so gut wie ein Cross-Encoder? Nein. Aber für einen lokalen, austauschbaren Stack ist es der beste Kompromiss aus Qualität und Einfachheit. Und weil der Re-Ranker hinter einer klaren Funktion sitzt, lässt sich die Engine später gegen einen echten Cross-Encoder tauschen, ohne einen einzigen Aufrufer anzufassen.
Hier kommt die Design-Entscheidung, auf die ich am meisten stolz bin. Eigentlich war es gar keine bewusste Entscheidung, sondern eine Beobachtung.
Wissen steckt nicht nur in PDFs. Es steckt in Blogposts, in Doku-Seiten, in Markdown-READMEs. Ich wollte eine URL eingeben und sie danach genau wie ein PDF durchsuchen und befragen können. Die Frage war, wie tief ich dafür ins System schneiden muss.
Die Antwort: gar nicht. Alles ab dem Feld content ist bereits formatunabhängig. Der Volltext-Index ist eine generierte Spalte über content, der Chunker liest nur content, und Retrieval, Re-Ranking und Chat kennen ausschließlich Chunks plus eine ebook_id. Um eine Web-Seite aufzunehmen, musste ich an Suche, Embeddings oder RAG nichts ändern. Es genügte eine neue Ingestion-Quelle, die content und title füllt, plus ein Diskriminator source_type.
Bei Web-Seiten steckt die Arbeit im Beschaffen. Viele Seiten rendern ihren Inhalt erst per JavaScript, ein reiner HTTP-Abruf liefert dann eine leere Hülle. Also rendert ein headless Chrome die Seite und liefert das fertige DOM, aus dem Floki Titel und Fließtext zieht. Das läuft als Oban-Job, weil Netzwerk und Rendern langsam und fehleranfällig sind. Bei Markdown ist selbst das überflüssig, denn der Dateiinhalt ist schon der content. Nichts zu extrahieren, nichts zu rendern.
Je einfacher das Quellformat, desto sichtbarer die Schnittstelle. Bei Markdown schrumpft „eine neue Quelle anschließen" auf drei Handgriffe: Datei ablegen, content gleich Dateitext, Titel raten. Suche, Embeddings, Re-Ranking und Chat merken von der neuen Quelle wieder nichts.
Diese schmale Schnittstelle hat auch eine ehrliche Kehrseite, denn sie trägt nur einen einzigen String. Als es später um seitengenaue Zitate ging, half das wenig. „Steht in Buch X" ist bei 300 Seiten nicht viel wert, und strukturierte Seiten-Metadaten passen durch die Schnittstelle nicht hindurch. Die Lösung war nicht, die Schnittstelle zu verbreitern, sondern die Seitengrenze als Konvention im String mitzuführen. pdftotext trennt Seiten längst mit einem Form-Feed (\f). Die Information war die ganze Zeit da, nur ungenutzt.
# Common.Embeddings.Chunker: PDF seitenweise chunken
def chunk_pages(content, opts \\ []) when is_binary(content) do
content
|> String.split("\f") # pdftotext trennt Seiten am Form-Feed
|> Enum.with_index(1) # 1-basierte Seitenzahl
|> Enum.flat_map(fn {page_text, page} ->
page_text |> chunk(opts) |> Enum.map(&%{page: page, content: &1})
end)
end
Ein reines Neu-Einbetten machte damit den Altbestand zitierfähig, ganz ohne Neu-Extraktion.
Ein Bericht, der nur die Siege erzählt, ist keiner. Parallel zum Chunk-Ansatz habe ich einen zweiten Retrieval-Pfad gebaut, die sogenannten IdeaBlocks. Statt roher Textstücke lässt man ein LLM aus jedem Dokument destillierte Frage/Antwort-Blöcke extrahieren und korpusweit deduplizieren. Die Theorie dahinter: entrauschte, in sich geschlossene Wissenseinheiten und damit präzisere Treffer.
Die Praxis war ernüchternd. Die Ingestion war teuer, weil sie mehrere LLM-Calls je Dokument brauchte. Sie verlor Information, denn was das LLM nicht als „Idee" erkennt, ist auf diesem Pfad weg, während Chunks alles bewahren. Und der abgerufene Text war eine Paraphrase statt der Quelle, also schlecht zum treuen Zitieren. Am Ende schlossen Hybrid-Fusion, Re-Ranking und HyDE auf dem vollständigen Chunk-Korpus genau die Lücke, die IdeaBlocks schließen sollten, nur billiger und erst zur Query-Zeit.
Statt aus Sunk-Cost-Denken daran festzuhalten, baute ich ein kleines A/B-Werkzeug, das dieselben Fragen durch beide Pfade schickte. Das Ergebnis war eindeutig, und die Konsequenz hieß löschen. IdeaBlocks flog komplett raus, samt Pipeline, Schema, Retrieval-Pfad und UI-Umschalter. Chunks ist heute der einzige Pfad.
Eine getestete Hypothese, die scheitert, ist kein Fehlschlag, solange man das Scheitern misst und die Konsequenz zieht. Und die Konsequenz heißt manchmal: Code wieder löschen.
Auf ein paar Prinzipien eingedampft:
embed, chat, chat_stream) mit zwei Implementierungen, Ollama und OpenAI. Rewriting, Re-Ranking und HyDE brauchten keinen neuen Endpoint, sie bauen alle auf chat/2 auf.content formatunabhängig ist, kostet eine neue Quelle fast nichts. Sie ist zugleich eine Grenze für strukturierte Metadaten, die man dann bewusst als Konvention hindurchführt.Aus einem „PDF-Finder" ist so eine Retrieval-Engine geworden, die drei Quellformate kennt, seitengenau zitiert und lokal wie in der Cloud läuft, ohne dass die Kernpipeline je davon wusste, woher der Text kam. Das ist für mich die eigentliche Pointe: Die spannenden Erweiterungen waren fast nie die, die man am tiefsten ins System schneiden musste.
Kommentare