用Haystack构建RAG数据抓取Pipeline:从网页到向量库的完整实践

发布时间:2026/10/17 3:40:27
用Haystack构建RAG数据抓取Pipeline:从网页到向量库的完整实践 做 RAG 类项目的时间久了你会发现真正耗时间的往往不是模型调优而是数据准备。我最近用 Haystack 自建了一个自定义数据抓取工具把网页抓取、清洗、切分、向量化、入库做成了同一条 pipeline整条链路跑下来比之前用 Scrapy 拼组件顺手很多。这篇文章不打算写成 API 文档而是把我从选型到落地的完整思路、核心代码以及那些网上不太有人提的坑一次讲清楚。如果你也在做知识库问答、企业内部检索这类场景需要一个能把网页变成可检索语料的入口这篇会对你很有帮助。1. 先厘清这个抓取工具到底解决什么问题1.1 传统爬虫链路在 RAG 场景下的断层很多人一提到数据抓取第一反应是 Scrapy、BeautifulSoup、Requests思路也停留在把 HTML 抓下来、解析出正文、存到数据库。这套做法在传统爬虫场景里没问题但在 RAG 场景里会出现一个很尴尬的断层抓下来的数据还要经过清洗、切分、向量化、写入向量库然后再接到检索和问答链路上。这几步在传统爬虫体系里完全没有对应的抽象你只能自己在中间写一堆胶水代码。我最初就是这么干的用 Scrapy 抓详情页用 BeautifulSoup 抽正文自己写清洗自己调 embedding 接口再自己拼检索逻辑。结果就是每个环节都能跑但整个工程变得非常脆。你会发现 80% 的代码都是在做数据格式转换和状态管理真正有价值的业务逻辑反而被淹没在里面。1.2 一条 pipeline 打通URL → 向量的完整链路Haystack 给我的直观感受是它把非结构化数据变成可检索知识这件事拆成了标准组件。抓取只是第一个节点后续有 HTML 转换器、清洗器、切分器、嵌入器、文档写入器。你不用自己发明一套架构只要决定每个节点用什么组件、按什么顺序连接剩下的交给 pipeline 调度。拿我做的这个自定义工具来说输入是一批 URL 列表输出是向量库里的文档块整个过程只有一条链抓取器拿到原始 HTML转成 Document清洁器去掉噪声切分器切成固定大小的块嵌入器生成向量写入器存到 DocumentStore。所有组件共用一套数据流调试的时候看每个节点的结果就行不会再出现数据在这步变成了什么结构这种模糊地带。1.3 需求边界什么场景值得用 Haystack 做抓取不是所有抓取需求都要上 Haystack。如果你的目标是最小化抓取特定字段比如每天抓商品价格存数据库那 Scrapy 仍然更合适。Haystack 更擅长的是你要把网页内容变成检索语料、知识块、问答上下文的场景。判断标准很简单下游是不是要接向量检索、大模型问答、语义匹配。只要答案是要Haystack 就能把抓取和下游无缝连起来如果只是把字段抽出来存表它反而显得重。我这边实际要处理的场景是把某内部技术文档站的上百个页面抓取下来做成知识库供后续问答使用。页面需要登录态才能访问内容以中文技术文档为主格式上有大量代码块和标题结构。这个场景就非常适合用 Haystack 来搭。2. 为什么选 Haystack 而不是传统爬虫框架2.1 组件化思维取代爬虫框架思维传统爬虫框架的核心抽象是 Spider、Item、Pipeline设计目标是解决如何高效地遍历网站、抽取字段、存储数据。但 RAG 数据准备的核心抽象是文档状态流转关注的是原始文本如何一步步变成干净的、可切分、可嵌入的知识单元。这两种思维在面对同一个任务时会产生完全不同的工程结构。Haystack 的核心抽象是 Pipeline 和 Component。每个组件只做一件事输入输出都是标准类型ByteStream、Document、List[Document]。这种约定让抓取和清洗之间不需要专门写适配层。我从一开始就把目标定成最小 pipeline 闭环所以没用 Scrapy而选 Haystack。2.2 我对比过的框架和它们的问题我实际拿三个方案做过对比自研 Requests BeautifulSoup 脚本、Scrapy 爬虫、Haystack pipeline。自研脚本最灵活但所有基础设施都得自己造重试要自己写去重要自己写缓存要自己写下游 embedding 的批次处理也要自己写。跑一次没问题跑两次、跑十次就开始难受。Scrapy 在抓取端的成熟度无可争议并发和限速机制都很完善。但它的产物是 Item你得把 Item 再手动转成能喂给 embedding 模型的结构等于额外维护一条平行的数据处理链。而且 Scrapy 项目结构对我只想抓 200 个文档页面来说偏重了。Haystack 组件化方案则让抓取变成 pipeline 里一个普通节点自定义起来也不难。抓到的 HTML 直接变成 ByteStream交给下游转换器数据格式天然统一。下面用一个简单表格概括我当时的主要考虑。维度自研脚本ScrapyHaystack pipeline抓取控制重试/限速需自研成熟组件内实现数据格式统一差需转换标准 Document下游 RAG 接入需大量胶水代码需大量胶水代码原生支持项目复杂度低偏高适中适合场景一次性抓取大规模定向抓取RAG 数据准备2.3 Haystack 组件的职责边界在实际搭建之前先把每个组件的职责搞清楚后面才不会乱。HTMLToDocument负责把 HTML 字节流转成 Document顺便提取正文内容。它不负责清洗也不负责去广告。DocumentCleaner负责去掉正文里的空行、多余空白、特定冗余文本本质是文本级规则清洗。DocumentSplitter负责把一篇长文档切成多个块是控制检索粒度最关键的节点。SentenceTransformersDocumentEmbedder负责把文本块变成向量内部管理模型加载和批处理。DocumentWriter负责把向量写入 DocumentStore可以配置按哪种策略去重或覆盖。自定义抓取器就是处理目标页面需要登录、有分页、可能要执行 JS 渲染这些官方组件默认覆盖不到的情况。我把官方组件和自定义组件放在同一条 pipeline 里各管一段职责就很清晰了。3. 抓取 pipeline 的设计与核心代码实现3.1 整体数据流与组件连接方式先给出我最终使用的 pipeline 结构然后逐段讲代码。数据流是URL 列表 - 自定义抓取器(ByteStream) - HTMLToDocument(Document) - DocumentCleaner(Document) - DocumentSplitter(List[Document]) - SentenceTransformersDocumentEmbedder(List[Document]) - DocumentWriter(List[Document]) - DocumentStore注意我不会把原始 HTML 直接塞进向量库。原始的网页数据噪声太多导航、页脚、版权声明对语义检索全是干扰。所以抓取器这里最重要的职责是拿到干净的 HTML 字节流并在meta里把来源 URL、抓取时间这些信息带上后面的清洗和切分节点才好工作。3.2 自定义抓取组件登录态、分页和缓存先写自定义抓取器。Haystack 的自定义组件只需要装饰component声明输入输出类型然后实现run方法。我这里用httpx做 HTTP 请求支持自定义 Cookie、User-Agent并且带一个简单的磁盘缓存避免重复抓取同一页面。import hashlib import time from pathlib import Path from typing import List, Optional import httpx from haystack import component from haystack.dataclasses import ByteStream component class CustomSiteFetcher: def __init__( self, cookie: str , delay: float 1.0, cache_dir: Optional[Path] None, ): self.headers { User-Agent: ( Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36 ), Cookie: cookie, } self.delay delay self.cache_dir cache_dir self.client httpx.Client(headersself.headers, timeout30, follow_redirectsTrue) component.output_types(streamsList[ByteStream]) def run(self, urls: List[str]) - dict: streams [] for url in urls: content self._fetch(url) if content is None: continue stream ByteStream( datacontent.encode(utf-8), meta{url: url, fetched_at: time.time()}, ) streams.append(stream) time.sleep(self.delay) return {streams: streams} def _fetch(self, url: str) - Optional[str]: if self.cache_dir: cache_file self.cache_dir / (hashlib.sha256(url.encode()).hexdigest() .html) if cache_file.exists(): return cache_file.read_text(encodingutf-8, errorsignore) resp self.client.get(url) resp.raise_for_status() html resp.text if self.cache_dir: self.cache_dir.mkdir(parentsTrue, exist_okTrue) cache_file.write_text(html, encodingutf-8) return html几个关键细节返回类型定为List[ByteStream]这样可以直接接HTMLToDocument的sources输入不需要额外的适配组件。meta里带上url和fetched_at后面去重和增量更新都会用到。缓存目录要把 URL 做哈希后作为文件名避免 URL 中有特殊字符导致文件名非法。delay参数控制请求间隔既是对目标站点的礼貌也是防止请求过快触发封锁。这个参数一定要暴露出来方便不同站点灵活调整。如果目标页面是纯 JS 渲染httpx拿到的 HTML 里往往没有正文内容。这种场景我建议在_fetch里判断如果响应页面里没有目标正文的特征标记就用 Playwright 渲染一次再抓取。这一步我会在后面的扩展部分展开先不写进核心代码。3.3 标准化组件组装与第一次运行自定义抓取器写完剩下的全部用 Haystack 官方组件。以我处理中文技术文档的场景为例完整的 pipeline 组装代码大致如下。from pathlib import Path from haystack import Pipeline from haystack.components.converters import HTMLToDocument from haystack.components.embedders import SentenceTransformersDocumentEmbedder from haystack.components.preprocessors import DocumentCleaner, DocumentSplitter from haystack.components.writers import DocumentWriter from haystack.document_stores.in_memory import InMemoryDocumentStore # 初始化存储 store InMemoryDocumentStore() # 配置清洗器去掉空行、多余空白以及常见冗余文本 cleaner DocumentCleaner( remove_empty_linesTrue, remove_extra_whitespacesTrue, remove_substrings[上一篇, 下一篇, 版权声明, Copyright], ) # 配置切分器按段落切分每块 512 个单元重叠 64 splitter DocumentSplitter( split_bypassage, split_length512, split_overlap64, ) # 配置嵌入模型这里路径按你本地可用的中文模型来换 embedder SentenceTransformersDocumentEmbedder( modelpath/to/your/chinese-embedding-model, devicecpu, ) embedder.warm_up() # 组装 pipeline pipe Pipeline() pipe.add_component(fetcher, CustomSiteFetcher( cookie你的登录 Cookie, delay1.0, cache_dirPath(./cache), )) pipe.add_component(converter, HTMLToDocument()) pipe.add_component(cleaner, cleaner) pipe.add_component(splitter, splitter) pipe.add_component(embedder, embedder) pipe.add_component(writer, DocumentWriter(document_storestore)) pipe.connect(fetcher.streams, converter.sources) pipe.connect(converter.documents, cleaner.documents) pipe.connect(cleaner.documents, splitter.documents) pipe.connect(splitter.documents, embedder.documents) pipe.connect(embedder.documents, writer.documents)组装 pipeline 时有一个常见的坑SentenceTransformersDocumentEmbedder在首次运行时需要加载模型如果模型比较大warm_up()这一步特别慢。不要担心这是正常现象。如果你跑在 CPU 环境一个中文 embedding 模型首次加载花两分钟都不稀奇。3.4 把 pipeline 跑起来并传入起始 URLpipeline 组装好之后运行方式非常直接。只需要按组件名传入参数把要抓取的 URL 列表传给fetcher即可。detail_urls [ https://example.com/docs/start, https://example.com/docs/advanced, # 实际项目中这里往往来自列表页解析 ] result pipe.run(data{fetcher: {urls: detail_urls}}) print(result[writer][documents_written])pipe.run的data结构让我最初有点不习惯它必须按照{组件名: {参数名: 值}}的方式嵌套。其实这是有意设计的好处是 pipeline 里任何组件的输入都可以单独指定互不干扰。如果你后续想跳过某些组件、单独测试清洗效果这种结构就会很方便。另外如果你的 URL 列表需要通过分页来收集我建议在进入 pipeline 之前先把详情页 URL 收集好。比如在CustomSiteFetcher里加一个collect_detail_urls方法先用同样的请求逻辑抓列表页再用正则或BeautifulSoup抽取出详情页链接。这样抓取和索引天然分成两个阶段先找链接再跑 pipeline会比在组件内部边解析边抓取更容易排查问题。4. 从抓取到检索之间最容易翻车的四个环节4.1 网页脏数据清洗规则必须前置大多数网页的真正正文只占整个 HTML 的一小部分导航栏、侧边栏、页脚、脚本片段会把文本内容污染得很严重。我一开始只靠DocumentCleaner的规则清洗结果嵌入后的向量里混进了大量相关推荐上一篇热门文章这类内容检索时经常召回一些标题相关但正文无用的块。后来我的做法是在自定义抓取器返回 ByteStream 前先用BeautifulSoup把目标正文区域摘出来。比如某篇文档的正文容器是div classarticle-content那我就只提取这个容器内部的文本。这样进入HTMLToDocument的内容已经相对干净后面的DocumentCleaner只需要处理空行和多余空白就够了。这里有个很重要的经验清洗规则一定要前置到抓取逻辑里而不是全部依赖通用清洗器。通用清洗器擅长处理格式问题但不理解你的目标站点的页面结构。每个站点都是一套独立的清洗规则我建议用配置文件管理 CSS 选择器而不是写死在代码里。4.2 chunk 切分策略召回质量的分水岭同一篇文档切分成 2000 字的大块和切分成 300 字的小块检索效果天差地别。块太大向量会把多个主题混在一起召回结果不够精准块太小语义不完整单个块可能表达不了完整的观点。我试过几种切分方式后最终选了按段落切分、块长 512、重叠 64 的策略。原因很简单技术文档的结构以段落为最小语义单位代码块和标题天然分隔按段落切不会把两个无关主题硬拼在一起。split_overlap的作用是让相邻块之间保留一些上下文避免一个完整句子恰好被腰斩。对于中文内容我特别提醒一点如果你用split_bysentence默认的句子切分对中文的支持未必好。用passage方式反而更稳因为中文段落之间的语义边界通常比较明确。4.3 embedding 模型选择与向量维度一致性这个环节的坑比较隐蔽而且最容易在索引数据量大之后才暴露。我第一次用了某个英文通用 embedding 模型来处理中文技术文档检索时发现语义完全不靠谱很多明显的同义改写都召不回来。换成一个对中文友好的开源模型后效果立刻提升了一个量级。另外要注意向量维度的变化。如果你之前用模型 A 生成了向量后来换成模型 B而两个模型的输出维度不同旧向量和新向量混在同一个 DocumentStore 里检索结果会一片混乱。遇到这种切换最省心的做法是清空向量库重建索引。在代码层面建议把模型路径、向量维度、切分参数都写到配置中心避免换模型时漏改参数。4.4 去重和增量更新的兜底机制爬虫运行次数多了之后你会发现同一批 URL 被重复抓取、重复索引的情况非常常见。如果不做去重向量库里的冗余文档会让检索结果出现大量重复块。我的做法是在清洗之后对文档内容做 SHA-256 指纹存入meta[content_hash]。每次索引前先对已有的 URL 列表做一次指纹比对相同的直接跳过。增量更新同理抓取时可以带上If-Modified-Since头或者比较meta[fetched_at]与页面的最后更新时间。简单场景下先全量抓取再按指纹去重已经能解决绝大多数重复问题。5. 本地实测的效果与调优记录5.1 一组本地实测数据我用这套 pipeline 在一个内部技术文档站上做过一次完整跑通测试目标页面约 200 个详情页。测试环境是普通开发机CPU 型号不算新内存 16GBembedding 模型跑在 CPU 上。实测数据如下。阶段耗时结果URL 收集约 1 分钟收集 205 个详情页抓取带 1s 延迟约 6 分钟缓存 HTML 文件 205 个HTML 转换 清洗约 40 秒205 个 Document切分约 10 秒2100 多个 chunks向量化CPU约 12 分钟2100 多个向量入库约 3 秒完成写入整体耗时主要被抓取延迟和 embedding 占掉。如果你有 GPU向量化时间能压缩到一分钟以内如果你愿意把请求延迟降到 0.3 秒抓取也能快不少。但我不建议为了速度把延迟调得太低尤其是有登录态的站点请求太快容易触发风控。5.2 运行中遇到的高频故障定位我把实际运行中遇到的几类问题整理成了一张排查表方便你直接对照。现象可能原因处理方式抓回内容全部是导航和脚本JS 渲染页面HTMLToDocument 无法提取在抓取器里接 Playwright 预渲染某些页面返回 403缺少登录态或者请求频率过高补 Cookie增大 delay增加重试文档写入数量为 0上游组件返回了空列表检查 cleaner 的 remove_substrings 是否误删太多检索结果语义混乱向量库混入旧模型向量清空向量库后重建索引5.3 这个工具后续还可以怎么扩展当前版本已经能用但我自己很清楚它还能往三个方向扩展。第一把CustomSiteFetcher升级成可选 Playwright 渲染模式专门处理前端框架渲染的单页应用这个改动只影响抓取器内部不会污染下游组件。第二把InMemoryDocumentStore换成 OpenSearch 或者 PostgreSQL 向量插件支撑更大规模的内容索引。第三在 pipeline 最后接一个质量校验节点筛掉诸如没有标题正文过短的劣质文档尽量减少对联调阶段的干扰。如果你只是需要一个把若干网页变成知识库的入口工具这条最小 pipeline 其实已经足够用了。真正值得投入时间的地方不是抓取本身而是清洗规则和切分策略的持续打磨。最后分享一个我的实操体会做这类抓取工具最忌讳一上来就追求功能大而全。先跑通一条最小链路抓 50 个页面看一下向量检索的实际效果再回头补动态渲染、去重、增量这些东西效率会高很多。Haystack 的优势正在于此——当你想给 pipeline 增删一个节点时改动成本被控制得很低这比任何花哨的架构设计都实在。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询