Un index RAG a une durée de vie limitée. Dès qu'une source change, l'index commence à dériver : une page de tarifs est mise à jour, une page de documentation est réécrite, une URL est retirée. Un pipeline qui crawle et génère les embeddings une seule fois n'a aucun moyen de s'en apercevoir, et le modèle continue de répondre à partir d'une version du web qui n'existe plus.
La solution évidente consiste à tout recrawler et à reconstruire chaque embedding. Cela fonctionne, mais une page inchangée et une page réécrite y sont traitées comme le même problème. Sur un corpus réel, la plupart des pages recrawlées sont identiques à ce qui est déjà indexé, et vous payez quand même leur chunking et leurs embeddings. Cet article construit l'alternative : un index RAG incrémental. L'Enterprise Crawler de Crawlbase recrawle selon un planning et livre chaque page à un webhook, un hash de contenu détermine si quelque chose a changé, seules les pages modifiées voient leurs embeddings régénérés dans pgvector, et chaque citation porte la date de dernière vérification de sa source.
- Le recrawl vous apprend ce qui a changé. La régénération des embeddings est la partie que vous pouvez sauter quand rien n'a changé.
- Réservez le
ridde chaque livraison avant tout traitement, afin qu'un webhook relancé n'indexe jamais deux fois la même page. - Hashez le Markdown normalisé, pas le HTML brut, et comparez-le au hash stocké. Même hash : mettez à jour un horodatage. Nouveau hash : refaites le chunking et les embeddings.
- Traitez 404 et 410 comme des suppressions. Une page disparue doit quitter l'index, pas s'y attarder.
- Gardez le webhook en bonne santé. Une livraison échouée est crawlée et facturée à nouveau, et un endpoint défaillant met le crawler en pause.
Le code exécutable se trouve dans ScraperHub/incremental-rag-index-with-crawler-webhooks-and-pgvector, avec des points d'étape sous steps/ et l'application complète sous final/. Chaque extrait ci-dessous est tiré de final/.
Pourquoi la réindexation complète ne passe pas à l'échelle
Une base de connaissances est un jeu de données qui évolue, pas un instantané. Reconstruire tout l'index à chaque crawl résout la fraîcheur au mauvais coût : le crawl doit de toute façon revisiter le corpus, mais rien ne justifie de régénérer les embeddings d'une page dont le contenu est identique à la dernière fois, et chaque reconstruction réécrit l'index vectoriel pour rien. La bonne abstraction est une boucle de mise à jour :
- Recrawler chaque URL selon un planning.
- Déterminer si son contenu indexé a réellement changé.
- Régénérer les embeddings des pages modifiées.
- Laisser les pages inchangées en l'état, mais enregistrer qu'elles ont été vérifiées.
- Supprimer les pages qui n'existent plus.
Le crawl établit l'état actuel de la source. Tout ce qui suit décide si cet état exige une modification de l'index. Cette séparation est toute la conception : le coût de crawl suit la taille du corpus, tandis que le coût d'embedding suit le rythme des changements.
Architecture
rid et acquitte immédiatement ; l'ingestion s'exécute en arrière-plan et décide si pgvector change.Les URL initiales sont poussées avec python push.py. Un job planifié sélectionne ensuite les URL actives de la table pages et les pousse à nouveau. Les deux passent par le même Enterprise Crawler avec crawler=NAME et callback=true. Un push renvoie immédiatement un rid ; le crawler gère la file, la concurrence, les nouvelles tentatives et la livraison.
Chaque livraison arrive sur un webhook FastAPI sous la forme d'un POST dont le corps est la page et dont les en-têtes portent les métadonnées : rid, url, original_status (ce que le site a répondu) et cb_status (le résultat de Crawlbase). Le webhook réserve le rid dans PostgreSQL, renvoie 200, et confie la page à une tâche en arrière-plan. L'ingestion place les pages 404 et 410 en tombstone (suppression logique), ignore tout code non-200 dans cb_status, hashe le reste et ne régénère les embeddings qu'en cas de changement de hash. L'endpoint /query interroge pgvector et renvoie des citations avec last_verified_at.
Le modèle de données
PostgreSQL 16 avec l'extension pgvector contient trois tables. deliveries enregistre chaque rid afin qu'une nouvelle tentative puisse être acquittée sans être traitée deux fois. pages conserve une ligne par URL avec l'état dont le filtrage par hash a besoin : content_hash, last_verified_at, deleted_at et original_status. chunks contient les fragments de texte et leurs 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() );
La colonne d'embedding est de type vector(1536) car l'exemple utilise le modèle d'OpenAI text-embedding-3-small, et la recherche de similarité s'appuie sur un index HNSW avec vector_cosine_ops. Le schéma comporte aussi une colonne recrawl_every que le code actuel n'utilise pas encore ; le recrawl suit un intervalle global unique, et la cadence par URL est abordée à la fin.
Une propriété du modèle compte pour la suite : chunks.content contient des fragments de recherche qui se chevauchent, pas une copie de la page. Impossible de reconstruire le Markdown d'origine à partir d'eux : changer de stratégie de chunking ou de modèle d'embedding implique donc de récupérer à nouveau la source.
Pousser des URL vers l'Enterprise Crawler
Créez un crawler nommé dans la Crawlers console avec livraison par webhook et une URL de callback HTTPS publique. En développement local, un tunnel comme ngrok ou Cloudflare Tunnel expose l'application FastAPI. Choisissez le type de token selon la cible : le JavaScript token lorsque les pages nécessitent un rendu ou page_wait et 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
Le package Python publié crawlbase expose cela via CrawlingAPI.get. Un push vers un crawler est une requête Crawling API avec crawler et callback=true. format=md est appliqué aux pushes du crawler, si bien que le webhook reçoit du GitHub Flavored Markdown. La passe de lisibilité ne fait pas partie aujourd'hui de la conversion Markdown du crawler : attendez-vous donc à recevoir la page complète, navigation et pied de page compris, et concevez l'étape de hash en conséquence.
Le crawler gère les nouvelles tentatives et le rythme, donc l'application ne doit pas ajouter sa propre pause ou son propre backoff par URL. Poussez dans les limites de débit documentées et laissez la file se vider. La documentation de l'Enterprise Crawler couvre la configuration, la livraison et les endpoints de gestion.
Un webhook idempotent
Le webhook est la frontière entre le crawler asynchrone et l'ingestion, et il a trois rôles : décoder le corps, reconnaître les sondes de santé et réserver la livraison avant tout vrai travail. Les livraisons sont compressées en gzip avec Content-Encoding: gzip, Markdown compris, et une livraison Markdown porte aussi 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 exécute INSERT INTO deliveries ... ON CONFLICT (rid) DO NOTHING et vérifie le nombre de lignes : une ligne insérée signifie que cette requête possède la livraison, zéro signifie que le rid a déjà été traité. Partez du principe d'une livraison at-least-once (au moins une fois). Une nouvelle tentative après un timeout porte le même rid, et le réserver avant de planifier l'ingestion garantit que deux copies d'une même livraison ne peuvent jamais écrire toutes deux des chunks.
Les sondes de santé ont besoin de leur propre chemin. Crawlbase vérifie un callback environ toutes les cinq minutes avec une requête envoyée par User-Agent: Crawlbase Monitoring Bot 1.0. Ce n'est pas une livraison de page, donc le handler répond 200 et s'arrête. Seuls 200, 201 ou 204 comptent comme sains. Si la sonde échoue de façon répétée, le crawler cesse de prendre du travail et reprend de lui-même dès que l'endpoint se rétablit, ce qui empêche un déploiement cassé de vider la file dans un endpoint mort.
Deux points opérationnels en découlent. D'abord, authentifiez les livraisons avec un token dans l'URL de callback (?token=..., défini via WEBHOOK_TOKEN) plutôt qu'avec une liste d'IP autorisées. Ensuite, gardez l'acquittement rapide et sortez l'embedding de la requête : une livraison qui échoue est remise en file, crawlée à nouveau et facturée à nouveau, si bien qu'un handler lent transforme un problème applicatif en dépense de crawl. Avec FastAPI, BackgroundTasks suffit pour l'exemple ; une file de workers persistante est l'évolution pour la production.
Régénération des embeddings filtrée par hash
C'est là que se font les économies. La page est normalisée, hashée en SHA-256 et comparée au hash stocké avant tout appel d'embedding.
# 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 n'atteint jamais l'embedder, et un hash inchangé ne l'appelle jamais.La normalisation est volontairement minimale : Unicode NFC, fins de ligne Windows converties, espaces de fin supprimés sur chaque ligne et suites de lignes vides réduites à deux au maximum. Cela élimine le bruit d'espacement sans rien présumer du sens. Les pages modifiées sont découpées avec tiktoken en chunks d'environ 512 tokens avec un chevauchement de 64 tokens, converties en embeddings, puis substituées aux chunks précédents de la page.
Comme les pushes du crawler livrent la page complète, tout ce qui change à chaque rendu, comme une date en pied de page, une bannière de session ou un bloc promotionnel tournant, modifie le hash et déclenche une régénération des embeddings. Si vos sources font cela, retirez le boilerplate connu avant le hash. Cela tient en quelques lignes dans normalize_markdown, et c'est ce qui transforme « la page a été récupérée » en « le contenu a changé ».
Le bénéfice est direct. Le recrawl consomme toujours des requêtes Crawlbase, mais l'embedding et les écritures dans l'index suivent le rythme des changements : recrawlez 1 000 documents dont 30 ont changé, et vous faites des appels d'embedding pour 30.
Suppressions, codes de statut et redirections
La suppression est décidée avant tout le reste. Une page dont le site répond 404 ou 410 est placée en tombstone : deleted_at est renseigné, content_hash est vidé et ses chunks sont supprimés, de sorte qu'elle ne peut plus apparaître dans la recherche. Distinguez bien les deux statuts : original_status est ce que le site a répondu, tandis que cb_status indique si Crawlbase a obtenu une réponse exploitable. Une réponse peut porter un original_status à 200 avec un code non-200 dans cb_status, et ce corps ne doit pas être converti en embeddings.
Les redirections demandent une décision. Quand le crawler suit une redirection HTTP, l'en-tête url porte l'URL finale et original_status porte le code 3xx. Un push pour http://example.com/doc peut donc revenir sous la forme https://example.com/doc/ et créer dans la table pages une seconde ligne. L'exemple utilise l'URL livrée comme clé des pages et recrawle celle-ci. Si vous avez besoin d'une identité stable à travers les redirections, stockez séparément un pushed_url et gérez la relation explicitement plutôt que de laisser une page en remplacer silencieusement une autre.
Recrawls planifiés et état du crawler
Le recrawl réutilise le même chemin de push que le seed. Le job lit les URL actives dans pages, ignore les lignes en tombstone et ne relit jamais le fichier de seed :
# 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 l'exécute à chaque intervalle RECRAWL_INTERVAL_HOURS au sein de l'application (0 le désactive) ; final/recrawl.py exécute le même job depuis cron, et POST /recrawl le déclenche à la demande. Pour la santé de la file, GET /crawler-stats sert de proxy vers l'endpoint de statistiques du crawler, qui liste, pour chaque crawler associé au token, le nombre d'éléments en attente, la concurrence, la latence et l'indicateur paused correspondant. Cet indicateur est la première chose à vérifier après un déploiement :
curl "https://api.crawlbase.com/crawler/YOUR_TOKEN/stats"
Des réponses qui tiennent compte de la fraîcheur
Le chemin de requête génère l'embedding de la question, calcule la similarité cosinus sur chunks, et fait une jointure avec pages afin que chaque résultat porte sa date de vérification :
# 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), )
Les extraits sont transmis au modèle de chat comme contexte, et l'API renvoie la réponse avec des citations contenant l'URL, l'extrait et last_verified_at. Attacher la fraîcheur au moment de la recherche est tout l'enjeu : une source vérifiée il y a une heure et une autre vérifiée il y a six semaines n'ont pas le même poids, même lorsque leurs hashes de contenu sont identiques.
Poussez des URL, recevez chaque page sur votre webhook au format Markdown et laissez le crawler gérer la file, les nouvelles tentatives et le rythme. Les crawls échoués ne sont pas facturés. Commencez avec jusqu'à 5 000 requêtes gratuites, sans carte bancaire.
Exploitation en production
Conservez une copie de la source si vous risquez de refaire le chunking. Comme chunks ne permet pas de reconstruire la page, une nouvelle stratégie de chunking ou un nouveau modèle d'embedding implique de récupérer à nouveau chaque page, sauf si vous les avez conservées. Ajouter store=true à un push webhook conserve aussi le HTML brut de chaque page livrée avec succès dans Cloud Storage, à raison d'un demi-crédit par page stockée et par mois, de sorte qu'un futur re-chunking puisse partir du stockage au lieu de recrawler. Un crawler peut aussi être créé en mode Storage, qui livre vers le stockage plutôt que vers un webhook ; un crawler utilise l'un ou l'autre mode de livraison.
Donnez à chaque URL sa propre cadence. Le planificateur utilise un intervalle global unique, mais le schéma comporte déjà pages.recrawl_every. Un planificateur par URL peut recrawler lorsque last_verified_at + recrawl_every est dépassé, de sorte qu'une page de tarifs soit vérifiée toutes les heures et un changelog archivé tous les mois.
Évaluez honnêtement les économies. Avec 800 pages d'environ 2 000 tokens d'embedding chacune, une réindexation complète chaque nuit convertit en embeddings environ 1,6 million de tokens. Si 4 % des pages changent, le filtrage par hash ramène ce volume à environ 64 000 tokens, tandis que le volume de crawl reste identique. Fixez l'intervalle de recrawl selon le degré d'obsolescence toléré pour une source : le crawl paie la vérification de la fraîcheur, et le hash maintient l'embedding proportionnel aux changements.
Conclusion
Un index devient obsolète le jour même de sa construction, et une reconstruction complète achète la fraîcheur à un prix qui croît avec le corpus. Scinder le problème en deux règle la question du coût : l'Enterprise Crawler recrawle et livre selon un planning, et la couche d'ingestion décide, à partir d'un hash de contenu, si l'index doit changer ou non. Les pages inchangées coûtent un horodatage, les pages modifiées coûtent leurs embeddings, et les pages supprimées s'en vont.
L'implémentation complète se trouve dans ScraperHub/incremental-rag-index-with-crawler-webhooks-and-pgvector. Pour l'exécuter, créez un compte Crawlbase gratuit et configurez un crawler à webhook.
Foire aux questions (FAQ)
L'indexation incrémentale supprime-t-elle le besoin de recrawler ?
Non. Les pages doivent toujours être recrawlées pour savoir si elles ont changé ou disparu. L'indexation incrémentale supprime les embeddings et les écritures d'index pour les pages qui n'ont pas changé.
Pourquoi hasher du Markdown normalisé plutôt que du HTML brut ?
Le Markdown élimine le balisage qui change sans que le contenu change, et la normalisation retire en plus le bruit d'espacement, si bien que le hash suit ce qui est réellement indexé. Les pushes du crawler livrent la page complète : retirez donc avant le hash le boilerplate qui change à chaque rendu, comme les dates en pied de page.
Que se passe-t-il lorsqu'une page n'a pas changé ?
Le nouveau hash correspond à pages.content_hash, les chunks existants restent en place, aucun appel d'embedding n'est effectué, et last_verified_at est mis à jour pour enregistrer la vérification.
Comment les pages supprimées sont-elles retirées de l'index ?
Une livraison avec un original_status à 404 ou 410 place la page en tombstone : elle est marquée comme supprimée, son hash est vidé et ses chunks sont retirés, de sorte qu'elle ne peut plus apparaître dans les résultats de recherche.
Combien coûte une livraison de webhook échouée ?
Une livraison échouée est remise en file et la page est crawlée à nouveau, et chaque tentative est facturée. Si la sonde de monitoring échoue de façon répétée, le crawler se met en pause jusqu'à ce que l'endpoint soit de nouveau sain. Acquittez rapidement et effectuez le travail lourd en arrière-plan.
Crawlez n'importe quel site à grande échelle, sans combattre l'infrastructure.
Crawlbase gère les proxies, les empreintes et les CAPTCHA afin que votre équipe livre des pipelines de données au lieu de maintenir la plomberie de crawl. 1 000 requêtes gratuites, sans carte requise.
