RAG 不过时:新鲜网页数据与刷新循环
一个 RAG 系统的新鲜程度取决于其上一次抓取。检索算法是简单的部分——难点在于刷新循环,它能在答案过时之前按各自的时间表重新抓取、重新分块和重新嵌入每个来源。
检索增强生成现已在大约一半的企业 AI 系统中使用——最新调查显示采用率跃升至 51%,而一年前为 31%。然而,大多数 RAG 项目都有相同的失败模式:它们只被填充过一次,然后世界就改变了。检索算法(嵌入查询,搜索向量,将最相关的分块填入提示)是简单且文档齐全的部分。难点在于决定你的助手是值得信赖还是自信地错误的部分,即新鲜度——一个不断重新抓取、重新分块和重新嵌入每个来源的管道,以防止答案过时。本指南介绍如何构建这种刷新循环,以 markdown 为先的摄取和每个来源的 TTL 为核心。
过时的检索比没有检索更糟糕
RAG 的全部承诺在于模型从检索到的证据中回答,而不是从冻结的训练数据中回答。破坏新鲜度就等于破坏承诺:一个支持机器人引用上季度的退货政策,一个定价助手引用已停产的数字,一个研究助手错过了改变答案的更新。更糟糕的是,模型以完全的信心陈述,因为检索到的分块看起来权威。过时的索引不会大声失败——它安静且合理地失败,这是最危险的类型。保持语料库的最新状态不是可有可无的;它是区分扎实基础和幻觉的关键。
摄取 markdown,而不是原始 HTML
下游所有内容的质量在摄取时就已决定。原始 HTML 对检索来说是灾难——导航、cookie 横幅、页脚和脚本标签成为噪音块,污染相似性搜索并浪费上下文标记。干净的方法是以 markdown 为先:抓取每个页面并仅提取主要内容作为结构化 markdown,在分块之前丢弃样板。我们的Scraper API直接返回 markdown,按需渲染 JavaScript 并轮换 IP,以防止被阻止的页面在你的语料库中留下空白——它取代了大多数 RAG 堆栈依赖的脆弱的 Puppeteer 和 BeautifulSoup 摄取层。web-data-for-LLMs 端点将抓取、清理和结构化封装为一个专为此设计的调用。
import requests
# markdown-first ingestion: clean text, JS rendered, IPs rotated
def fetch_markdown(url):
r = requests.post(
"https://api.quantumproxies.io/scrape",
headers={"Authorization": f"Bearer {QP_KEY}"},
json={"url": url, "format": "markdown", "render": True},
timeout=60,
)
doc = r.json()
return {
"url": url,
"markdown": doc["markdown"], # no nav, no boilerplate
"fetched_at": doc["fetchedAt"], # timestamp for TTL logic
"content_hash": doc["contentHash"], # skip re-embed if unchanged
}
分块小,携带元数据
一旦你有了干净的 markdown,将其分成大约 300 到 500 个标记的块——足够小以确保检索到的块紧扣主题,足够大以保持连贯的思路。使用递归分割器(LangChain 或 LlamaIndex 都提供一个),尊重句子和标题边界,而不是在单词中间切割。关键且常被忽略的步骤是元数据:每个块必须携带其来源 URL、时间戳和内容哈希。元数据使新鲜度成为可能——检索可以按最近性过滤,而你的刷新循环可以判断哪些块属于刚刚更改的来源。
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=1800, chunk_overlap=200, # ~400 tokens per chunk
)
def to_chunks(doc):
parts = splitter.split_text(doc["markdown"])
return [{
"text": p,
"source": doc["url"],
"fetched_at": doc["fetched_at"], # drives recency filtering
"ttl": ttl_for(doc["url"]), # per-source refresh clock
} for p in parts]
用当前模型嵌入这些块——如 text-embedding-3-large、Cohere 模型或开放的 Sentence-BERT 变体——并将向量与元数据一起存储在如 Pinecone、Weaviate、Milvus 或 FAISS 的向量数据库中。调整相似性阈值,重要的是,启用元数据过滤,以便查询可以要求,例如,定价问题只检索过去 30 天内获取的块。

