我一直有个习惯:遇到拿不准的问题,会先让我自己的聊天机器人去查一圈资料再回答。折腾了大半年,最大的感悟是——光有“联网”远远不够,更关键的是“可引用”。你让对话机器人上网找到一段内容,复述出来,这跟搜索引擎摘要没什么本质区别;但如果你把网页拆成知识块,存进自己的知识库,让模型回答时带着编号出处、逐条对应原文,这才是把网页真正变成“可引用知识”。下面聊聊我在这条链路里的完整做法,从抓取、清洗、切片、向量化,到检索生成时如何绑定引用。适合正在做知识库问答、企业客服机器人、个人研究助手的朋友,也适合被模型幻觉折磨、想给每个答案都加上可靠出处的开发者。
1. 先想明白:为什么是“可引用”,而不是“能联网”
1.1 聊天机器人真正的短板不在“消息闭塞”,而在“无法负责”
聊天机器人的知识来自训练时接触过的语料,训练截止日之后的事它不知道,你自己内部文档和网页它更没见过。很多人第一反应是“给它接上网络不就得了”,于是做了一堆搜索插件、联网开关,结果发现效果依然不理想:它能搜到东西,但回答时张冠李戴,把A站观点安到B站头上,甚至凭空给出一个看起来很像样的链接。
问题出在哪?出在“没有建立责任机制”。搜索返回的是十来个网页标题和摘要,模型照着这些碎片发挥,自然容易跑偏。我们需要的是让模型“对每个说法负责”——它说的每一句话,都能追溯到具体网页的具体段落。这是“可引用知识”和“联网搜索”最本质的区别。
从我的实践看,这一点决定了整个系统形态,而不是简单加一个“联网开关”。你要让系统具备三件事:找到相关页面(检索)、把页面消化成可引用的知识单元(抽取与切片)、回答时强制标注引用(绑定与生成)。三件事少一件,最后得到的都只是“会搜索的聊天机器人”,而不是“能把网页变成可引用知识的助手”。
1.2 联网工具的本质:把检索、抽取、引用三段串起来
如果把一条问答请求画成数据流,大致是这样:用户提问 → 从中提取检索关键词 → 从知识库里召回候选块 → 把候选块连同引用编号拼进提示词 → 对话模型生成带[1][2]出处的回答。
我最初以为难点在“检索”,后来发现三段各有各的坑:检索结果不相关,多半是切片粒度不对;生成引用错位,多半是元数据映射没做牢;前端看不到出处,用户就无法验证。所以整个项目的重心,其实是把这三段之间的接口设计好。接口设计得越干净,后面调优时就越省力。
比如“引用编号”这件事,不能等生成回答时再临时想,而是要在知识库的块结构里就定下来。每个块自带一个稳定的ID,检索时把这个ID暴露给模型和前端,生成回答时模型引用的是“块ID”,展示层看到的是“块ID对应的URL和标题”。这个链路想清楚了,代码只是体力活。
1.3 哪些场景最需要这种能力
我自己主要用在三个场景:
- 企业内部知识库问答:把部门Wiki、产品文档、历史公告变成问答底料,答案后附上内部链接,同事可以直接点进去核对,信任度提升一大截。
- 行业资讯研判助手:每天定时抓取若干资讯源,切片入库,然后问“最近这个领域有哪些变化”,回答能带出来源和时间,写报告时省下大量检索时间。
- 个人研究助手:把正在参考的几十篇长文一次性抓进来,然后逐章提问,引用标记能帮你快速回翻原文。
如果你的需求只是“闲聊”或“查天气”,那确实不需要这么重;但凡是“答案要对内容负责”的场景,可引用就是刚需。哪怕现在手头没有复杂项目,我也建议先按这套思路搭一个小原型,因为引用能力一旦有了,你会发现所有知识类任务的交付质量都上了一个台阶。
2. 从URL到干净正文:网页抓取与提取的三道关
2.1 抓取之前,先把目标网站结构看透
我在动手前都会先做一件事:用浏览器打开目标页面,按F12看DOM结构,判断它是静态渲染还是动态渲染。静态页面,HTML里就包含正文,普通的HTTP请求就能拿回来;动态页面,正文由JavaScript异步加载,直接请求拿到的只是一个空壳框架。
怎么判断?最简单的方式是查看网页源代码,搜索正文里的几个关键词,能找到就说明正文在HTML里;找不到,就说明需要浏览器渲染。还有一种更省事的检测方式:抓回来之后统计HTML长度,如果只有几KB且没有任何段落标签,大概率是动态页面。
针对动态页面,我的做法是准备好一个浏览器自动化组件作为“重武器”,页面加载后等待正文区域出现再抓取。代价是速度和资源占用明显上升,所以策略上要“能静态就不渲染”:先普通请求,发现正文缺失再自动升级为渲染模式。这套自适应逻辑比一刀切省非常多资源。
另外一个容易忽略的点是抓取频率。我会先读一下目标站的爬虫协议文件,尊重对方列的抓取规则,同时控制并发和间隔。这不是道德绑架问题,而是现实问题——频率过高大概率被限流,最终两边受损。
2.2 正文噪声清洗:密度算法与规则保护的组合
拿到HTML之后,最脏的活来了。一个普通新闻页,光导航、广告、相关推荐、评论区就可能占掉一半以上标签。如果不过滤,后面切出来的知识块会充满噪声,向量化质量也会被拉低。
我一开始用“黑名单规则”,把常见的广告、导航、页脚标签直接删掉。效果不错,但对“标签写得比较随意”的网站就不灵了。后来改成“文本密度”思路:统计页面里每个区块的文本长度和链接密度,正文通常文本长、链接少,而导航和广告正好相反。
实际落地时我用了一个混合策略:先用HTML结构定位候选容器(标题、段落、列表),再按文本密度打分,最后用一组白名单规则保护可能会被误杀的表格、代码块和引用块。网上有很多成熟的正文提取算法可以参考,原理大同小异:把DOM树转成带权重的文本节点图,然后找到权重最高的那棵子树作为正文。
这里要给个提醒:清洗结果的验收不能靠感觉。我每调一次规则,都会拿一组20个页面的样例集跑一遍,对照原始网页看正文是否完整、有没有多余尾巴,形成“回归测试”的习惯。否则今天的改动明天就可能误伤一片。
2.3 编码、元数据与抓取礼节:容易被忽略的“地基”
新手最容易栽的坑是编码。网页头部声明的字符集可能是假的,HTML实体又经常出现;一个中等规模的中文网站,GB2312、GBK、UTF-8轮着来,稍不注意,存进知识库的就是一堆乱码。
我的处理流程是“组合判定”:先读响应头里的charset,再解析HTML里的meta声明,两者都缺失时用字节序列猜测编码。拿到最终编码后统一转成UTF-8,再交给正文提取。这个过程必须固定下来,因为乱码一旦入库,后面怎么检索都是错的。
同时我会把三样元数据原样保留:页面标题、页面URL、页面发布时间。发布时间尤其重要——资讯类知识一定有新旧,检索排序时可以用时间做衰减,也可以让回答标注信息来源的日期。千万不要只存正文文本而丢弃这些“边料”,后面全部会用到。文本之间的边界条件处理、异常捕获,都要在这一层完成,因为抓取模块是所有下游环节的“水质源头”。
3. 切片不是截断:让每个知识块自带语境
3.1 三种切片方式对比
网页正文经常几千字甚至上万字,不可能整篇塞给模型,所以要切块。常见的做法有三类:
固定长度切片最简单,每N字符一段,代码10分钟搞定。缺点是会在句子中间生硬切断,一个知识点被拆到两个块里,向量化后互相之间缺少关联。
标题感知切片按HTML里的h1、h2、段落结构来切,每个标题下的段落组成一个知识块。这个最符合人的阅读习惯,也是我现在的主力方案。缺点是遇到结构混乱的页面,标题层级不清晰,需要额外的兜底逻辑。
滑动窗口重叠切片是固定长度切片的改进版,相邻块之间重叠一部分内容,减少边界信息丢失。检索时可以命中更多上下文,但入库量和检索重复度会上升。
我现在的推荐是“标题优先、段落兜底、必要时重叠”:正常页面按结构切,遇到没有明显小标题的页面,回退到带重叠的段落切块。三类方式并不是互斥的,可以按页面特征动态选择。
3.2 用什么粒度切,取决于问答的“颗粒度”
块切多大,不是拍脑袋定的。块太大,向量之间的区分度降低,检索召回会变“糊”;块太小,单个块的语义信息不足,模型回答问题缺少上下文。
我的经验范围是单块256~512个中文词,换算成正文大约一到三个自然段。举个具体化的例子:一篇3000字的技术博客,按标题感知切片通常会切成8到12个块,每个块覆盖一个小主题;如果切成30个固定长度碎片,检索时返回的Top-5很可能来自同一段落,信息密度反而下降。
如果具体问题喜欢“短平快”,可以偏向小粒度;如果需要长文综述,可以额外加一层“把多个相关块合并”的聚合逻辑,而不是一开始就切大块。切片粒度还直接决定了向量维度上的区分度,这个只有在自己数据集上多跑几轮检索实验才能找到最优值,没有万能参数。
3.3 结构化信息与引用锚点的保留
切片时最常犯的错,是只保留纯文本、丢掉结构。举个例子:原文有“步骤一”这样的小标题和几条列表,切成纯文本后,步骤关系就模糊了;生成回答引用时,读者看到的引文也跟着变成无头无尾的一句话。
所以我在每个知识块里不止存正文,还保存“小标题链”和“原始片段”。小标题链记录这个块在原文中的位置,比如“第三章 > 3.2节 > 注意事项”;原始片段则是HTML清洗后保留下来的那段原样文本,既用于展示引用,也用于跟源网页做核对。
这一步还有个关键动作:在块命名空间上生成稳定的块ID,比如“页面哈希-序号”。这个ID不能只在你自己的内存里用,要跟着向量一起存,后面所有绑定引用、去重、更新都靠它。如果ID不稳定,前面说的引用绑定全部都会是空中楼阁。
4. 出处如何长在答案上:引用元数据的设计与绑定
4.1 元数据字段:给每个知识块一个身份证
我在设计知识块结构时,最初只存了“文本”和“URL”,后来发现完全不够用。现在每个知识块都有这样一组字段:
- 来源URL:用于点击跳转和追溯
- 页面标题:展示时让用户先看到“这是哪篇文章”
- 发布时间:资讯类知识排序和展示的硬指标
- 块ID:唯一标识,生成引用编号的物理基础
- 小标题链:标明这个块在页面中的位置
- 原始片段:原样保留的正文,用于和原文对照
你可以把知识块理解成“带档案的知识卡片”。没有档案的卡片,检索出来只是一段文字;有了档案,才能支撑“第2个引用来自3.2节”这种精确表述。一开始我觉得多存几个字段没什么差别,直到做引用校验时才发现,元数据少一个,校验逻辑就少一层保障。
4.2 检索结果如何“强制”模型带引用说话
模型不会天然带着引用说话,除非提示词里给出明确的规则。我的做法是把召回的知识块编号后拼进提示词,同时在系统提示里写明三条纪律:
第一,只根据提供的上下文回答,不要使用训练记忆里的额外信息;第二,回答中每个关键事实后面,都要标注引用编号,格式如[1]、[2];第三,引用编号只能使用提示词中实际存在的块编号,不允许编造。
因为在提示词里所有候选块都有明确编号,模型只要跟着编号走,就能做到“事实对应来源”。如果它硬要引用一个不存在的[9],我会在生成后做一道校验:检查回答中的所有引用编号是否都落在本次候选块集合内,把无效引用直接过滤掉或要求模型重答。
这道校验极其便宜,却能把幻觉导致的“假引用”斩掉一大半。每天成百上千次问答,真正触发重答的比例很低,但每触发一次,就避免了一次“看起来像模像样、实则编造出处”的交付事故。
4.3 展示层的落地:回答末尾的引用列表
后端生成带编号的答案只是第一步,前端展示同样重要。我的界面方案是:回答正文里保留[1][2]角标,末尾自动拼一个“引用来源”列表,每个条目包含标题、来源URL、发布或抓取时间,点击即可跳转原网页。
有的方案会把引用做成悬浮卡片,鼠标悬停就能看到原文片段,体验更好。但前提都是后端把元数据完整传过来,所以元数据接口的设计要比UI早一步规划好。
我踩过的一个小坑是,引用列表里的标题如果直接从HTML的title标签取,经常带着站点名后缀,比如“产品发布 - 某资讯站”,展示在引用区显得很啰嗦。后来我在清洗阶段顺带做一步标题规范化,去掉站点名和分割符,展示效果干净很多。
5. 最小可用方案的完整实操:从抓取到带引用的回复
5.1 技术选型与整体链路
为了让你能直接照着搭,我说一下我这套最小方案选了什么“原材料”。语言用Python,抓取用Python自带的HTTP客户端库,正文解析用标准库的HTML解析器,向量化调用某个商用嵌入模型的服务接口,存储用一个简单的内存向量列表,生成部分调用某个对话模型的接口。实际生产环境可以把内存向量存储换成持久化的向量数据库,其余环节思路不变。
整体链路分成五个模块:抓取、清洗、切片、入库、检索生成。下面按数据流顺序给出核心代码片段,关键处我会加注释说明为什么这么写。
5.2 抓取与正文提取代码
如果页面是静态HTML,基础抓取就能完成。这里要特别注意响应编码的正确读取:
import urllib.request from html.parser import HTMLParser def fetch_page(url): req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) with urllib.request.urlopen(req, timeout=10) as resp: raw = resp.read() charset = resp.headers.get_content_charset() if charset is None: charset = guess_encoding(raw) # 通过字节特征和meta声明推测 return raw.decode(charset, errors="replace"), resp.geturl()有个容易被忽略的动作:读取响应后不要直接decode,先去看头部的charset,必要时做编码推测,否则中文站点经常给你一屏乱码。resp.geturl()拿到的可能是跳转后的最终URL,这个比请求时传入的URL更值得存为来源地址。
正文提取我采用“结构定位 + 密度打分”的思路。先用HTML解析器把DOM拆出来,接着对每个候选容器统计文本量与链接数,最后挑出分数最高的区块作为正文。具体打分可以根据语料调整,我的初始参考值是:文本量权重1.0,链接文本权重-0.5。
def extract_main_content(dom): candidates = locate_content_candidates(dom) # 找包含p/li/pre/table的容器 best = None best_score = -1 for node in candidates: text_len = len(node.text_content()) link_len = sum(len(a.text_content()) for a in node.find_all("a")) score = text_len - 0.5 * link_len if score > best_score: best_score, best = score, node return clean_block(best) # 删掉剩余script/style/iframe后返回5.3 切片、向量化与入库代码
拿到正文后,先按标题标签定位小标题位置,再把小标题下的段落聚合成块;如果没有小标题,就回退到带重叠窗口的段落切块。每个块生成稳定ID,记录小标题链和原始片段。
def chunk_by_headings(parsed_dom): blocks = [] current_block = {"title_chain": [], "paragraphs": []} for node in walk(parsed_dom): if node.tag in ("h1", "h2", "h3"): if current_block["paragraphs"]: blocks.append(build_block(current_block)) current_block = { "title_chain": extract_title_chain(node), "paragraphs": [] } elif node.tag in ("p", "li", "pre", "table"): current_block["paragraphs"].append(node.text) if current_block["paragraphs"]: blocks.append(build_block(current_block)) return blocks向量化这一步我没有自己训练模型,直接调用嵌入服务把每个块转成向量。为什么不用开源方案自己训?因为在这个项目里,嵌入质量决定召回效果,商用服务在长文本语义上更稳定,自己调试的成本高太多。当然如果你有离线部署要求,用开源嵌入模型也可以,逻辑不变。
入库时,我把块文本、元数据、向量放进同一个存储列表。生产化之后,这个内存列表会替换成真正的向量数据库,但字段结构不需要变。
5.4 检索与带引用生成代码
检索时,把用户问题向量化,与库里所有块的向量算余弦相似度,取Top-K个块。为了让模型能带着引用回答,我拼提示词时会显式给每个块编号,并要求模型只能使用这些编号:
def build_prompt(question, top_blocks): context_lines = [] for i, block in enumerate(top_blocks, 1): context_lines.append(f"[{i}] 来源: {block['url']}\n{block['text']}") prompt = "以下是从知识库中检索到的内容,回答时只依据这些内容:\n\n" prompt += "\n\n".join(context_lines) prompt += f"\n\n问题:{question}\n" prompt += "请用[1][2]这样的编号标注每个事实的来源,不要编造不存在的编号。" return prompt def answer_with_citations(question, top_blocks): messages = [ {"role": "system", "content": "你是严谨的知识助手,只根据提供的上下文回答,不确定时明确说不知道。"}, {"role": "user", "content": build_prompt(question, top_blocks)} ] answer = chat_completion(messages) # 调用你选型的对话模型服务 return validate_citations(answer, top_blocks)生成后,我会遍历回答内容,检查所有出现的[编号]是否都落在候选块集合里。发现无效引用时,最简单的处理是把这个结果丢掉,用修正提示词重答一次。
这一步看起来很小,但直接关系到“可引用”三个字的可信度。宁可多花一次模型调用,也不能让一个注水引用出现在最终答案里。
5.5 用一个真实URL跑通全链路
联调时我习惯选一个结构简单、正文干净的技术博客页面做冒烟测试。流程是:抓取 → 清洗 → 切出3到5个块 → 向量化入库 → 提一个针对该页面内容的问题 → 生成带引用的回答 → 人工检查引用是否对得上。
第一次跑通时,模型回答的引用编号虽然正确,但语义仍有一半来自训练记忆,原因是提示词里“只根据上下文回答”这句约束被弱化了。我把它提升到系统角色提示中,并补充“当上下文信息不足时,直接说不知道”之后,效果立刻改观。这一条是整套方案里性价比最高的一处调优。
6. 我实测中踩过的坑,以及对应的调优措施
6.1 动态页面抓不到正文
我第一次碰到动态渲染页面时,普通请求返回的HTML里只有一段空壳,正文一个字没有。当时的排查思路是先确认响应状态码正常、编码正常,再确认HTML里确实没有正文关键词,最后判断是页面本身需要JavaScript渲染。
解决办法是接入浏览器自动化组件,设置页面加载完成后等待正文元素出现。这里有个细节:等待条件不要死等固定秒数,而是轮询判断“正文区域是否可见”,否则慢网络下照样抓空。切换渲染模式后,抓取速度从每秒几十页掉到每页几秒,所以我把“是否需要渲染”的判断结果缓存下来,同一个站点只判断一次。
6.2 清洗规则把正文误杀了
有段时间我为了追求“极净”效果,把文本密度阈值调得很高,结果大量表格类和列表类页面正文被整体删除。表现是某个页面抓回来后,知识库里的块内容完全对不上原文。
根因是密度算法对“短平快”的页面不友好——表格单元格和列表项文本短、链接密度分布不均匀,很容易被当噪声。我加的补救是白名单保护:如果某个容器标签本身是表格、列表、引用块或代码块,先跳过密度判断,保留内容。之后再配合样例集回归测试,误杀率降到可接受范围。
从那以后,我给自己定了一条规矩:清洗规则必须配一套小规模“黄金样例集”,每次改动都跑一遍回归。不然你根本不知道哪次优化是在全局帮倒忙。
6.3 引用“张冠李戴”的问题
一次测试里,我让机器人回答“最近某产品有哪些更新”,回复里[1]指向了正确的产品文档,[2]却指向了一篇无关的文章。当时很困惑,后来追到入库环节才发现原因:同一张抓取任务的并发写库时,两个块的ID冲突,后写入的元数据覆盖了先写入的内容。
解决方案是把块ID改为“内容哈希+序号”的形式,入库时先查是否存在相同内容哈希,存在则复用旧ID并合并元数据,而不是简单追加。另外在生成后校验引用编号时,我额外校验了编号对应的URL是否与问题主题相关,多一道保险。
6.4 向量召回不准时,先别急着换模型
有一次明明知识库里已经存了答案,提问却召回不到相关块。我第一反应是嵌入模型不行,准备换服务。后来排查发现,问题出在切片上:那个页面一个大章节被切成了十几个小块,每块只有两三句话,向量化的结果过于碎片化,与问题向量匹配时分数都不够高。
调整方向不是换模型,而是把“块合并”。我把同一小标题下的多个碎片块合并成更大的块,同时允许检索时返回相邻块作为上下文补充。召回率立刻上升。这个经验让我意识到,向量召回的上游是切片质量,切片质量比换模型更值得优先排查。
6.5 抓取成本与更新策略的平衡
刚开始我每天全量重抓所有来源页面,半夜跑一次,早上起来知识库是新的。但随着页面数上涨,成本越来越高。
后来改成增量策略:对每个页面记录抓取指纹(正文哈希和最后修改时间),定期检查时如果指纹没变就不重新入库,变了才更新对应块。又把高频页面和低频页面分开调度,前者每天更新,后者每周甚至每月更新。这样在保证新鲜度的同时,成本下降非常明显。
7. 上线前必须补的软功课:更新、去重与内容合规
7.1 定时更新与死链处理
知识库是会“变质”的。网页改版、文章下线、内容修订,都会让原来入库的知识块失去时效甚至失效。我的更新逻辑是周期性的URL检测:用轻量请求判断页面状态码和指纹是否变化,状态码异常就标记该URL为死链,从检索结果里降权或淘汰。
注意死链不要直接物理删除,建议软删除,保留一份“历史归档”。因为用户之前生成过的引用和链接,可能还在别处被引用;直接删会让历史资料变成断头链接。保留归档之后,旧引用点击还能看到抓取时的快照,这个细节对长期运营很重要。
7.2 去重与知识库膨胀控制
多个网页经常讲同一件事:不同媒体转载、官方文档重复段落、活动公告多版本。如果不做去重,知识库会快速膨胀,检索时同一主题的块挤占Top-K,答案多样性被压缩。
我用两级去重:入库前先看内容哈希,完全相同的内容直接跳过;入库后再做一次基于向量相似度的聚类,相似度超过阈值的块合并引用(保留多个来源URL),这样既不丢失信息源多样性,又控制规模。
块合并时要注意来源归属。我的策略是保留最早发布且内容最完整的那个块作为主块,其余来源URL挂到主块的扩展来源列表里。这样回答的引用区既能体现“多家信息源都在说同一件事”,又不至于让说法不同的块互相打架。
7.3 内容来源的信任边界
最后这部分是最容易忽略的。作为知识源抓取,一定要守住几个边界:只收录授权允许抓取的站点,遵守对方的抓取规则;涉及版权内容时只保留摘要式引用而非全文转载;对用户可见的引用,必须真实存在且可点击回验。
在内容层面,我建议建立一份“来源白名单”而不是全网乱抓。白名单内的站点经过人工筛选,质量和可信度有保障,知识库的噪声和风险都会小很多。一个可信知识库,不仅要让模型“答得准”,还要让用户“查得到、验得真”。这也是“可引用”三个字对整个系统的最终要求。
如果让我给后来者一条最精炼的建议,那就是先把“正文提取”这关做到位。我见过太多人在向量模型、提示词工程上反复调优,结果源头抓进来的正文都是带广告、带乱码的脏数据,后面所有环节都在给前面的错误买单。我现在每次修改清洗规则,都还在用那套20页的回归样例集,一点点地比对再发布。这套“网页→知识块→带引用答案”的方法,也已经成了我知识库项目里最稳定的底座,建议你从一个小规模的垂直站点开始跑起,先把引用闭环跑通,再慢慢加站点和智能策略。