为 RAG 构建网页知识库:从内容采集到检索与引用回查

用 Crawl4AI 与 SQLite FTS5 建立网页检索基线,按章节分块、保留来源,在事务中替换旧内容,并避免把抓取失败当作删除。

亿牛云技术团队2026年10月28日6 分钟阅读

网页已经转成 Markdown,不代表它可以直接用于可靠的回答。检索片段需要有出处,页面更新后旧片段不能继续混在结果里,某页临时失败时也不能顺手删掉上一版内容。

本文从明确的文档 URL 清单开始,用 Crawl4AI 获取页面,按章节整理并分块,写入 SQLite FTS5,再检索返回片段与来源。这里先建立不依赖模型的词法检索基线;回答生成与向量检索可以之后接入,不作为本次前提。

准备输入地址

先在浏览器找到下列页面并确认内容,再从地址栏复制完整地址。服务或仓库地址从相应账户设置、官方项目的接入说明或克隆菜单取得。不要从示例输出中反推真实地址。

  • DOC_URL_1:Python library 文档的 sqlite3.html 页地址。
  • DOC_URL_2:同目录 asyncio-queue.html 页地址。

在执行后续命令的同一个 Bash/WSL 终端中设置:

export DOC_URL_1='<paste-the-actual-doc-url-1>'
export DOC_URL_2='<paste-the-actual-doc-url-2>'

下文 JSON 或任务参数中的 <变量名> 是待替换标记,提交前填写刚复制的实际值;JSON 不会自动展开环境变量。结果示例中的 example.16yun.cn 只表示来源字段的形状,不是本文部署的演示站。

安装与索引选择

使用 Python 3.12:

python3.12 -m venv .venv
source .venv/bin/activate
python -m pip install crawl4ai==0.9.5 beautifulsoup4
python -m playwright install chromium

Crawl4AI 管理浏览器与加载,Beautiful Soup 读取章节结构,SQLite 保存文档与索引。Python 的 SQLite 需要支持 FTS5 和 trigram tokenizer;缺少时应使用提供该能力的 SQLite/Python 发行环境,不自动降级成语义不同的索引。

本文选择 trigram 是为了建立可解释的子串检索,包括较长中文词组。它不是中文语义分词,也不能自动识别近义表达。短于三个字符的查询在此示例中被拒绝。配置边界见 FTS5 文档。

定义来源、章节与版本

一条片段至少保留来源 URL、文档标题、章节名、章节序号、正文和片段 ID。同名章节可以出现多次,因此不能只用 heading 作为唯一键。

本例用“URL + 内容摘要 + 章节序号 + 块起点”生成片段 ID。内容变化会产生新 ID;查询时同时返回文档观测时间,避免把采集时间当成内容发布日期。

创建 knowledge.py,先放入分块与数据库结构:

import argparse
import asyncio
import hashlib
import json
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from bs4 import BeautifulSoup
from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig, CacheMode
 
DB = Path("knowledge.sqlite")
SCHEMA = """
CREATE TABLE IF NOT EXISTS documents(
  url TEXT PRIMARY KEY, title TEXT NOT NULL,
  observed_at TEXT NOT NULL, content_hash TEXT NOT NULL
);
CREATE VIRTUAL TABLE IF NOT EXISTS chunks USING fts5(
  chunk_id UNINDEXED, source_url UNINDEXED, title, heading,
  section_no UNINDEXED, body, tokenize='trigram'
);
"""
 
def parse_sections(html):
    soup = BeautifulSoup(html, "html.parser")
    root = soup.select_one("main, article, div.body")
    if root is None or root.find("h1") is None:
        raise ValueError("没有识别到文档正文与标题")
    for unwanted in root.select("nav, script, style"):
        unwanted.decompose()
    title = root.find("h1").get_text(" ", strip=True)
    sections, heading, parts, number = [], title, [], 0
    for element in root.find_all(["h1", "h2", "h3", "p", "pre", "table", "li", "dt", "dd"]):
        if element.find_parent(["pre", "table", "li", "dt", "dd"]):
            continue
        if element.name in {"h1", "h2", "h3"}:
            if parts:
                sections.append((number, heading, "\n\n".join(parts)))
            number += 1
            heading = element.get_text(" ", strip=True)
            parts = []
        else:
            text = element.get_text(" ", strip=True)
            if text:
                parts.append(text)
    if parts:
        sections.append((number, heading, "\n\n".join(parts)))
    if not sections:
        raise ValueError("文档没有可索引正文")
    return title, sections
 
def chunk_rows(url, title, sections, digest):
    rows = []
    for number, heading, body in sections:
        for start in range(0, len(body), 1400):
            text = body[start:start + 1600]
            identity = f"{url}|{digest}|{number}|{start}"
            chunk_id = hashlib.sha256(identity.encode()).hexdigest()
            rows.append((chunk_id, url, title, heading, number, text))
    return rows

正文选择器适用于本文选用的文档结构,迁移站点时应先核对。表格在这里转为可读文本,未保留完整单元格关系;代码块也只作为章节材料,不承诺编程语法级分块。

每块最多 1600 字符、步长 1400,保留一小段重叠。这是教学起点,不是 token 配额;长代码和段落可能被切开。建立问题集后,再比较段落优先、代码完整性和不同窗口的召回效果。

成功刷新一页后,再事务替换索引

在同一文件追加采集与检索:

