1. 从“闭卷考试”到“开卷考试”为什么需要给 Chatbot 装上联网工具做过 RAG 应用的人都有一个共同的痛大模型本身是个“闭卷考生”它的知识截止到训练数据的那一天之后发生的事情它一概不知。你问它某个新发布的框架版本有什么变化它要么胡编一个看起来很像的答案要么直接告诉你“我的知识截止于某年某月”。这在演示阶段还能糊弄过去一旦放到真实业务场景里用户问的都是最新政策、最新价格、最新文档闭卷考生立刻就露馅了。“把网页变成可引用知识”这件事本质上就是给 Chatbot 配一套开卷考试的能力遇到不知道的问题先去网上查资料把查到的网页内容切成片段、转成向量、存进知识库然后基于这些真实抓取到的内容来回答并且明确告诉用户“这段话来自哪个网页的第几段”。这跟传统 RAG 的区别在于传统 RAG 的知识库是你提前离线灌进去的而联网工具是运行时动态构建知识库——用户问什么就去抓什么抓完立刻用。我之所以花时间折腾这套东西是因为在实际项目里遇到过太多次“模型一本正经胡说八道”的尴尬。有一次做一个技术选型助手用户问某个开源库最新版本是否支持某个特性模型信誓旦旦说支持结果用户去官网一查根本没有。问题不在于模型笨而在于它手里没有最新的“教材”。联网工具要解决的就是这个让 Chatbot 在回答前先拿到最新、最相关的网页内容并且这些内容是可追溯、可引用的。这套方案适合谁如果你正在做智能客服、技术问答、行业资讯助手、竞品监控这类需要“时效性 可溯源”的场景那这套东西基本是刚需。如果你只是做一个闲聊机器人那确实用不上。下面我会从整体设计、核心细节、实操实现、问题排查四个层面把这套联网工具拆开讲清楚代码和参数都会给到可以直接抄的程度。2. 整体架构设计一条从“问题”到“带引用的答案”的完整链路2.1 核心链路的四个阶段拆解整套联网工具的核心链路可以拆成四段查询理解 → 网页检索 → 内容加工 → 引用生成。这四段不是简单的线性关系中间有若干可以并行和缓存的地方但逻辑上是递进的。第一段查询理解要做的事情是把用户那句口语化的问题转成适合搜索引擎的查询词。用户问“最近那个很火的向量数据库新版本有啥变化”直接拿这句话去搜效果很差需要先让模型把它改写成“向量数据库 最新版本 更新日志 2024”这样的关键词组合。这一步很多人会跳过觉得直接把原问题丢给搜索 API 就行实测下来召回质量差一大截。第二段网页检索就是真正去网上抓内容。这里有个关键决策用搜索引擎 API 还是自己爬我的建议是优先用搜索 API因为自己爬要处理反爬、解析、去重一大堆脏活而且搜索结果的相关性排序本身就是个技术活。搜索 API 返回的通常是标题、摘要、URL 列表你需要从中挑出最相关的几个 URL 去抓正文。第三段内容加工是把抓回来的 HTML 变成可用的知识片段。这一步是整个链路里最脏最累的活要去广告、去导航栏、去页脚提取正文然后按语义切块再转成向量存起来。切块策略直接决定了后面检索的质量切得太碎语义不完整切得太大检索精度下降。第四段引用生成是把检索到的片段喂给模型让它在回答时标注来源。这里有个坑模型经常会“忘记”标注引用或者标注的引用和内容对不上。需要在 prompt 里强制约束格式并且在输出后做一次校验。2.2 为什么选择“运行时构建”而不是“离线预构建”很多人会问为什么不提前把整个网站爬下来建好索引非要运行时抓这个问题我认真权衡过结论是两者要结合但运行时构建是核心。离线预构建的优点是快检索时直接查本地索引延迟低。但缺点是知识陈旧而且你不可能预知用户会问什么预构建的覆盖面永远不够。运行时构建的优点是永远最新、按需抓取缺点是延迟高、依赖外部服务稳定性。我的实际做法是混合模式对高频访问的站点做离线预构建 定时增量更新对长尾问题走运行时抓取。这样既保证了常见问题的响应速度又保证了冷门问题的覆盖率。下面这张表是我在实际项目里总结的两种模式对比你可以根据自己的场景取舍。维度离线预构建运行时构建混合模式推荐响应延迟低本地索引高需抓取解析中命中缓存则低知识时效取决于更新频率实时高频实时长尾按需覆盖范围受限于预爬范围理论上无限兼顾两者实现复杂度中需调度高需容错高适用场景固定知识库开放域问答生产级应用2.3 技术选型的关键考量选型这块我踩过不少坑说几个关键决策点。向量模型方面如果追求效果且预算充足用商用 embedding API 最省事如果要本地部署开源的中文 embedding 模型现在效果也够用但要注意维度选择和显存占用。向量库方面小规模用 FAISS 或 Chroma 就够了上生产再考虑 Milvus 或 Qdrant。搜索 API方面要选支持中文、有免费额度、返回结构清晰的具体哪家我就不点名了你按这几个标准去挑就行。这里重点说一个容易被忽略的点网页正文提取库的选择。Python 生态里 readability-lxml、trafilatura、newspaper3k 我都用过实测 trafilatura 在中文网页上的正文提取准确率最高对广告和导航的过滤最干净但它的依赖稍微重一点。readability-lxml 轻量但中文支持一般。我的建议是主用 trafilatura失败时降级到 readability。3. 核心细节解析查询改写、正文提取与切块策略3.1 查询改写让搜索 API 听懂人话查询改写这一步核心是让模型把用户的自然语言问题转成搜索引擎友好的查询词。这里有个细节不要只生成一个查询词要生成多个。因为单一查询词很容易漏掉相关信息生成 2-3 个不同角度的查询词分别去搜然后合并结果召回率会明显提升。具体做法是让模型输出一个 JSON 数组每个元素是一个查询词。prompt 大概长这样QUERY_REWRITE_PROMPT 你是一个搜索查询改写助手。请把用户的问题改写成2-3个适合搜索引擎的查询词。 要求 1. 每个查询词控制在15字以内 2. 覆盖问题的不同角度 3. 只输出JSON数组不要有其他内容 用户问题{question} 输出示例[查询词1, 查询词2, 查询词3] 实测下来这个 prompt 在主流模型上都能稳定输出合法 JSON。但要注意有些模型会画蛇添足加解释文字所以解析时要做容错用正则把方括号里的内容抠出来再解析。提示查询改写会增加一次模型调用延迟大概多 300-800ms。如果你的场景对延迟极度敏感可以考虑用更小的模型专门做改写或者把改写和后面的答案生成合并成一次调用。3.2 正文提取从一堆 HTML 里捞出真金抓回来的网页是一坨 HTML里面混着导航、广告、评论区、相关推荐。正文提取的目标就是把这坨东西里真正的文章内容捞出来。我用 trafilatura 的典型代码是这样的import trafilatura def extract_main_content(html: str, url: str) - str: # trafilatura 的 extract 方法会自动识别正文 content trafilatura.extract( html, urlurl, include_commentsFalse, # 不要评论 include_tablesTrue, # 保留表格 include_imagesFalse, # 不要图片 favor_precisionTrue, # 偏向精确宁可少要不要多 deduplicateTrue # 去重 ) return content or 这里有几个参数值得说。favor_precisionTrue是我强烈建议开的它会让提取器更保守宁可漏掉一点正文也不要把广告混进来。因为广告混进知识库比漏掉正文的危害大得多——漏掉正文只是少一条知识混进广告会让模型基于错误信息回答。include_tablesTrue也要开很多技术文档的关键信息在表格里。如果 trafilatura 返回空有些网站结构特殊就降级到 readabilityfrom readability import Document def fallback_extract(html: str) - str: doc Document(html) return doc.summary()3.3 切块策略决定检索质量的关键一步切块这件事看起来简单实际上是最影响最终效果的环节。我试过固定长度切、按段落切、按语义切三种方式最后落地的是递归字符切块 重叠的方案。固定长度切块的问题是会把一句话从中间切断导致语义不完整。按段落切的问题是段落长度差异太大有的段落几百字有的只有一句话。递归字符切块的思路是优先按段落切段落太长就按句子切句子还太长就按字符切同时保留一定的重叠区域保证跨块的语义连续性。from langchain.text_splitter import RecursiveCharacterTextSplitter splitter RecursiveCharacterTextSplitter( chunk_size500, # 每块目标500字符 chunk_overlap80, # 块间重叠80字符 separators[\n\n, \n, 。, , , ., , ], length_functionlen ) chunks splitter.split_text(content)参数怎么定chunk_size500是我在中文场景下反复测试的结果。太小比如 200会导致单块信息量不足检索时容易漏太大比如 1000会导致单块里混入无关信息检索精度下降。chunk_overlap80是为了保证跨块的句子不被切断一般取 chunk_size 的 15%-20% 比较合适。注意切块前一定要先做一次文本清洗把连续的空行、特殊符号、零宽字符去掉。这些脏字符会干扰向量化导致检索时匹配不上。4. 实操过程从零搭一个可引用的联网问答链路4.1 环境准备与依赖安装先把依赖装齐。我用的是 Python 3.10主要依赖包括网页抓取、正文提取、向量化、向量检索四类pip install requests trafilatura readability-lxml pip install langchain langchain-community pip install sentence-transformers faiss-cpu pip install beautifulsoup4 lxml如果你要用商用 embedding API把 sentence-transformers 换成对应的 SDK 就行。FAISS 用 CPU 版就够除非你的知识库规模上百万条否则没必要上 GPU。4.2 完整链路的代码实现下面这条链路是我实际项目里跑通的版本做了简化但核心逻辑都在。先看主流程import requests from typing import List, Dict from dataclasses import dataclass dataclass class RetrievedChunk: content: str source_url: str title: str score: float class WebKnowledgeBot: def __init__(self, search_api, embedder, vector_store, llm): self.search_api search_api self.embedder embedder self.vector_store vector_store self.llm llm def answer(self, question: str) - Dict: # 1. 查询改写 queries self._rewrite_query(question) # 2. 多路检索 all_urls [] for q in queries: results self.search_api.search(q, top_k5) all_urls.extend([r[url] for r in results]) all_urls list(dict.fromkeys(all_urls))[:8] # 去重取前8个 # 3. 抓取 提取 切块 chunks [] for url in all_urls: html self._fetch(url) if not html: continue content self._extract(html, url) if not content or len(content) 100: continue for piece in self._split(content): chunks.append(RetrievedChunk( contentpiece, source_urlurl, titleself._get_title(html), score0.0 )) # 4. 向量化 检索 if not chunks: return {answer: 抱歉没有找到相关资料。, sources: []} texts [c.content for c in chunks] vectors self.embedder.encode(texts) self.vector_store.add(vectors, chunks) q_vec self.embedder.encode([question])[0] top_chunks self.vector_store.search(q_vec, top_k5) # 5. 生成带引用的答案 answer self._generate(question, top_chunks) return { answer: answer, sources: [{url: c.source_url, title: c.title} for c in top_chunks] }这段代码里有几个关键点值得展开。第一all_urls去重后限制在 8 个是因为抓取和解析是耗时操作抓太多会拖慢响应。8 个 URL 大概能覆盖 80% 的相关信息再多边际收益就低了。第二每个 URL 抓完立刻切块而不是全部抓完再统一处理这样可以在抓取过程中就过滤掉无效页面减少后续计算量。4.3 生成带引用的答案prompt 怎么写才管用生成环节是整个链路里最需要调 prompt 的地方。核心诉求是模型必须基于给定片段回答不能自己发挥并且要标注引用编号。我的 prompt 模板是这样的ANSWER_PROMPT 你是一个严谨的问答助手。请严格基于下面提供的资料回答问题。 规则 1. 只使用资料中的信息不要添加资料之外的内容 2. 每个事实性陈述后面必须标注来源编号格式为[1][2] 3. 如果资料中没有相关信息直接说资料中未提及 4. 回答要简洁不要重复资料原文 资料 {context} 问题{question} 回答context的拼接方式也有讲究每个片段前面要带上编号和来源def build_context(chunks: List[RetrievedChunk]) - str: parts [] for i, c in enumerate(chunks, 1): parts.append(f[{i}] 来源{c.title} ({c.source_url})\n{c.content}) return \n\n.join(parts)实测下来这种带编号的 context 能让模型引用准确率从 60% 左右提升到 90% 以上。但即便如此还是会有模型“忘记”标注的情况所以输出后要做一次校验如果回答里没有任何[数字]格式的引用就重新生成一次或者在回答末尾追加一句“以上内容基于检索到的资料”。4.4 参数计算chunk 数量和 top_k 怎么定这里给一组我实测下来比较稳的参数你可以直接抄参数推荐值说明查询改写数量2-3 个太少召回不足太多增加延迟每查询取 URL 数5 个搜索 API 通常返回 10 个取前 5 相关度最高去重后总 URL 数8 个平衡覆盖率和延迟chunk_size500 字符中文场景实测最优chunk_overlap80 字符chunk_size 的 15%-20%检索 top_k5 个太少信息不足太多超出模型上下文单页最大 chunk 数20 个防止长文档淹没其他来源这些参数不是拍脑袋定的。比如 top_k5是因为主流模型上下文窗口虽然能塞下更多但塞太多会导致模型注意力分散反而降低回答质量。我做过对比测试top_k 从 3 增加到 5 时回答质量提升明显从 5 增加到 10 时提升就很小了但延迟增加不少。5. 常见问题与排查技巧实录5.1 抓取失败与反爬应对抓取失败是这套链路里最高频的问题。表现是 requests 返回 403、429或者返回的 HTML 里根本没有正文。我的排查顺序是这样的先看状态码403 通常是 UA 被识别加个正常浏览器 UA 能解决大部分429 是频率限制需要加延迟和重试如果状态码正常但正文为空那就是页面是 JS 渲染的requests 拿不到内容。对于 JS 渲染的页面有两个选择上无头浏览器Playwright、Selenium或者放弃这个页面。我的建议是优先放弃因为无头浏览器资源消耗大、速度慢为了少数页面拖慢整体链路不划算。如果确实必须抓就单独开一个异步队列处理不要阻塞主链路。HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Accept-Language: zh-CN,zh;q0.9,en;q0.8 } def fetch_with_retry(url: str, max_retries: int 2) - str: for i in range(max_retries 1): try: resp requests.get(url, headersHEADERS, timeout8) if resp.status_code 200: resp.encoding resp.apparent_encoding return resp.text if resp.status_code 429: time.sleep(2 ** i) # 指数退避 except requests.RequestException: time.sleep(1) return 注意resp.encoding resp.apparent_encoding这行很关键。很多中文网站不声明正确编码requests 默认用 ISO-8859-1 解码结果全是乱码。用 apparent_encoding 让 chardet 自动检测能解决 90% 的乱码问题。5.2 检索结果不相关的排查思路有时候链路跑通了但检索出来的片段和问题八竿子打不着。这种情况我一般按这个顺序排查第一步看查询改写的结果。如果改写出来的查询词本身就偏了后面全白搭。把改写结果打印出来人工判断一下是否合理。如果模型改写质量差换个更大的模型或者在 prompt 里给几个示例。第二步看切块质量。把检索到的 chunk 打印出来看看是不是被切得七零八落。如果 chunk 里全是导航文字或者半句话说明正文提取或切块有问题回去调 trafilatura 参数。第三步看向量模型。如果 chunk 内容没问题但检索还是不准可能是 embedding 模型对中文支持不好。换一个中文语料训练过的模型试试效果差异会很明显。第四步看相似度阈值。有时候检索返回的 top_k 里混了一些低分片段可以在检索后加一个阈值过滤低于阈值的直接丢掉。def filter_by_score(chunks, threshold0.35): return [c for c in chunks if c.score threshold]阈值定多少要看你的 embedding 模型和相似度度量方式。余弦相似度的话0.3-0.4 是个常见的起点具体要拿实际数据调。5.3 引用标注错乱的修复引用标注错乱有两种表现一是模型标注的编号和实际来源对不上二是模型编造了不存在的编号。第一种通常是 context 拼接时编号和来源没对齐检查build_context函数确保编号连续且和 chunks 列表顺序一致。第二种是模型幻觉需要在 prompt 里强调“只能使用给定的编号”并且在输出后做校验把超出范围的编号替换掉或删掉。import re def validate_citations(answer: str, max_index: int) - str: def replace(match): idx int(match.group(1)) if 1 idx max_index: return match.group(0) return # 删掉非法引用 return re.sub(r\[(\d)\], replace, answer)5.4 常见问题速查表问题现象可能原因排查方向解决方案抓取返回 403UA 被识别看状态码加浏览器 UA抓取返回 429频率限制看状态码加延迟指数退避正文为空JS 渲染看 HTML 内容换无头浏览器或放弃中文乱码编码错误看响应内容用 apparent_encoding检索不相关查询改写差打印改写结果换模型或加示例检索不相关切块太碎打印 chunk调大 chunk_size引用错乱编号不对齐检查 context修正拼接逻辑引用编造模型幻觉校验输出正则过滤非法编号响应太慢抓取太多看耗时分布减少 URL 数或加缓存5.5 几个我踩过的坑第一个坑是缓存没做。早期版本每次提问都重新抓取同样的 URL 反复抓既慢又容易被限流。后来加了一层 URL 级别的缓存同一个 URL 在 24 小时内只抓一次命中缓存直接读本地响应速度提升了一倍多。缓存用简单的文件缓存就行key 是 URL 的 hashvalue 是提取后的正文。第二个坑是没有限制单页 chunk 数。有一次抓到一个超长的技术文档切出来 200 多个 chunk全部塞进向量库后检索时这个文档的片段把其他来源全挤掉了导致回答只基于这一个来源。后来加了单页最大 chunk 数限制20 个超出就只取前 20 个问题解决。第三个坑是prompt 里的资料顺序。一开始我把检索到的 chunk 按相似度从高到低排列结果模型倾向于只引用第一个片段后面的片段被忽略。后来改成把最相关的放最后模型对末尾内容注意力更强引用分布就均匀多了。这个技巧在长 context 场景下特别管用。6. 效果优化与扩展方向6.1 提升检索质量的两个实用技巧第一个技巧是混合检索。纯向量检索对语义匹配好但对关键词精确匹配弱。比如用户问某个具体的错误码向量检索可能召回一堆语义相近但错误码不同的内容。解决办法是同时做一路关键词检索BM25然后把两路结果融合。融合算法用 RRFReciprocal Rank Fusion最简单不需要调权重def rrf_fusion(vector_results, keyword_results, k60): scores {} for rank, item in enumerate(vector_results): scores[item.id] scores.get(item.id, 0) 1 / (k rank 1) for rank, item in enumerate(keyword_results): scores[item.id] scores.get(item.id, 0) 1 / (k rank 1) return sorted(scores.items(), keylambda x: -x[1])第二个技巧是重排序。检索出 top_k 后用一个 cross-encoder 模型对 query 和每个 chunk 做一次精细打分重新排序。这一步能显著提升精度但会增加延迟。我的做法是只在 top_k 较大比如 10时启用重排序把 10 个重排后取前 5 个性价比最高。6.2 引用展示的产品化处理引用不只是给模型看的最终要展示给用户。我的做法是在回答下方列出所有引用来源每个来源显示标题、域名和可点击的链接。如果用户点击某个引用编号能跳转到对应来源。这里有个细节引用编号要和来源列表的序号一致否则用户会困惑。另外如果多个 chunk 来自同一个 URL展示时要合并不要重复列出同一个来源。可以在生成来源列表时按 URL 去重但保留所有引用编号的映射关系。6.3 后续可以扩展的方向这套链路跑通后有几个自然的扩展方向。一是多轮对话把历史问答也纳入 context让模型能理解“它”“这个”之类的指代。二是定时增量更新对高频站点做定时抓取把新内容增量加入向量库减少运行时抓取的压力。三是来源可信度加权给不同域名设置不同的可信度权重检索时优先采用高可信度来源。四是答案置信度评估让模型在回答时给出一个置信度低置信度时主动提示用户“建议核实”。我个人在实际操作中的体会是这套东西最难的不是代码而是对脏数据的容忍度设计。网上抓来的内容质量参差不齐你永远无法保证每个页面都能干净提取。所以整个链路要有足够的容错抓取失败就跳过提取为空就跳过切块异常就跳过绝不能让一个坏页面拖垮整个回答。宁可少几条知识也不要让脏数据污染答案。这个原则我在多个项目里反复验证过是保证系统稳定性的底线。