RAG 索引是有保质期的。一旦来源发生变化,索引就开始偏离:定价页面被更新,文档页面被重写,某个 URL 被下线。只爬取和嵌入一次的流水线无从察觉这些变化,模型会继续依据一个已不复存在的网络版本来作答。
显而易见的解决办法是把所有内容重新爬取一遍,并重建每一个嵌入。这样可行,但它把未变化的页面和被重写的页面当作同一个问题来处理。在真实的语料库中,大多数重新爬取的页面与已索引的内容完全相同,而你仍需为它们的分块和嵌入付费。本文构建另一种方案,即 增量 RAG 索引:Crawlbase Enterprise Crawler 按计划重新爬取,并将每个页面投递到 webhook;内容哈希判断是否有任何变化;只有变化的页面才会被重新嵌入到 pgvector 中;每条引用都带有其来源最后一次被验证的时间。
- 重新爬取是你了解变化的途径。当内容没有变化时,重新嵌入就是可以省去的部分。
- 在开始任何工作之前,先认领每次投递的
rid,这样重试的 webhook 就绝不会把同一个页面索引两次。 - 对规范化后的 Markdown 而非原始 HTML 计算哈希,并与已存储的哈希进行比较。哈希相同:仅刷新时间戳。哈希不同:重新分块并重新嵌入。
- 将 404 和 410 视为删除。已经不存在的页面应当离开索引,而不是滞留其中。
- 保持 webhook 健康。投递失败的页面会被再次爬取并再次计费,而持续失败的端点会使爬虫暂停。
可运行的代码位于 ScraperHub/incremental-rag-index-with-crawler-webhooks-and-pgvector,分阶段的检查点位于 steps/,完整的应用程序位于 final/。下文的每个代码片段均引自 final/.
为什么全量重建索引难以扩展
知识库是一个不断变化的数据集,而不是一张快照。每次爬取都重建整个索引,是以错误的代价换取新鲜度:爬取本来就必须重新访问语料库,但对于内容与上次完全相同的页面,没有理由重新生成嵌入,而每次重建都在白白重写向量索引。更有用的抽象是一个更新循环:
- 按计划重新爬取每个 URL。
- 判断其已索引的内容是否真的发生了变化。
- 重新嵌入发生变化的页面。
- 不改动未变化的页面,但记录它们已经过验证。
- 移除已不存在的页面。
爬取确定来源的当前状态。之后的一切都在判断这一状态是否需要更改索引。这种拆分就是整个设计的核心:爬取成本取决于语料库的规模,而嵌入成本取决于变化的频率。
架构
rid 后立即确认;摄取在后台运行,并决定 pgvector 是否需要变更。初始种子 URL 通过 python push.py推送;随后,一个定时任务从 pages 表中选取仍然有效的 URL 并再次推送。两者使用同一个 Enterprise Crawler,并带上 crawler=NAME 和 callback=true。推送会立即返回一个 rid;爬虫负责队列、并发、重试和投递。
每次投递都以 POST 请求的形式到达 FastAPI webhook,其正文是页面内容,请求头则携带元数据:rid, url, original_status(站点返回的状态)以及 cb_status(Crawlbase 的结果)。webhook 在 PostgreSQL 中认领 rid,返回 200,并将页面交给后台任务。摄取过程会将 404 和 410 页面标记为墓碑,跳过任何非 200 的 cb_status,对其余页面计算哈希,并且仅在哈希变化时重新嵌入。/query 端点检索 pgvector,并返回引用,每条引用都附带 last_verified_at.
数据模型
带有 pgvector 扩展的 PostgreSQL 16 包含三张表。deliveries 记录每一个 rid,这样重试可以被确认,而不会被处理两次。pages 为每个 URL 保存一行,包含哈希闸门所需的状态:content_hash, last_verified_at, deleted_at 和 original_status. chunks 保存文本切片及其嵌入。
-- 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() );
嵌入列为 vector(1536),因为示例使用 OpenAI 的 text-embedding-3-small,相似度检索则运行在使用 vector_cosine_ops的 HNSW 索引上。表结构中还包含一个 recrawl_every 列,当前代码尚未使用;重新爬取按单一的全局间隔运行,按 URL 设置的频率会在文末讨论。
模型的一个特性在后文很重要:chunks.content 保存的是相互重叠的检索切片,而不是页面的副本。你无法从中重建原始 Markdown,因此更改分块策略或嵌入模型就意味着要重新获取来源内容。
将 URL 推送到 Enterprise Crawler
在 Crawlers 控制台 中创建一个命名爬虫,启用 webhook 投递,并设置公开的 HTTPS 回调 URL。在本地开发时,可以用 ngrok 或 Cloudflare Tunnel 之类的隧道暴露 FastAPI 应用。根据目标选择令牌类型:页面需要渲染或需要使用以下参数时,选用 JavaScript 令牌:page_wait 和 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
已发布的 crawlbase Python 包通过 CrawlingAPI.get提供这一功能:爬虫推送就是一个 Crawling API 请求,带有 crawler 和 callback=true. format=md 会应用于爬虫推送,因此 webhook 收到的是 GitHub Flavored Markdown。目前 readability 处理并不属于爬虫 Markdown 转换的一部分,因此你会收到完整页面,包括导航和页脚,请据此规划哈希步骤。
爬虫负责重试和节奏控制,因此应用不应再为每个 URL 添加自己的休眠或退避。在文档规定的速率限制内推送,然后让队列自行消化。Enterprise Crawler 文档 涵盖了配置、投递和管理端点。
幂等的 webhook
webhook 是异步爬虫与摄取之间的边界,它有三项职责:解码正文、识别健康探测,并在任何实际工作开始之前认领投递。投递内容以 Content-Encoding: gzip进行 gzip 压缩,Markdown 也不例外,而 Markdown 投递还会携带 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 执行 INSERT INTO deliveries ... ON CONFLICT (rid) DO NOTHING 并检查受影响的行数:插入一行表示本次请求拥有该投递,零行表示该 rid 已经处理过。请将投递视为至少一次(at-least-once)语义。超时后的重试会携带相同的 rid,而在调度摄取之前先认领它,意味着同一投递的两个副本绝不可能同时写入分块。
健康探测需要单独的处理路径。Crawlbase 大约每五分钟用一个带有 User-Agent: Crawlbase Monitoring Bot 1.0的请求检查一次回调;它不是页面投递,因此处理程序返回 200 后即停止。只有 200, 201 或 204 被视为健康。如果探测持续失败,爬虫会停止领取任务,并在端点恢复后自动继续,这样可以避免一次失败的部署把队列消耗在一个无法响应的端点上。
由此引出两个运维要点。第一,用回调 URL 中的令牌(?token=...,设置为 WEBHOOK_TOKEN)而不是 IP 白名单来验证投递。第二,保持确认快速,并把嵌入移出请求处理过程:失败的投递会被重新排队、再次爬取并再次计费,因此一个缓慢的处理程序会把应用问题变成爬取开销。对于本示例,FastAPI BackgroundTasks 已经足够;持久化的工作队列则是生产环境中的升级方案。
基于哈希闸门的重新嵌入
节省正是来自这里。页面会先被规范化,再用 SHA-256 计算哈希,并在发起任何嵌入调用之前与已存储的哈希进行比较。
# 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 永远不会到达嵌入器,而未变化的哈希永远不会调用它。规范化刻意保持精简:Unicode NFC、转换 Windows 换行符、去除每行末尾的空白,并将连续空行压缩为最多两行。这样可以去除空白噪声,而无需揣测内容含义。发生变化的页面使用 tiktoken 切分为约 512 个词元的分块,相邻分块重叠 64 个词元,然后进行嵌入,并替换该页面先前的分块。
由于爬虫推送投递的是完整页面,任何在每次渲染时都会变化的内容,例如页脚日期、会话横幅或轮播的促销区块,都会改变哈希并触发重新嵌入。如果你的来源存在这种情况,请在计算哈希之前去除已知的样板内容。这只需在 normalize_markdown中加几行代码,而正是它把“页面已被获取”变成了“内容已发生变化”。
收益很直接。重新爬取仍然会消耗 Crawlbase 请求,但嵌入和索引写入取决于变化率:重新爬取 1,000 个文档,其中 30 个发生了变化,你就只需为这 30 个文档调用嵌入。
删除、状态码与重定向
删除判断先于其他一切。站点返回 404 或 410 的页面会被标记为墓碑:deleted_at 被设置,content_hash 被清空,其分块被删除,因此它不会再出现在搜索中。请区分这两个状态:original_status 是站点返回的状态,而 cb_status 表示 Crawlbase 是否获得了可用的响应。一个响应可能带有 original_status 200,而 cb_status却不是 200,这样的正文不得被嵌入。
重定向需要一个决策。当爬虫跟随 HTTP 重定向时,url 请求头携带最终 URL,而 original_status 携带 3xx 状态码。因此,对 http://example.com/doc 的推送可能以 https://example.com/doc/ 的形式返回,并创建第二个 pages 行。本示例以投递的 URL 作为页面的键,并重新爬取该 URL。如果你需要跨重定向的稳定标识,请单独存储一个 pushed_url 并显式管理二者的关系,而不是让一个页面悄无声息地替换另一个页面。
定时重新爬取与爬虫状态
重新爬取复用与种子相同的推送路径。该任务从 pages读取仍然有效的 URL,跳过已标记为墓碑的行,并且从不重新读取种子文件:
# 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 在应用内每隔 RECRAWL_INTERVAL_HOURS 小时运行一次该任务(0 表示禁用);final/recrawl.py 通过 cron 运行同一任务,而 POST /recrawl 可按需触发该任务。为了查看队列健康状况,GET /crawler-stats 代理了爬虫统计端点,它会列出该令牌下的每个爬虫,包括等待数量、并发数、延迟以及一个 paused 标志。部署之后首先要检查的就是这个标志:
curl "https://api.crawlbase.com/crawler/YOUR_TOKEN/stats"
带新鲜度信息的回答
查询路径会对问题进行嵌入,针对 chunks执行余弦相似度检索,并联接 pages,使每个命中结果都带有其验证时间:
# 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), )
摘录作为上下文传给聊天模型,API 返回答案及引用,每条引用包含 URL、摘录和 last_verified_at。在检索时附加新鲜度信息正是关键所在:一小时前验证过的来源与六周前验证过的来源权重不同,即使它们的内容哈希相同。
推送 URL,让每个页面以 Markdown 格式投递到你的 webhook,并由爬虫负责队列、重试和节奏控制。失败的爬取不计费。从最多 5,000 次免费请求开始,无需信用卡。
在生产环境中运行
如果可能需要重新分块,请保留一份来源副本。 由于 chunks 无法重建页面,新的分块策略或嵌入模型就意味着要重新获取每个页面,除非你保留了它们。在 webhook 推送中加入 store=true 还会把每个成功投递页面的原始 HTML 保存在 Cloud Storage中,每个存储页面每月收费半个积分,这样以后重新分块时可以直接从存储中转换,而无需重新爬取。也可以将爬虫创建为 Storage 模式,此时内容投递到存储而不是 webhook;一个爬虫只能使用其中一种投递模式。
为每个 URL 设置各自的频率。 调度器使用单一的全局间隔,但表结构中已经有 pages.recrawl_every。按 URL 调度的调度器可以在 last_verified_at + recrawl_every 到期时重新爬取,这样定价页面可以每小时检查一次,而归档的更新日志每月检查一次。
如实估算节省。 假设有 800 个页面,每个约 2,000 个嵌入词元,每晚全量重建索引大约要嵌入 1,600,000 个词元。如果有 4% 的页面发生变化,哈希闸门可将其削减到约 64,000 个词元,而爬取量保持不变。根据来源允许过时的程度来设置重新爬取间隔:爬取是在为检查新鲜度付费,而哈希让嵌入量与变化量成正比。
结论
索引从建成之日起就开始过时,而全量重建换取新鲜度的代价随语料库规模增长。把问题一分为二就能解决成本问题:Enterprise Crawler 按计划重新爬取并投递,摄取层则根据内容哈希判断索引是否需要变更。未变化的页面只需一个时间戳,变化的页面只需支付其嵌入成本,而被删除的页面则被移出索引。
完整实现位于 ScraperHub/incremental-rag-index-with-crawler-webhooks-and-pgvector。如需运行,创建一个免费的 Crawlbase 账户,然后设置一个 webhook 爬虫。
常见问题解答(FAQ)
增量索引是否就不需要重新爬取了?
不是。仍然需要重新爬取页面,才能知道它们是否发生了变化或已经消失。增量索引省去的是未变化页面的嵌入和索引写入。
为什么对规范化后的 Markdown 而不是原始 HTML 计算哈希?
Markdown 去掉了那些内容不变时也会变化的标记,规范化又在此基础上去除了空白噪声,因此哈希追踪的正是实际被索引的内容。爬虫推送投递的是完整页面,所以在计算哈希之前,请去除每次渲染都会变化的样板内容,例如页脚日期。
页面没有变化时会发生什么?
新的哈希与 pages.content_hash一致,现有分块保持不变,不会发起嵌入调用,并且 last_verified_at 会被更新,以记录本次检查。
已删除的页面如何从索引中移除?
如果投递的 original_status 为 404 或 410,页面就会被标记为墓碑:它被标记为已删除,哈希被清空,分块被移除,因此它不会再出现在搜索结果中。
失败的 webhook 投递会带来什么成本?
失败的投递会被重新排队,页面会被再次爬取,而每次尝试都会计费。如果监控探测持续失败,爬虫会暂停,直到端点恢复健康。请快速确认,并在后台完成繁重的工作。
大规模爬取任何站点,无需与基础设施对抗。
Crawlbase 负责处理代理、指纹和 CAPTCHA,让你的团队专注于交付数据流水线,而非维护爬取管道。1,000 次请求免费,无需信用卡。