async def ingest(urls):
    db = sqlite3.connect(DB)
    db.executescript(SCHEMA)
    failures = []
    try:
        async with AsyncWebCrawler(config=BrowserConfig(headless=True)) as crawler:
            for url in urls:
                try:
                    result = await crawler.arun(
                        url=url, config=CrawlerRunConfig(cache_mode=CacheMode.BYPASS)
                    )
                    if not result.success or result.status_code != 200:
                        raise ValueError("页面未成功取得")
                    title, sections = parse_sections(result.html)
                    text = "\n".join(body for _, _, body in sections)
                    digest = hashlib.sha256(text.encode()).hexdigest()
                    rows = chunk_rows(url, title, sections, digest)
                    observed = datetime.now(timezone.utc).isoformat()
                    with db:
                        db.execute("DELETE FROM chunks WHERE source_url=?", (url,))
                        db.executemany("INSERT INTO chunks VALUES (?,?,?,?,?,?)", rows)
                        db.execute("""INSERT INTO documents VALUES (?,?,?,?)
                          ON CONFLICT(url) DO UPDATE SET title=excluded.title,
                          observed_at=excluded.observed_at,content_hash=excluded.content_hash""",
                          (url, title, observed, digest))
                except Exception as error:
                    failures.append({"source_url": url, "error_type": type(error).__name__})
        Path("ingest-failures.json").write_text(
            json.dumps(failures, indent=2), encoding="utf-8"
        )
        print({"documents": db.execute("SELECT count(*) FROM documents").fetchone()[0],
               "failed": len(failures)})
    finally:
        db.close()
 
def search(query):
    if len(query) < 3:
        raise ValueError("本例 trigram 查询至少需要三个字符")
    if not DB.exists():
        raise FileNotFoundError("先执行 ingest 创建知识库")
    db = sqlite3.connect(DB.resolve().as_uri() + "?mode=ro", uri=True)
    db.row_factory = sqlite3.Row
    try:
        phrase = '"' + query.replace('"', '""') + '"'
        rows = db.execute("""
          SELECT c.chunk_id,c.source_url,c.title,c.heading,c.section_no,
                 snippet(chunks,5,'[',']',' ... ',24) AS excerpt,d.observed_at
          FROM chunks AS c JOIN documents AS d ON d.url=c.source_url
          WHERE chunks MATCH ? ORDER BY bm25(chunks) LIMIT 5
        """, (phrase,)).fetchall()
        print(json.dumps([dict(row) for row in rows], ensure_ascii=False, indent=2))
    finally:
        db.close()
 
parser = argparse.ArgumentParser()
parser.add_argument("action", choices=["ingest", "search"])
parser.add_argument("values", nargs="+")
args = parser.parse_args()
if args.action == "ingest":
    asyncio.run(ingest(args.values))
else:
    search(" ".join(args.values))

抓取两个明确文档,再用原文中的技术词检索:

python knowledge.py ingest   "$DOC_URL_1" "$DOC_URL_2"
python knowledge.py search SQLite

Crawl4AI 的浏览器配置与运行配置承担不同责任;本例绕过页面缓存以获取刷新材料,基本接口见 官方采集说明。

更新失败为什么不能先删除

代码先取得、解析并构造完整片段,再开启替换事务。删除旧片段、插入新片段与更新文档元数据一起提交;中途写入失败则回滚。

页面下载或解析失败时根本不进入替换,旧索引保留,失败来源写入清单。这样一次临时故障不会让已知内容从检索中消失。它同时意味着旧内容可能继续可查,需要结合 observed_at 提示新鲜度。

本例不自动删除“本轮没看到”的 URL,因为输入清单不证明站点完整目录。真正删除要依据明确状态或经确认的完整发现流程,并限定在当前站点范围内,不能刷新一个来源时清空其他来源。

检索结果如何回查

结果包含片段 ID、来源、标题、章节、序号、摘要与观测时间。先打开 source_url,找到对应章节,判断片段是否真的支持问题。重复的同名章节通过 section_no 区分,但它不自动成为网页锚点;不能自己拼造一个不存在的 #heading 链接。

词法命中只表示查询字符串出现在索引规则中。它不会自动理解“怎样保证写入一起成功”与“事务”的语义关系。先用有明确原文的词确认采集和索引路径,再用问题集评价召回。

后续接入生成回答时,交给模型的是片段与来源标识。引用应映射回这份记录,不能让模型凭空生成 URL。找不到支持片段时应保持未知,而不是让生成掩盖检索缺失。

常见问题与修复

提示 no such module: fts5 或 tokenizer 错误。 检查 Python 实际连接的 SQLite 能力;系统 sqlite3 命令版本不一定等于 Python 链接版本。

查询创建了一个空库。 本例以只读模式打开已有文件,并先检查存在性。不要把普通 connect 的自动建库行为当成“检索成功但没数据”。

更新后仍查到旧内容。 查网页缓存策略、是否真的刷新成功,以及失败清单。旧数据保留可能是正确的失败保护,不只是索引没替换。

中文短词没有结果。 本例明确限制三个字符。短词、分词与语义召回需选择其他索引策略,并用相同问题集比较,不能静默改变查询含义。

命中大量菜单。 修正文档正文选择器和清理范围,再重建受影响页面。增加检索条数只会返回更多噪声。

同页同名章节丢失。 检查片段身份是否包含章节位置。本例不会只凭标题覆盖两个章节。

先让来源、内容版本、片段和检索结果一一对应,再扩展更复杂的检索。这样没有答案时,你能知道是采集、分块还是召回需要改进。

需要企业代理方案?

我们可根据目标站点、并发规模与稳定性目标提供定制方案。