Ein RAG-Index hat ein Haltbarkeitsdatum. Sobald sich eine Quelle ändert, beginnt der Index abzudriften: Eine Preisseite wird aktualisiert, eine Dokumentationsseite neu geschrieben, eine URL stillgelegt. Eine Pipeline, die einmal crawlt und einmal embeddet, kann nichts davon bemerken, und das Modell antwortet weiter aus einer Version des Webs, die es nicht mehr gibt.
Die naheliegende Lösung ist, alles erneut zu crawlen und jedes Embedding neu zu erzeugen. Das funktioniert, behandelt eine unveränderte und eine neu geschriebene Seite aber als dasselbe Problem. In einem realen Korpus sind die meisten erneut gecrawlten Seiten identisch mit dem, was bereits indexiert ist, und trotzdem bezahlen Sie dafür, sie zu chunken und zu embedden. Dieser Beitrag baut die Alternative, einen inkrementellen RAG-Index: Der Crawlbase Enterprise Crawler crawlt nach Zeitplan erneut und liefert jede Seite an einen Webhook, ein Content-Hash entscheidet, ob sich etwas geändert hat, nur geänderte Seiten werden neu in pgvector embeddet, und jede Quellenangabe trägt den Zeitpunkt, zu dem ihre Quelle zuletzt verifiziert wurde.
- Der Recrawl zeigt Ihnen, was sich geändert hat. Das erneute Embedding können Sie sich sparen, wenn sich nichts geändert hat.
- Beanspruchen Sie die
ridjeder Zustellung, bevor Sie mit der Arbeit beginnen, damit ein erneut zugestellter Webhook dieselbe Seite nie zweimal indexiert. - Hashen Sie normalisiertes Markdown, nicht rohes HTML, und vergleichen Sie es mit dem gespeicherten Hash. Gleicher Hash: einen Zeitstempel aktualisieren. Neuer Hash: neu chunken und neu embedden.
- Behandeln Sie 404 und 410 als Löschungen. Eine Seite, die nicht mehr existiert, sollte den Index verlassen, statt darin liegen zu bleiben.
- Halten Sie den Webhook gesund. Eine fehlgeschlagene Zustellung wird erneut gecrawlt und erneut abgerechnet, und ein fehlerhafter Endpoint pausiert den Crawler.
Der lauffähige Code liegt in ScraperHub/incremental-rag-index-with-crawler-webhooks-and-pgvector, mit schrittweisen Checkpoints unter steps/ und der vollständigen Anwendung unter final/. Jedes der folgenden Snippets stammt aus final/.
Warum eine vollständige Neuindexierung nicht skaliert
Eine Wissensdatenbank ist ein sich ändernder Datensatz, keine Momentaufnahme. Den gesamten Index bei jedem Crawl neu aufzubauen, löst das Aktualitätsproblem zum falschen Preis: Der Crawl muss den Korpus ohnehin erneut besuchen, aber es gibt keinen Grund, Embeddings für eine Seite neu zu erzeugen, deren Inhalt identisch mit dem letzten Mal ist, und jeder Neuaufbau schreibt den Vektorindex umsonst neu. Die nützliche Abstraktion ist eine Update-Schleife:
- Jede URL nach Zeitplan erneut crawlen.
- Entscheiden, ob sich ihr indexierter Inhalt tatsächlich geändert hat.
- Die geänderten Seiten neu embedden.
- Unveränderte Seiten in Ruhe lassen, aber festhalten, dass sie verifiziert wurden.
- Seiten entfernen, die nicht mehr existieren.
Der Crawl stellt den aktuellen Zustand der Quelle fest. Alles danach entscheidet, ob dieser Zustand eine Änderung am Index erfordert. Diese Trennung ist das gesamte Design: Die Crawl-Kosten folgen der Größe des Korpus, die Embedding-Kosten dagegen der Änderungsrate.
Architektur
rid und bestätigt sofort; die Ingestion läuft im Hintergrund und entscheidet, ob sich pgvector ändert.Initiale Seed-URLs pusht python push.py; ein geplanter Job wählt später aktive URLs aus der Tabelle pages aus und pusht sie erneut. Beide nutzen denselben Enterprise Crawler mit crawler=NAME und callback=true. Ein Push gibt sofort eine rid zurück; der Crawler übernimmt Queue, Parallelität, Retries und Zustellung.
Jede Zustellung kommt an einem FastAPI-Webhook als POST an, dessen Body die Seite ist und dessen Header die Metadaten tragen: rid, url, original_status (was die Website geantwortet hat) und cb_status (das Ergebnis von Crawlbase). Der Webhook beansprucht die rid in PostgreSQL, antwortet mit 200, danach übergibt er die Seite an einen Hintergrund-Task. Die Ingestion versieht 404- und 410-Seiten mit einem Tombstone, überspringt jeden von 200 abweichenden cb_status, hasht den Rest und embeddet nur bei einer Hash-Änderung neu. Der /query Endpoint durchsucht pgvector und liefert Quellenangaben mit last_verified_at.
Das Datenmodell
PostgreSQL 16 mit der Erweiterung pgvector enthält drei Tabellen. deliveries erfasst jede rid und erlaubt so, einen Retry zu bestätigen, ohne ihn zweimal zu verarbeiten. pages führt eine Zeile pro URL mit dem Zustand, den das Hash-Gate braucht: content_hash, last_verified_at, deleted_at und original_status. chunks enthält die Textabschnitte und ihre Embeddings.
-- final/sql/schema.sql (excerpt) CREATE TABLE IF NOT EXISTS chunks ( id BIGSERIAL PRIMARY KEY, url TEXT NOT NULL REFERENCES pages (url) ON DELETE CASCADE, chunk_index INTEGER NOT NULL, content TEXT NOT NULL, embedding vector(1536) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (url, chunk_index) ); CREATE TABLE IF NOT EXISTS deliveries ( rid TEXT PRIMARY KEY, url TEXT, original_status INTEGER, cb_status INTEGER, received_at TIMESTAMPTZ NOT NULL DEFAULT now() );
Die Embedding-Spalte ist als vector(1536) dimensioniert, passend zu OpenAIs text-embedding-3-small, das im Beispiel verwendet wird, und die Ähnlichkeitssuche läuft auf einem HNSW-Index mit vector_cosine_ops. Das Schema enthält außerdem recrawl_every als Spalte, die der aktuelle Code noch nicht nutzt; der Recrawl läuft in einem globalen Intervall, und eine Taktung pro URL kommt am Ende zur Sprache.
Eine Eigenschaft des Modells ist später wichtig: chunks.content enthält überlappende Retrieval-Abschnitte, keine Kopie der Seite. Sie können das ursprüngliche Markdown daraus nicht rekonstruieren, daher bedeutet eine Änderung der Chunking-Strategie oder des Embedding-Modells, dass Sie die Quelle erneut abrufen müssen.
URLs an den Enterprise Crawler pushen
Legen Sie in der Crawlers console einen benannten Crawler mit Webhook-Zustellung und einer öffentlichen HTTPS-Callback-URL an. Für die lokale Entwicklung macht ein Tunnel wie ngrok oder Cloudflare Tunnel die FastAPI-App erreichbar. Wählen Sie den Token-Typ passend zum Ziel: den JavaScript token, wenn Seiten Rendering benötigen, oder für page_wait und ajax_wait.
# final/app/crawler.py def push_options() -> dict: options = { "crawler": settings.crawler_name, "callback": "true", "format": "md", "md_readability": "true", } if settings.page_wait is not None: options["page_wait"] = str(settings.page_wait) if settings.ajax_wait is not None: options["ajax_wait"] = str(settings.ajax_wait) return options def push_url(url: str) -> str: """Enqueue one URL. Returns the Crawlbase rid.""" api = _client() res = api.get(url, push_options()) rid = _rid_from_response(res if isinstance(res, dict) else {}) if not rid: raise RuntimeError(f"Crawler push failed for {url!r}: {res!r}") return rid
Das veröffentlichte crawlbase Python-Paket bietet dafür CrawlingAPI.get: Ein Crawler-Push ist ein Request an die Crawling API mit crawler und callback=true. format=md wird bei Crawler-Pushes angewendet, sodass der Webhook GitHub Flavored Markdown erhält. Der Readability-Durchlauf ist derzeit nicht Teil der Markdown-Konvertierung des Crawlers, rechnen Sie also mit der vollständigen Seite einschließlich Navigation und Footer, und planen Sie den Hash-Schritt entsprechend.
Der Crawler übernimmt Retries und Taktung, daher sollte die App keine eigenen Pausen oder Backoffs pro URL hinzufügen. Pushen Sie innerhalb der dokumentierten Rate Limits und lassen Sie die Queue abarbeiten. Die Dokumentation zum Enterprise Crawler behandelt Konfiguration, Zustellung und die Management-Endpoints.
Ein idempotenter Webhook
Der Webhook ist die Grenze zwischen dem asynchronen Crawler und der Ingestion und hat drei Aufgaben: den Body dekodieren, Health-Probes erkennen und die Zustellung beanspruchen, bevor die eigentliche Arbeit beginnt. Zustellungen sind gzip-komprimiert und tragen Content-Encoding: gzip, Markdown eingeschlossen, und eine Markdown-Zustellung trägt außerdem Content-Type: text/markdown; charset=utf-8.
# final/app/webhook.py (inside the /webhook handler) raw = await request.body() markdown = decode_body(raw, request.headers.get("content-encoding")) if is_monitor_probe(request.headers.get("user-agent", ""), markdown): return Response(status_code=200) if settings.webhook_token and token != settings.webhook_token: raise HTTPException(status_code=401, detail="invalid webhook token") # ... read rid, url, original_status and cb_status from the headers ... if not claim_delivery(rid, url, original_status, cb_status): return Response(status_code=200) background_tasks.add_task( ingest_delivery, rid, url or "", original_status, cb_status, markdown ) return Response(status_code=200)
claim_delivery führt INSERT INTO deliveries ... ON CONFLICT (rid) DO NOTHING aus und prüft die Zeilenanzahl: Eine eingefügte Zeile bedeutet, dass dieser Request die Zustellung besitzt, null bedeutet, dass die rid bereits verarbeitet wurde. Gehen Sie von einer At-least-once-Zustellung aus. Ein Retry nach einem Timeout trägt dieselbe rid, und weil sie beansprucht wird, bevor die Ingestion eingeplant wird, können zwei Kopien einer Zustellung nie beide Chunks schreiben.
Health-Probes brauchen einen eigenen Pfad. Crawlbase prüft einen Callback etwa alle fünf Minuten mit einem Request mit dem Header User-Agent: Crawlbase Monitoring Bot 1.0; das ist keine Seitenzustellung, daher antwortet der Handler mit 200 und beendet die Verarbeitung. Nur 200, 201 oder 204 gelten als gesund. Schlägt die Probe weiterhin fehl, nimmt der Crawler keine Arbeit mehr an und setzt von selbst fort, sobald sich der Endpoint erholt hat. So verhindert er, dass ein fehlerhafter Deploy die Queue in einen toten Endpoint leert.
Daraus folgen zwei betriebliche Punkte. Erstens: Authentifizieren Sie Zustellungen mit einem Token in der Callback-URL (?token=..., gesetzt als WEBHOOK_TOKEN) statt mit einer IP-Allowlist. Zweitens: Halten Sie die Bestätigung schnell und verlagern Sie das Embedding aus dem Request heraus: Eine fehlgeschlagene Zustellung wird erneut eingereiht, erneut gecrawlt und erneut abgerechnet, sodass ein langsamer Handler ein Anwendungsproblem in Crawl-Kosten verwandelt. FastAPI BackgroundTasks genügt für das Beispiel; eine persistente Worker-Queue ist das Upgrade für die Produktion.
Hash-gesteuertes Re-Embedding
Hier entstehen die Einsparungen. Die Seite wird normalisiert, mit SHA-256 gehasht und mit dem gespeicherten Hash verglichen, bevor ein Embedding-Aufruf erfolgt.
# final/app/ingest.py (inside ingest_delivery) with get_conn() as conn: if original_status in (404, 410): tombstone_page(conn, url, original_status) conn.commit() return "deleted" if cb_status is not None and cb_status != 200: log.warning("skip rid=%s: cb_status=%s (not embedding)", rid, cb_status) conn.commit() return "skipped" normalized = normalize_markdown(markdown) content_hash = content_sha256(normalized) page = fetch_page(conn, url) if page and page["content_hash"] == content_hash and page["deleted_at"] is None: touch_page(conn, url, original_status) conn.commit() return "unchanged" texts = chunk_markdown(normalized) vectors = embed_texts(texts) upsert_page(conn, url, content_hash, original_status) replace_chunks(conn, url, texts, vectors) conn.commit() return "embedded"
cb_status ungleich 200 erreicht den Embedder nie, und ein unveränderter Hash ruft ihn nie auf.Die Normalisierung ist bewusst knapp gehalten: Unicode NFC, Windows-Zeilenenden umgewandelt, nachgestellte Leerzeichen in jeder Zeile entfernt und Folgen von Leerzeilen auf höchstens zwei zusammengefasst. Das entfernt Whitespace-Rauschen, ohne Bedeutung zu erraten. Geänderte Seiten werden mit tiktoken in Chunks von etwa 512 Tokens mit 64 Tokens Überlappung aufgeteilt, embeddet und anstelle der bisherigen Chunks der Seite eingesetzt.
Da Crawler-Pushes die vollständige Seite liefern, ändert alles, was sich bei jedem Rendern ändert, etwa ein Datum im Footer, ein Session-Banner oder ein wechselnder Promo-Block, den Hash und löst ein Re-Embedding aus. Wenn Ihre Quellen das tun, entfernen Sie den bekannten Boilerplate vor dem Hashen. Das sind ein paar Zeilen in normalize_markdown, und genau das macht aus „die Seite wurde abgerufen“ ein „der Inhalt hat sich geändert“.
Der Nutzen ist eindeutig. Recrawls verbrauchen weiterhin Crawlbase Requests, aber Embeddings und Index-Schreibvorgänge folgen der Änderungsrate: Crawlen Sie 1.000 Dokumente erneut, von denen sich 30 geändert haben, und Sie tätigen Embedding-Aufrufe für 30.
Löschungen, Statuscodes und Redirects
Über die Löschung wird vor allem anderen entschieden. Eine Seite, deren Website mit 404 oder 410 antwortet, erhält einen Tombstone: deleted_at wird gesetzt, content_hash wird geleert und ihre Chunks werden gelöscht, sodass sie in der Suche nicht mehr auftauchen kann. Halten Sie die beiden Status auseinander: original_status ist das, was die Website geantwortet hat, während cb_status angibt, ob Crawlbase eine verwertbare Antwort erhalten hat. Eine Antwort kann original_status 200 tragen, zusammen mit einem von 200 abweichenden cb_status, und dieser Body darf nicht embeddet werden.
Bei Redirects ist eine Entscheidung nötig. Wenn der Crawler einem HTTP-Redirect folgt, trägt der Header url die finale URL und original_status den 3xx-Code. Ein Push für http://example.com/doc kann daher als https://example.com/doc/ zurückkommen und eine zweite Zeile in pages anlegen. Das Beispiel verwendet die zugestellte URL als Schlüssel für Seiten und crawlt diese erneut. Wenn Sie über Redirects hinweg eine stabile Identität brauchen, speichern Sie separat pushed_url und verwalten Sie die Beziehung explizit, statt eine Seite stillschweigend eine andere ersetzen zu lassen.
Geplante Recrawls und Crawler-Zustand
Der Recrawl nutzt denselben Push-Pfad wie der Seed. Der Job liest aktive URLs aus pages, überspringt Zeilen mit Tombstone und liest die Seed-Datei nie erneut:
# final/app/recrawl.py def run_recrawl() -> dict[str, str]: results: dict[str, str] = {} for url in list_live_urls(): try: results[url] = push_url(url) log.info("recrawl queued url=%s rid=%s", url, results[url]) except Exception: log.exception("recrawl push failed url=%s", url) results[url] = "error" return results
APScheduler führt ihn alle RECRAWL_INTERVAL_HOURS Stunden innerhalb der App aus (0 deaktiviert ihn); final/recrawl.py führt denselben Job per Cron aus, und POST /recrawl löst ihn bei Bedarf aus. Für die Queue-Gesundheit leitet GET /crawler-stats an den Stats-Endpoint des Crawlers weiter, der jeden Crawler des Tokens mit Anzahl wartender Einträge, Parallelität, Latenz und einem paused Flag auflistet. Dieses Flag ist das Erste, was Sie nach einem Deploy prüfen sollten:
curl "https://api.crawlbase.com/crawler/YOUR_TOKEN/stats"
Antworten mit Aktualitätsangabe
Der Abfragepfad embeddet die Frage, sucht per Kosinus-Ähnlichkeit in chunks, bindet per Join pages ein, sodass jeder Treffer seinen Verifizierungszeitpunkt mitbringt:
# final/app/query.py cur.execute( """ SELECT c.content, c.url, p.last_verified_at FROM chunks c JOIN pages p ON p.url = c.url WHERE p.deleted_at IS NULL ORDER BY c.embedding <=> %s::vector LIMIT %s """, (qvec, k), )
Die Auszüge gehen als Kontext an das Chat-Modell, und die API liefert die Antwort mit Quellenangaben aus URL, Auszug und last_verified_at. Genau darum geht es, die Aktualität zum Zeitpunkt des Retrievals mitzuliefern: Eine vor einer Stunde verifizierte Quelle und eine vor sechs Wochen verifizierte haben unterschiedliches Gewicht, selbst wenn ihre Content-Hashes übereinstimmen.
Pushen Sie URLs, lassen Sie sich jede Seite als Markdown an Ihren Webhook liefern, und überlassen Sie dem Crawler Queue, Retries und Taktung. Fehlgeschlagene Crawls werden nicht berechnet. Zum Start gibt es bis zu 5.000 kostenlose Requests, ohne Kreditkarte.
Betrieb in Produktion
Behalten Sie eine Kopie der Quelle, wenn Sie eventuell neu chunken. Da chunks die Seite nicht rekonstruieren kann, bedeutet eine neue Chunking-Strategie oder ein neues Embedding-Modell, jede Seite erneut abzurufen, es sei denn, Sie haben sie aufbewahrt. Wenn Sie store=true zu einem Webhook-Push hinzufügen, wird außerdem das rohe HTML jeder erfolgreich zugestellten Seite gespeichert, und zwar in Cloud Storage, zu einem halben Credit pro gespeicherter Seite und Monat, sodass ein späteres Neu-Chunken aus dem Speicher konvertieren kann, statt erneut zu crawlen. Alternativ kann ein Crawler im Storage-Modus angelegt werden, der in den Speicher statt an einen Webhook liefert; ein Crawler nutzt entweder den einen oder den anderen Zustellmodus.
Geben Sie jeder URL ihren eigenen Takt. Der Scheduler nutzt ein globales Intervall, aber das Schema enthält bereits pages.recrawl_every. Ein Scheduler pro URL kann erneut crawlen, wenn last_verified_at + recrawl_every überschritten ist, sodass eine Preisseite stündlich und ein archiviertes Changelog monatlich geprüft wird.
Schätzen Sie die Einsparungen ehrlich ein. Bei 800 Seiten mit jeweils etwa 2.000 Embedding-Tokens embeddet eine vollständige nächtliche Neuindexierung rund 1,6 Millionen Tokens. Wenn sich 4 % der Seiten ändern, senkt das Hash-Gating das auf etwa 64.000 Tokens, während das Crawl-Volumen gleich bleibt. Legen Sie das Recrawl-Intervall danach fest, wie veraltet eine Quelle werden darf: Das Crawlen bezahlt die Prüfung der Aktualität, und das Hashing hält das Embedding proportional zur Änderung.
Fazit
Ein Index veraltet an dem Tag, an dem er gebaut wird, und ein vollständiger Neuaufbau erkauft Aktualität zu einem Preis, der mit dem Korpus wächst. Das Problem in zwei Teile zu zerlegen, löst die Kostenfrage: Der Enterprise Crawler crawlt erneut und liefert nach Zeitplan, und die Ingestion-Schicht entscheidet anhand eines Content-Hashes, ob sich der Index überhaupt ändern muss. Unveränderte Seiten kosten einen Zeitstempel, geänderte Seiten kosten ihre Embeddings, und gelöschte Seiten verschwinden.
Die vollständige Implementierung finden Sie in ScraperHub/incremental-rag-index-with-crawler-webhooks-and-pgvector. Um sie auszuführen, erstellen Sie ein kostenloses Crawlbase-Konto und richten Sie einen Webhook-Crawler ein.
Häufig gestellte Fragen (FAQs)
Macht inkrementelle Indexierung den Recrawl überflüssig?
Nein. Seiten müssen weiterhin erneut gecrawlt werden, um festzustellen, ob sie sich geändert haben oder verschwunden sind. Inkrementelle Indexierung spart das Embedding und die Index-Schreibvorgänge für Seiten ein, die sich nicht geändert haben.
Warum normalisiertes Markdown statt rohem HTML hashen?
Markdown verwirft Markup, das sich ändert, ohne dass sich der Inhalt ändert, und die Normalisierung entfernt zusätzlich Whitespace-Rauschen, sodass der Hash erfasst, was tatsächlich indexiert wird. Crawler-Pushes liefern die vollständige Seite, entfernen Sie daher vor dem Hashen Boilerplate, der sich bei jedem Rendern ändert, etwa Datumsangaben im Footer.
Was passiert, wenn sich eine Seite nicht geändert hat?
Der neue Hash entspricht pages.content_hash, die vorhandenen Chunks bleiben erhalten, es erfolgt kein Embedding-Aufruf, und last_verified_at wird aktualisiert, um die Prüfung festzuhalten.
Wie werden gelöschte Seiten aus dem Index entfernt?
Eine Zustellung mit original_status 404 oder 410 versieht die Seite mit einem Tombstone: Sie wird als gelöscht markiert, ihr Hash wird geleert und ihre Chunks werden entfernt, sodass sie nicht mehr in Suchergebnissen erscheinen kann.
Was kostet eine fehlgeschlagene Webhook-Zustellung?
Eine fehlgeschlagene Zustellung wird erneut eingereiht und die Seite erneut gecrawlt, und jeder Versuch wird abgerechnet. Schlägt die Monitoring-Probe weiterhin fehl, pausiert der Crawler, bis der Endpoint wieder gesund ist. Bestätigen Sie schnell und erledigen Sie die aufwendige Arbeit im Hintergrund.
Crawlen Sie jede Website im großen Maßstab, ohne gegen die Infrastruktur zu kämpfen.
Crawlbase übernimmt Proxys, Fingerprints und CAPTCHAs, damit Ihr Team Datenpipelines ausliefert, statt Crawl-Infrastruktur zu pflegen. 1.000 Anfragen kostenlos, keine Karte erforderlich.
