技术博客
把链接读取接进 RAG 管道:去重、缓存与分块

把链接读取接进 RAG 管道:去重、缓存与分块

作者: 万维易源
2026-09-15
链接读取RAG知识库缓存设计限流
# 把链接读取接进 RAG 管道:去重、缓存与分块 接口:链接读取(apiCode=3262)· 接入点:获取网页正文(3262-1)· 计费:50 厘/次 · 并发上限:2 次/秒 · 适用人群:要把公开网页正文批量灌进知识库的工程师 · 阅读时间:约 12 分钟 · 最后实测核对:2026-09-15 ## 核心要点 - 读取前先按归一化 URL 去重,每次调用 0.05 元,去重直接减少实际调用次数。 - 读取失败的 URL 单独记入过滤表,与成功结果**分表存储**,避免对同一个 URL 重复调用。 - 并发要压在 2 次/秒以内,令牌桶比 `sleep` 好写,也不会拖慢整体。 ## 管道的四个环节 一条能上生产的读取管道,比"写个循环调接口"多出四件事: ``` URL 列表 → ① 归一化去重 → ② 缓存查命中 → ③ 限流读取(2 次/秒) ↓ ④ 清洗分块 → 向量库 ↓ 失败清单(不再重读) ``` 下面按顺序拆。所有代码基于 Python,缓存用 SQLite(够用,换成 Redis 也行,键的设计一样)。 ## 第一步:URL 归一化 同一个页面会有好几种写法,不去重会产生重复调用。 ```python from urllib.parse import urlsplit, urlunsplit, parse_qsl, urlencode # 会被去掉的跟踪参数(按你实际数据源补充) TRACKING = {"utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "spm", "from", "share_token", "ref"} def normalize(url: str) -> str: s = urlsplit(url.strip()) scheme = (s.scheme or "https").lower() netloc = s.netloc.lower() if netloc.endswith(":80") and scheme == "http": netloc = netloc[:-3] if netloc.endswith(":443") and scheme == "https": netloc = netloc[:-4] path = s.path or "/" if path != "/" and path.endswith("/"): path = path[:-1] # 去掉尾部斜杠 qs = [(k, v) for k, v in parse_qsl(s.query, keep_blank_values=True) if k not in TRACKING] return urlunsplit((scheme, netloc, path, urlencode(sorted(qs)), "")) # 丢掉 fragment ``` `#` 后面的 fragment 一定去掉——它对服务端不可见,留着只会让同一页面变成两条记录。 ## 第二步:缓存表设计 两张表,别合并。这是把"读取不到"和"还没读过"分开的关键。 ```python import sqlite3, hashlib, time def init_db(path="pages.db"): conn = sqlite3.connect(path) conn.executescript(""" -- 读取成功的正文 CREATE TABLE IF NOT EXISTS pages ( url_key TEXT PRIMARY KEY, -- sha256(归一化 URL) url TEXT NOT NULL, content TEXT NOT NULL, fetched_at INTEGER NOT NULL ); -- 读取不到的 URL。单独存,避免重复付费 CREATE TABLE IF NOT EXISTS blocked ( url_key TEXT PRIMARY KEY, url TEXT NOT NULL, reason TEXT NOT NULL, -- empty_output / http_500 / not_html ... attempts INTEGER DEFAULT 1, last_try INTEGER NOT NULL ); """) return conn def key_of(url: str) -> str: return hashlib.sha256(url.encode("utf-8")).hexdigest() ``` `blocked` 表要记 `reason` 和 `attempts`。原因决定了要不要重试,次数决定了要不要永久放弃。实测里 `empty_output` 这类原因是站点层面的,重试没意义;`timeout` 才是值得退避重试的。 命中缓存的判断,按内容类型给不同 TTL:新闻页可以放久一点,首页和榜单页最好短一些。 ## 第三步:令牌桶限流 并发上限是 **2 次/秒**。最简单可靠的写法是令牌桶,读取线程自己算,不用全局锁。 ```python import threading, time class TokenBucket: """按固定速率放行,平滑突发的关键。rate=2 表示每秒 2 次。""" def __init__(self, rate: float, capacity: float = None): self.rate = rate self.capacity = capacity if capacity is not None else rate self.tokens = self.capacity self.updated = time.monotonic() self.lock = threading.Lock() def acquire(self): while True: with self.lock: now = time.monotonic() self.tokens = min(self.capacity, self.tokens + (now - self.updated) * self.rate) self.updated = now if self.tokens >= 1: self.tokens -= 1 return wait = (1 - self.tokens) / self.rate time.sleep(wait) ``` 留点余量,把 `rate` 设成 1.8 而不是 2.0。接口的读超时是 10 秒,服务端统计口径和你本地时钟不一定对齐,贴着上限跑没必要。 单线程串行时,每 0.5 秒一次调用就够安全,但在队列消费场景下通常会有多个 worker,令牌桶是更省心的选择。 ## 第四步:读取 + 分类落库 失败分类必须走到落库这一步,否则重试策略没有依据。 ```python import requests from requests.exceptions import Timeout, ConnectionError, HTTPError API = "https://route.showapi.com/3262-1" APPKEY = "YOUR_APPKEY" def fetch(url: str, bucket: TokenBucket, retries: int = 3): """返回 (ok, payload_or_reason)。可重试的错误在这里退避,不可重试的直接返回。""" # 不可重试的原因:站点层面问题,重试只是重复扣费 RETRYABLE = {"timeout", "conn_error"} for attempt in range(retries): bucket.acquire() try: r = requests.post(API, params={"appKey": APPKEY, "url": url}, timeout=(5, 10)) except Timeout: reason = "timeout" except ConnectionError: reason = "conn_error" else: if r.status_code != 200: # 如实测的 HTTP 500 + backend fail,不计费,可重试 reason = f"http_{r.status_code}" else: data = r.json() if data.get("showapi_res_code") != 0: # 参数错误,不可重试:这是代码 bug return False, f"gateway:{data.get('showapi_res_error','')}" output = (data.get("showapi_res_body") or {}).get("output") or "" if not output: # 读取失败但已计费,不可重试 return False, "empty_output" return True, output if reason not in RETRYABLE: return False, reason time.sleep(min(2 ** attempt, 8)) # 指数退避,封顶 8 秒 return False, reason ``` 注意 `empty_output` 是直接 `return` 的,不进退避循环。实测里返回空的站点重复请求结果一致,退避重试只会多花几次钱。 消费端把结果分开写: ```python def consume(urls, conn): bucket = TokenBucket(rate=1.8) # 留余量 ok_cnt = blocked_cnt = 0 for raw in urls: url = normalize(raw) k = key_of(url) hit = conn.execute("SELECT content FROM pages WHERE url_key=?", (k,)).fetchone() if hit: yield {"url": url, "md": hit[0], "from_cache": True} continue if conn.execute("SELECT 1 FROM blocked WHERE url_key=?", (k,)).fetchone(): continue # 已知读取不到,跳过,不花钱 ok, payload = fetch(url, bucket) if ok: conn.execute("INSERT OR REPLACE INTO pages VALUES (?,?,?,?)", (k, url, payload, int(time.time()))) ok_cnt += 1 yield {"url": url, "md": payload, "from_cache": False} else: conn.execute("""INSERT INTO blocked VALUES (?,?,?,1,?) ON CONFLICT(url_key) DO UPDATE SET attempts = attempts + 1, last_try = excluded.last_try""", (k, url, payload, int(time.time()))) blocked_cnt += 1 conn.commit() ``` ## 成本怎么估 单价固定,50 厘/次,也就是 0.05 元/次。 ``` 总成本 ≈ 实际调用次数 × 0.05 元 实际调用次数 = 总 URL 数 − 缓存命中数 − 黑名单命中数 ``` 举个规模感受一下:一万个 URL,如果去重后剩 9,000 条、其中 2,000 条命中缓存、800 条在黑名单里,实际调用 6,200 次,成本 310 元。 折扣档位说明:9.90 元的档位只用本接入点可以调 **198 次**(198 × 0.05 = 9.90)。调用量大的场景建议直接选择更大的档位,按 9.9 元档位叠加多份并不划算。 时间成本:并发上限 2 次/秒,6,200 次调用的理论最短耗时是 6,200 ÷ 2 = 3,100 秒,约 52 分钟。这是下限,实际还要加上单页响应耗时和重试。本次未实测单页平均耗时,按上面的公式自己乘一下更准。 ## 分块:这一步决定检索效果 正文清洗和分块的写法在第 3 篇里给了完整代码([链接读取的 output 到底是什么格式](https://www.showapi.com/guides/link-read-markdown-output-3262)),这里只说管道层面的两个决定。 **按标题切,不按固定字数切。** `output` 里的 `#` / `##` 层级来自原页面,是免费拿到的语义边界。按字数硬切会把一段论述劈成两半。 **给每一块带上来源 URL 和标题。** 检索命中后要能回到原文,也要能在回答里标出处。`chunks` 里存 `{url, title, text}`,别只存文本。 ## 监控该看什么 - **空值率**:`blocked` 表里 `empty_output` 占当天调用数的比例。这个数突然涨,通常是某个数据源改了前端。 - **重试率**:`timeout` 和 `http_5xx` 的次数。持续偏高就说明你的并发压太紧了。 - **缓存命中率**:低于预期说明 URL 归一化没做干净。 - **`showapi_res_id`**:读取异常时把它记下来,找客服时比自己描述现象有效得多。 ## FAQ **Q1:一天读取十万个页面现实吗?** 并发上限 2 次/秒是硬约束,十万次调用的理论最短耗时约 13.9 小时,成本约 5,000 元。这是个天花板,实际还要打折——所以去重和缓存不是优化项,是必需项。 **Q2:读取失败的 URL 要一直留在黑名单里吗?** 不用。`blocked` 表里有 `last_try`,可以定期做一次"复活"扫描,把超过 30 天的记录重新试一遍。站点改版、撤掉访问限制之后是能恢复的。复活窗口设得过短,会带来无谓的重复调用。 **Q3:能用多线程吗?** 可以,但所有 worker 必须共用同一个令牌桶。每个线程各建一个 `TokenBucket(rate=2)`,总速率就是线程数乘以 2,会立刻触发限流。 **Q4:空值要不要缓存?** 要存,但要存进 `blocked` 表而不是 `pages` 表。混在一起的话,你的读取逻辑拿到空字符串会当成"这个页面没内容",而不是"这个页面读取不到"。 **Q5:超时该设多少?** 接口在 OpenAPI 文档里标了连接 5 秒、读取 10 秒,客户端按这个对齐就行:`requests` 里写 `timeout=(5, 10)`。客户端设得更短的话,慢页面会在本地先超时,服务端实际能返回内容的情况也会被记为失败。 ## 下一步阅读 - 清洗与分块的代码 → [链接读取的 output 到底是什么格式](https://www.showapi.com/guides/link-read-markdown-output-3262) - 哪些页面类型该提前过滤掉 → [链接读取返回结构说明与结果判断](https://www.showapi.com/guides/link-read-empty-output-3262) - 自建读取和这个接口怎么选 → [读取公开网页正文的方案怎么选](https://www.showapi.com/guides/link-read-vs-selfbuilt-3262) - **本系列共 6 篇**:查看[链接读取指南总目录](https://www.showapi.com/guides/link-read-guides-3262)