每个来源的 TTL:新鲜度的核心
大多数团队犯的错误是按一个时间表刷新所有内容——每晚的全面重新抓取对价格来说太慢,对参考文档来说又浪费。不同的来源老化速度不同,因此为每个来源分配一个与其内容实际变化速度相匹配的生存时间。实时价格和库存可能需要几分钟的 TTL;新闻、列表和论坛帖子需要一个小时;排名和目录需要一天;文档、政策和参考材料需要一周或更长时间。为每个块标记其来源的 TTL,调度程序只重新抓取时钟已过期的来源。内容哈希是你的效率阀门:如果重新抓取的页面在字节上是相同的,完全跳过嵌入步骤,只需重置时间戳。
import time
TTL = {"prices": 300, "news": 3600, "catalog": 86400, "docs": 604800}
def needs_refresh(chunk, now=None):
now = now or time.time()
return (now - chunk["fetched_at"]) > chunk["ttl"]
# refresh only what has expired; re-embed only what actually changed
for src in due_sources(TTL):
doc = fetch_markdown(src.url)
if doc["content_hash"] != src.last_hash:
reindex(to_chunks(doc)) # re-chunk + re-embed
src.touch(doc["content_hash"]) # reset the clock either way
这是将演示系统与生产系统区分开的循环。它保持检索的可预测性,只在世界实际变化的地方花费计算资源,并允许你对每个来源做出真正的新鲜度保证。关于在实时数据上建立 LLM 基础的更广泛图景,请参阅我们的为 LLM 提供新鲜网页数据指南,以及关于将单个网站转变为可引用知识库的构建支持机器人知识库。

检索覆盖率,而不仅仅是相似性
查询时的最后一次升级。纯向量相似性会错过精确术语——产品代码、错误字符串、专有名词——而关键词搜索可以精准命中。混合检索将两者结合,然后应用你的近期过滤器,以便时间敏感的查询更倾向于新鲜块。记录每个答案的检索块,以便你可以审核模型实际使用的证据;这种可追溯性使 RAG 可解释,并且可以在用户发现之前捕捉到过时的块。LLM 驱动的提取技术在需要从检索到的页面中提取结构化字段而不是散文时非常适合。
常见问题解答
如何保持 RAG 管道的数据新鲜?
为每个来源分配一个与其内容变化速度相匹配的生存时间,为每个块标记来源 URL 和时间戳,并运行一个调度程序,只重新抓取已过期的来源。使用内容哈希跳过未实际更改页面的重新嵌入,并在检索时启用近期过滤,以便时间敏感的查询更倾向于新鲜块。
为什么要为 RAG 摄取 markdown 而不是 HTML?
原始 HTML 携带导航、横幅、页脚和脚本,这些会成为噪音块,污染相似性搜索并浪费上下文标记。以 markdown 为先的摄取仅提取主要内容,因此块是干净且紧扣主题的。返回渲染 markdown 的抓取 API 消除了大多数 RAG 堆栈依赖的脆弱的自定义解析。
RAG 应使用什么块大小?
大约 300–500 个标记是常见的最佳点:足够小以确保检索到的块紧扣主题,足够大以保持完整的思路。使用尊重句子和标题边界的递归分割器,添加小的重叠以避免在接缝处丢失上下文,并为每个块附加来源元数据。
我是否需要代理来构建 RAG 数据管道?
如果你在任何规模上抓取开放网络,是的——网站会限制和阻止来自一个 IP 的重复请求,导致你的语料库中出现空白。抓取 API 或轮换代理保持抓取的可靠性,以便来源完全刷新并按计划进行,这正是每个来源 TTL 所依赖的。
RAG 的生死取决于新鲜度。摄取干净的 markdown,分块小且带有元数据,并通过每个来源的 TTL 驱动重新嵌入,以便每个来源按其自身的时间表刷新。构建一次这个循环,你的助手将从今天的网络中回答,而不是上个月的快照。