1. 从“网页收藏夹吃灰”说起:为什么需要把网页变成可引用知识
我猜你也有过这种经历:浏览器收藏夹里躺了几百个链接,标签页开了一排又一排,当时觉得“这个以后肯定用得上”,结果再也没有打开过。更麻烦的是,当你真正需要某个信息的时候,明明记得在哪篇文章里看到过,却怎么也搜不出来。传统的书签管理只能存个标题和链接,网页内容本身是“死”的,它不能被检索、不能被引用、更不能被对话式地调用。
这就是“把网页变成可引用知识”这件事的核心痛点。简单来说,这个项目要解决的问题是:让 Chatbot 能够读取你指定的网页内容,并且在回答问题时精准引用这些内容作为依据。它不是一个简单的“总结网页”工具,而是一套完整的知识管道——从网页抓取、内容清洗、结构化存储,到最终在对话中按需检索和引用。
适合谁来参考?如果你正在做 RAG(检索增强生成)相关的应用,或者你是一个经常需要查资料、写报告、做研究的人,再或者你是一个开发者想给自己的 Chatbot 加上“联网查资料”的能力,这套思路都直接可用。哪怕你只是想让自己的 AI 助手能“记住”某几个特定网站的内容,下面的方案也能帮你落地。
我接下来会从整体设计思路、核心细节、实操过程、常见问题四个维度,把这件事拆开揉碎讲清楚。所有代码和配置都是可以直接抄作业的级别,参数选择我也会解释清楚背后的计算逻辑。
2. 整体架构设计:从网页到可引用知识的完整链路
2.1 为什么不能直接把网页丢给大模型
很多人第一反应是:我把网页内容复制粘贴到对话窗口里不就行了?短网页可以,但一旦内容超过几千字,问题就来了。大模型的上下文窗口是有限的,而且按 token 计费,你不可能每次提问都把几十个网页全文塞进去。更关键的是,全文塞进去之后,模型很难精准定位到“哪句话来自哪个网页”,引用就无从谈起。
所以我们需要一套中间层:把网页内容切碎、向量化、存起来,提问的时候只取最相关的片段。这就是 RAG 的基本思路。但“把网页变成可引用知识”比通用 RAG 多了一层要求——引用要可追溯。也就是说,模型给出的每个关键信息,都要能对应回原始网页的具体段落。
2.2 四层架构拆解
我把整个链路分成四层,每一层都有明确的职责:
| 层级 | 职责 | 关键产出 |
|---|---|---|
| 抓取层 | 获取网页原始内容 | HTML 原文 |
| 清洗层 | 提取正文、去除噪声 | 纯文本 + 元数据 |
| 索引层 | 切块、向量化、存储 | 向量库 + 原文映射 |
| 引用层 | 检索、重排、生成带引用的回答 | 带来源标注的答案 |
抓取层我推荐用httpx加BeautifulSoup的组合,轻量且可控。如果你需要处理大量动态渲染的页面,可以考虑加一个无头浏览器,但大多数技术博客、文档站、新闻页用静态抓取就够了。清洗层是很多人忽略的重灾区——网页里充斥着导航栏、广告、评论区、页脚,这些噪声如果不剔除,会严重污染向量检索的结果。
索引层的核心决策是切块策略。我试过按固定字数切、按段落切、按语义切,最后发现对于网页内容,按标题层级 + 段落长度混合切块效果最稳。具体来说,优先按<h2>、<h3>标签把内容分成大块,如果某个块超过 800 字,再按段落进一步切分。这样每个块既有独立的语义完整性,又不会太长导致检索精度下降。
引用层的关键是在生成回答时强制模型输出引用标记。我的做法是在 prompt 里明确要求:每个事实性陈述后面必须跟[来源: 块ID],然后在后处理阶段把块ID映射回原始URL和段落位置。
2.3 技术选型背后的取舍
向量模型我选的是text-embedding-3-small这个级别的,原因很简单:网页内容的语义检索不需要顶级精度,但需要速度和成本的平衡。实测下来,对于技术文档类内容,这个级别的模型召回率已经足够。如果你处理的是法律、医疗等对精度要求极高的领域,可以上更大的模型,但成本会翻好几倍。
向量库我用的是ChromaDB,因为它支持本地持久化、API 简单、和 Python 生态无缝集成。如果你需要分布式部署,可以换成Qdrant或Milvus,但单机场景下 Chroma 完全够用。这里有个经验:不要一上来就追求“生产级”方案,先用最简单的工具把链路跑通,验证效果之后再考虑扩展。
3. 核心细节解析:抓取、清洗、切块、引用四步走
3.1 网页抓取:怎么拿到“干净”的原始内容
抓取这一步看似简单,但坑不少。首先是请求头,很多网站会检查User-Agent,默认的 Python 请求头会被直接拒绝。我的做法是模拟一个常见的浏览器 UA,同时加上Accept-Language和Accept-Encoding。其次是超时设置,我一般设 15 秒连接超时、30 秒读取超时,太短容易失败,太长会拖慢整体流程。
import httpx from bs4 import BeautifulSoup HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", } def fetch_page(url: str) -> str: with httpx.Client(headers=HEADERS, timeout=30.0, follow_redirects=True) as client: resp = client.get(url) resp.raise_for_status() return resp.text这里有个细节:follow_redirects=True一定要开,很多短链接会跳转,不开的话你拿到的可能是 301 页面而不是真实内容。另外,如果目标网站有反爬机制,可以加一个简单的延迟,比如每次请求间隔 1-2 秒,避免触发频率限制。
3.2 内容清洗:把“网页”变成“文章”
拿到 HTML 之后,下一步是提取正文。我试过newspaper3k、readability-lxml和手写规则,最后发现对于结构相对规范的页面,基于标签密度的启发式方法最可控。核心思路是:正文通常集中在<article>、<main>或<div class="content">这类容器里,而导航、侧边栏、页脚通常有固定的 class 或 id 特征。
def extract_content(html: str) -> dict: soup = BeautifulSoup(html, "lxml") # 移除明显的噪声标签 for tag in soup(["script", "style", "nav", "footer", "header", "aside"]): tag.decompose() # 优先找 article 或 main container = soup.find("article") or soup.find("main") or soup.body if not container: return {"title": "", "text": ""} title = soup.title.string if soup.title else "" text = container.get_text(separator="\n", strip=True) # 压缩连续空行 lines = [line for line in text.split("\n") if line.strip()] return {"title": title, "text": "\n".join(lines)}注意:
get_text的separator参数很关键。默认是空字符串,会把所有文字连在一起,导致段落边界丢失。用\n作为分隔符可以保留基本的段落结构。
清洗完之后,我建议再做一个“长度过滤”:如果提取出的正文少于 200 字,大概率是提取失败或者页面本身内容太少,这种就直接丢弃,不要进入后续流程。
3.3 切块策略:怎么切才能让检索又准又全
切块是 RAG 系统里最容易被低估的环节。切得太碎,语义不完整,检索出来的片段可能缺上下文;切得太大,噪声多,检索精度下降。我的经验值是:每个块 300-800 字,块之间有 50-100 字的重叠。
重叠的作用是防止关键信息刚好被切在边界上。比如一句话前半段在块 A 末尾,后半段在块 B 开头,如果没有重叠,两个块都拿不到完整信息。50-100 字的重叠可以覆盖大多数句子长度。
def chunk_text(text: str, max_len: int = 800, overlap: int = 80) -> list: paragraphs = text.split("\n") chunks = [] current = "" for para in paragraphs: if len(current) + len(para) <= max_len: current += para + "\n" else: if current: chunks.append(current.strip()) # 保留重叠部分 current = current[-overlap:] + para + "\n" if current else para + "\n" if current.strip(): chunks.append(current.strip()) return chunks这个切法有个好处:它尽量保持段落完整性,不会把一句话从中间切断。实测下来,对于技术文档和博客文章,这种切法的检索命中率比固定字数切法高不少。
3.4 引用生成:让模型“说实话”的关键
引用层的核心是在 prompt 里建立强约束。我的 prompt 模板大致是这样的:
你是一个基于给定资料回答问题的助手。请严格根据以下资料回答问题。 每个事实性陈述后面必须标注来源,格式为 [来源: 块ID]。 如果资料中没有相关信息,请直接说“根据现有资料无法回答”,不要编造。 资料: {context} 问题:{question}后处理阶段,我用正则把[来源: 块ID]提取出来,然后映射回原始 URL 和段落位置。这样最终输出的答案里,每个关键信息都能点回到原始网页。
提示:块ID 的命名建议用
{url_hash}_{chunk_index}的格式,这样既能保证唯一性,又能从 ID 直接看出是哪个网页的第几个块,排查问题的时候非常方便。
4. 实操过程:从零搭建一个可用的联网知识 Chatbot
4.1 环境准备与依赖安装
先把基础环境搭起来。我用的 Python 3.10,依赖不多,核心就是几个库:
pip install httpx beautifulsoup4 lxml chromadb openai tiktoken如果你用的是其他向量模型,把openai换成对应的 SDK 就行。tiktoken是用来计算 token 数量的,虽然可选,但我建议装上,方便你控制成本。
4.2 完整流程代码实现
下面是一个最小可运行的版本,把抓取、清洗、切块、向量化、检索、生成串起来:
import hashlib import chromadb from openai import OpenAI client = OpenAI() chroma = chromadb.PersistentClient(path="./knowledge_db") collection = chroma.get_or_create_collection("web_knowledge") def url_to_id(url: str) -> str: return hashlib.md5(url.encode()).hexdigest()[:12] def ingest_url(url: str): html = fetch_page(url) content = extract_content(html) chunks = chunk_text(content["text"]) uid = url_to_id(url) ids = [f"{uid}_{i}" for i in range(len(chunks))] metadatas = [{"url": url, "title": content["title"], "chunk_index": i} for i in range(len(chunks))] # 向量化 embeddings = [] for chunk in chunks: resp = client.embeddings.create( model="text-embedding-3-small", input=chunk ) embeddings.append(resp.data[0].embedding) collection.add( ids=ids, documents=chunks, embeddings=embeddings, metadatas=metadatas ) return len(chunks) def query_knowledge(question: str, top_k: int = 5) -> str: q_emb = client.embeddings.create( model="text-embedding-3-small", input=question ).data[0].embedding results = collection.query( query_embeddings=[q_emb], n_results=top_k ) context_parts = [] for i, (doc, meta) in enumerate(zip(results["documents"][0], results["metadatas"][0])): context_parts.append(f"[块ID: {results['ids'][0][i]}]\n{doc}\n") context = "\n---\n".join(context_parts) prompt = f"""你是一个基于给定资料回答问题的助手。请严格根据以下资料回答问题。 每个事实性陈述后面必须标注来源,格式为 [来源: 块ID]。 如果资料中没有相关信息,请直接说"根据现有资料无法回答",不要编造。 资料: {context} 问题:{question} """ resp = client.chat.completions.create( model="gpt-4o-mini", messages=[{"role": "user", "content": prompt}], temperature=0.1 ) return resp.choices[0].message.content这段代码可以直接跑。temperature=0.1是为了让输出更稳定、更贴近资料原文,减少模型自由发挥的空间。
4.3 参数选择与计算过程
top_k设多少合适?我一般设 5。原因是:网页内容通常比较聚焦,5 个块基本能覆盖一个问题的相关信息。设太少可能漏掉关键信息,设太多会引入噪声,而且增加 token 消耗。你可以根据实际效果调整,但建议从 5 开始试。
切块长度 800 字是怎么来的?我算过一笔账:text-embedding-3-small的向量维度是 1536,对于 800 字左右的中文文本,语义信息已经能被充分编码。再长的话,向量会“稀释”,检索精度反而下降。800 字大约对应 500-600 个 token,也在模型的最佳处理范围内。
重叠 80 字的选择依据是:中文平均句长约 30-40 字,80 字可以覆盖两个完整句子,确保边界信息不丢失。
4.4 批量导入与增量更新
实际使用中,你肯定不是一次只导入一个网页。我写了一个简单的批量导入脚本:
def batch_ingest(urls: list): for url in urls: try: n = ingest_url(url) print(f"OK: {url} -> {n} chunks") except Exception as e: print(f"FAIL: {url} -> {e}")增量更新也很重要。如果某个网页内容变了,你需要重新抓取并覆盖旧的块。我的做法是:先用url_to_id找到该 URL 对应的所有块 ID,从 collection 里删掉,再重新导入。Chroma 支持按 metadata 过滤删除,很方便。
5. 常见问题与排查技巧实录
5.1 抓取失败怎么办
最常见的失败原因是 403 和超时。403 通常是 UA 被识别了,换个更常见的浏览器 UA 试试。超时的话,先检查网络连通性,然后适当延长超时时间。如果目标网站有 Cloudflare 之类的防护,静态抓取基本没戏,这时候要么放弃,要么上无头浏览器。
还有一个隐蔽的坑:有些网站返回 200 但内容是空的,或者是一个“请开启 JavaScript”的提示页。这种情况我建议在清洗后加一个长度检查,少于 200 字直接标记为失败。
5.2 检索结果不相关怎么调
检索不准通常有三个原因:切块太碎、向量模型不合适、top_k 太大。我的排查顺序是:先看切块结果,如果块太短(少于 200 字),说明切太碎了,调大max_len;如果块长度正常但检索还是不准,考虑换一个更强的向量模型;如果前两个都没问题,把top_k从 5 降到 3 试试。
还有一个容易被忽略的点:问题本身的表述方式。如果用户问的是“这个项目的依赖有哪些”,而网页里写的是“环境要求”,语义上有差距,检索可能不准。这时候可以在检索前用模型把问题改写一下,或者直接用原始问题加几个同义词一起检索。
5.3 引用标注不准确怎么修
模型有时候会“忘记”标注引用,或者标错块 ID。我的经验是:在 prompt 里把要求写得越具体越好,最好给一个示例。另外,temperature调低到 0.1 以下能显著减少这类问题。如果还是不行,可以在后处理阶段做一次校验:检查每个引用 ID 是否真实存在于检索结果中,不存在的就删掉。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 抓取返回 403 | UA 被识别 | 更换浏览器 UA |
| 抓取超时 | 网络慢或网站响应慢 | 延长超时时间至 60 秒 |
| 正文提取为空 | 页面依赖 JS 渲染 | 改用无头浏览器或放弃 |
| 检索结果不相关 | 切块太碎或 top_k 太大 | 调大 max_len,降低 top_k |
| 引用标注缺失 | prompt 约束不够强 | 加示例,降低 temperature |
| 向量化速度慢 | 逐条调用 API | 批量调用,一次传多个文本 |
提示:批量向量化的时候,
input参数可以传一个列表,一次最多传 100 条左右,能大幅提升速度。
5.5 几个我踩过的坑
第一个坑是编码问题。有些中文网页的编码不是 UTF-8,httpx默认按 UTF-8 解码会乱码。解决办法是用resp.content拿到字节流,然后用chardet检测编码再解码。虽然多一步,但能避免很多莫名其妙的乱码问题。
第二个坑是重复内容。同一个网页可能被多次导入,导致检索结果里出现大量重复块。我的做法是在导入前先检查该 URL 是否已经存在,存在就先删再导。另外,不同 URL 可能指向同一篇文章(比如带参数的分享链接),这种情况可以在清洗后做一次文本相似度去重。
第三个坑是块 ID 冲突。如果两个不同 URL 的 MD5 前 12 位刚好相同(概率极低但理论上存在),会导致 ID 冲突。解决办法是用更长的哈希,或者直接用 URL 本身作为 ID 的一部分。我后来改成了{url_hash}_{chunk_index}的格式,冲突概率基本为零。
6. 进阶优化:让这套系统更好用
6.1 混合检索提升召回率
纯向量检索有个天然缺陷:对于专有名词、代码片段、数字这类内容,语义向量的表达能力有限。我的优化方案是向量检索 + 关键词检索混合。具体做法是:先用向量检索拿到 top 20,再用 BM25 或简单的关键词匹配拿到 top 20,然后合并去重,最后用重排模型取 top 5。
关键词检索可以用rank_bm25这个库,轻量且效果好。合并的时候,我给两个来源的结果各打一个分数,向量检索的分数用余弦相似度,关键词检索的分数用 BM25 分数归一化后的值,然后加权求和。权重我一般设 0.7 向量 + 0.3 关键词,你可以根据实际效果调整。
6.2 引用溯源的可视化
如果你想让引用更直观,可以在输出答案的时候,把每个引用块对应的原始 URL 和段落位置也展示出来。我的做法是在 metadata 里存一个char_start和char_end,记录该块在原文中的字符位置。这样用户点击引用的时候,可以直接跳转到原文的对应位置。
# 在切块时记录位置信息 def chunk_with_position(text: str, max_len: int = 800): chunks = [] start = 0 while start < len(text): end = min(start + max_len, len(text)) chunks.append({ "text": text[start:end], "char_start": start, "char_end": end }) start = end - 80 # 重叠 return chunks这个位置信息在排查“为什么检索到这个块”的时候特别有用,你能直接看到原文的上下文。
6.3 定期更新与失效检测
网页内容会变,链接会失效。我建议加一个定期检查机制:每隔一段时间(比如一周),重新抓取所有已导入的 URL,对比内容哈希。如果哈希变了,就重新导入;如果抓取失败(404 或超时),就标记为失效,从检索库里移除。
这个机制用简单的定时任务就能实现,不需要复杂的调度系统。关键是内容哈希要基于清洗后的正文计算,而不是原始 HTML,因为 HTML 里可能包含时间戳、广告等无关变化。
7. 一些个人体会
这套方案我从去年开始在自己的知识管理流程里用,目前维护了大概两百多个网页的知识库,覆盖技术文档、行业报告、博客文章。最直观的感受是:找信息的时间从“翻收藏夹十分钟”变成了“问一句十秒钟”。而且因为每个回答都有引用来源,我不用担心模型在胡说八道,点回去看原文就行。
如果你刚开始做,我的建议是先用 10 个网页跑通全流程,确认检索和引用效果符合预期,再逐步扩大规模。不要一上来就想着导入几千个页面,那样出了问题很难定位。另外,向量模型和向量库的选择不用太纠结,先用最便宜的方案跑起来,效果不够再升级,这样试错成本最低。
还有一个小心得:把常用网站的抓取规则单独配置。比如某些技术博客的正文在<div class="post-content">里,某些文档站在<article>里,针对性地写几个提取规则,比通用启发式方法准确得多。这个投入是一次性的,但收益是长期的。