Alain Lilic

DemoRecruiting-Challenge, April 2026

Hybrid-RAG mit Quellenpflicht

Problem

Die Aufgabe: in etwa 2-4 Stunden ein End-to-End-RAG mit Next.js und Supabase bauen, das Fragen zu einer eigenen Wissensbasis beantwortet. Ein normales LLM kennt die Inhalte einer Firma nicht und erfindet plausible, falsche Antworten. Gefragt war ein nachvollziehbarer Ansatz, nicht Vollständigkeit.

Meine Rolle

Ich habe Wissensbasis und Architektur gewählt und vorab einen Skill für meinen Coding-Agenten gebaut, der RAG-Regeln sowie SQL- und TypeScript-Vorlagen bündelt. Mit diesem Skill entstand die App. Ich habe den Code geprüft, die Ingestion aufgesetzt, auf Vercel deployt und die Präsentation für das Gespräch vorbereitet.

Vorgehen

Die Wissensbasis sind zwei öffentliche Websites und ein YouTube-Kanal. Ein cheerio-Crawler liest die Seiten, ein Loader holt Transkripte mit Zeitstempeln. Der Chunker trennt erst an Überschriften, dann an Absätzen, dann an Sätzen (Ziel 450 Tokens, 60 Overlap). Die Embeddings liegen mit pgvector in Supabase, ein Content-Hash macht erneutes Einlesen idempotent.

Pro Frage läuft eine SQL-Funktion: Vektor- und Volltextsuche, zusammengeführt per Reciprocal Rank Fusion. Die besten Chunks gehen nummeriert in den Prompt, bis 3000 Tokens. Das Modell darf nur aus dem Kontext antworten und muss jede Aussage mit [n] belegen; fehlt die Antwort im Kontext, kommt ein fester Satz. Die Quellen streamen vor der Antwort ins UI, YouTube-Links springen an die zitierte Sekunde. Jede Frage landet mit gefundenen und genutzten Chunk-IDs in query_logs. Ein Skript prüft 10 Fragen mit bekannter Antwort und schlägt unter 70 % Trefferquote fehl.

Architektur

  1. Quellen

    Zwei Websites (cheerio-Crawler), ein YouTube-Kanal (Transkripte mit Zeitstempel)

  2. Ingestion

    Semantisches Chunking, Embeddings mit text-embedding-3-small, Content-Hash gegen Duplikate

  3. Supabase Postgres

    documents, chunks mit vector(1536) und HNSW, GIN-Volltextindex, query_logs, tenant_id überall

  4. hybrid_search_chunks

    Vektor- und Volltextsuche in einer Funktion, Fusion per RRF mit k = 60, Top 8

  5. Kontext-Builder

    Nummerierte Chunks mit Titel und Überschrift, Budget 3000 Tokens

  6. Route Handler /api/chat

    gpt-4o-mini über AI SDK, Pflicht-Zitate [n], Streaming mit Quellen vorab

  7. UI und Logging

    Quellenliste neben der Antwort, YouTube-Deeplinks auf die Sekunde, Eintrag in query_logs

Entscheidung

Ränge fusionieren statt Scores addieren

Kosinus-Ähnlichkeit und ts_rank liegen auf verschiedenen Skalen, eine gewichtete Summe hätte ich ständig nachjustieren müssen. RRF rechnet nur mit Rängen: jeder Treffer bekommt 1/(k + Rang) mit k = 60, die Summen entscheiden. Beides läuft in einer Postgres-Funktion, also ein Roundtrip. Eigennamen und Versionsnummern, die reine Vektorsuche gern verliert, findet die Volltextsuche trotzdem. Der Nachteil: Die Rohscores gehen verloren, ein sehr starker Treffer in nur einer Liste kann gegen zwei mittlere verlieren.

Code-Auszug

supabase/migrations/002_functions.sql (gekürzt)Seitlich scrollensql
with vector_hits as (
    select c.id, row_number() over (order by c.embedding <=> query_embedding) as rank
    from public.chunks c
    where (p_tenant_id is null or c.tenant_id = p_tenant_id)
    order by c.embedding <=> query_embedding
    limit match_count * 2
),
keyword_hits as (
    select c.id, row_number() over (
        order by ts_rank_cd(to_tsvector('simple', c.content),
                            websearch_to_tsquery('simple', query_text)) desc
    ) as rank
    from public.chunks c
    where (p_tenant_id is null or c.tenant_id = p_tenant_id)
      and to_tsvector('simple', c.content) @@ websearch_to_tsquery('simple', query_text)
    limit match_count * 2
),
fused as (
    select id, sum(1.0 / (rrf_k + rank)) as score
    from (select id, rank from vector_hits
          union all select id, rank from keyword_hits) u
    group by id
)
select c.id, c.document_id, c.content, c.metadata, f.score
from fused f join public.chunks c on c.id = f.id order by f.score desc limit match_count;
Beide Suchen holen doppelt so viele Kandidaten wie gebraucht, die Fusion zählt nur Ränge. Der Mandanten-Filter ist schon im Schema angelegt.

Ergebnisse

  • 10Fragen mit bekannter Antwort im Retrieval-Test
  • 70 %Mindest-Trefferquote, darunter Exit-Code 1
  • 3Quellen: zwei Websites, ein YouTube-Kanal
  • 3000Tokens Kontextbudget pro Antwort

Was ich daraus gelernt habe

Das Ergebnis des Retrieval-Tests habe ich nicht ins Repo geschrieben, und die README sagte noch, es gebe keinen Eval-Runner. Heute committe ich Messwerte mit dem Code. Der Test prüft nur, ob ein Stichwort im Titel der Top 3 steht; besser wären gelabelte Chunk-IDs mit Recall@k und MRR. Außerdem ist /api/ingest per Admin-Key geschützt, /api/chat aber offen und ohne Rate-Limit, und jede Frage kostet Tokens. Solange das so ist, verlinke ich die Demo hier nicht.