从URL到向量索引:RAG应用如何高效构建网页知识库 做过几个 RAG 应用之后你会发现一个特别尴尬的现象只要你喂的是静态 PDF 或者 Markdown整个流程跑得顺顺畅畅可一旦客户要求“把官网这几个最新公告接进知识库”事情就变得很难看。要么抓下来一堆导航栏和页脚噪音要么网页结构一变之前的索引直接失效要么 chunk 切得稀碎导致检索召回完全不对味。我最近在整理的VectifyAI/PageIndex项目就是想把这套“从 URL 到可查询向量索引”的流程做成一个开箱即用的工具。简单说PageIndex 是一个面向 AI 问答、语义搜索场景的网页索引服务你给它一个 URL 列表它负责抓取内容、清洗噪音、智能分块、向量化入库最后暴露一个检索接口给上层应用调用。它不是爬虫不负责全网发现 URL也不是向量数据库不管海量数据的分布式存储它盯住的是“网页内容变成高质量索引”这条很少有人做好的链路。这篇博文我会完整讲一遍这个项目从设计到落地的过程包括抓取层怎么取舍、分块为什么不能拍脑袋切、索引服务怎么搭、批量 CLI 怎么用以及我踩过的几个坑适合正在做 RAG、知识库问答、或者想把自己收藏夹变成可检索知识库的朋友参考。1. PageIndex 要解决的真实痛点RAG 应用与“新鲜网页”之间的最后一公里1.1 从一次失败的 Demo 说起去年我帮一个做电商的朋友搭客服问答机器人知识源是商家官网的活动页和售后政策页。一开始我用通用爬虫把几个活动页整页抓下来丢给文本切割器做完 embedding 就塞进向量库。结果上线没两天就翻车了用户问“退货政策”返回的内容是页面顶部的导航文字用户问“满减活动时间”因为活动页是前端动态渲染的爬虫抓到的是空壳 div索引里根本没有任何有效信息。那次之后我意识到RAG 应用效果差很多时候不是模型不行也不是向量库不够好而是“网页到索引”这一段链路太糙。网页不是 PDF它有导航栏、页脚、cookie 弹窗、公告横幅、动态渲染内容还有一个要命的属性——它会变。把网页当普通文档处理等于把一堆装修垃圾和家具一起搬进新房子后面的检索效果自然没法看。1.2 PageIndex 的项目定位所以做 VectifyAI/PageIndex 的时候我给它定了一个非常清楚的边界它只做一件事——把 URL 变成“可回答问题的索引”。拆开来看这条管道包含五个阶段抓取拿到 URL下载 HTML保留正文主体。清洗去掉导航、脚本、样式、cookie 弹窗等噪音转成干净文本。分块按语义边界把长文切成适宜 embedding 的块而不是机械地按字符数硬切。向量化给每个块生成语义向量。索引与查询存下向量和元数据对外提供检索 API。它不是一个分布式爬虫不会自动发现链接、不会管理抓取队列它也不是一个完整的向量数据库不解决几千万向量的横向扩展问题。PageIndex 更像是一个“管道工”专门把网页和向量库之间那段最脏、最容易被糊弄的路修好。数据量上来之后你完全可以把它接入 Qdrant、Milvus 或者 PGVector。1.3 适合谁用我整理了几类典型用户和对应场景方便你对号入座用户类型典型场景PageIndex 解决什么RAG 应用开发者把官网文档、公告页接入问答机器人解决网页正文提取和语义分块的一致性信息整合爱好者把关注的技术博客、日报源合并成知识库提供批量 CLI一条命令索引几十个 URL企业内部工具团队对接多个业务系统的帮助中心页面统一清洗规则避免每个页面单独写解析脚本知识库维护者定期同步页面更新增量更新、内容 hash 去重这个项目对代码基础的要求不算高你懂一点 Python、会跑命令行就能用起来。我后面会讲到它把大部分繁琐的决策都固化成默认参数你只需要提供 URL。2. 先想清楚三层问题抓取、分块、索引各自的最优解与取舍动手写代码之前我花了很多时间在选型上。因为网页索引这个领域表面看起来每个环节都有现成库但组合在一起的时候问题全是出在“交接处”。我按抓取、分块、索引三层一个个说。2.1 抓取层为什么我选择“静态优先、动态兜底”而不是无脑上浏览器渲染很多人一提到网页抓取就上 Playwright觉得无头浏览器万能。但实测下来无头浏览器有三个很烦的问题内存占用高、抓取速度慢、容易被目标网站的风控拦。对于一个要索引几十上百个页面的工具每条链接都启动一个浏览器实例很不划算。所以 PageIndex 的抓取策略是“静态优先、动态兜底”先用trafilatura或BeautifulSoup lxml做第一轮抓取。trafilatura这个库在提取正文上做得相当好它能根据 HTML 结构推断出正文区域对大多数博客、文档站、新闻页效果都很棒。如果发现正文为空或者页面内容长度明显低于预期再自动升级到 Playwright 渲染等 JavaScript 跑完再取 HTML。判断“要不要升级”的逻辑我写得很简单async def fetch_page(url: str) - FetchResult: # 第一轮静态抓取 try: async with httpx.AsyncClient( headers{User-Agent: UA}, timeout15, follow_redirectsTrue, ) as client: resp await client.get(url) html resp.text except Exception as e: logger.warning(static fetch failed: %s, fallback to playwright, e) return await fetch_with_playwright(url) # 提取正文 text trafilatura.extract(html, include_commentsFalse, include_tablesTrue) if not text or len(text.strip()) 200: logger.info(正文过短尝试 JS 渲染: %s, url) return await fetch_with_playwright(url) return FetchResult(urlurl, htmlhtml, texttext)这个设计让静态页面走快速通道动态页面才付出浏览器渲染的代价。在我的测试集合里大概八成页面能走静态通道整体抓取速度提升了近五倍。另外有一点必须说抓取一定要带合理的 User-Agent并且尊重目标网站的节奏。我在后面踩坑部分会细讲这里先提一句别把人家网站抓挂了。2.2 分块层固定窗口切块最大的问题是“语义腰斩”向量化之前要分块但分块这个环节最容易被低估。网上很多教程直接split_text_by_chars(text, 500)这在小规模 demo 里看不出问题一旦到了真实长网页立刻露馅。举一个实际例子。某个售后政策页面长这样退款条件商品未拆封签收后 7 天内以下情况不支持退款已使用过的商品无购买凭证超出时效用 500 字符固定窗口切第一块结尾很可能停在“签收后 7 天内”第二块开头是“以下情况不支持退款”。用户问“我拆了包装还能退吗”向量检索只召回到第一块答案只有“未拆封”和“7 天内”完全没有“不支持退款”的信息这就是典型的“语义腰斩”。PageIndex 的分块策略是“结构感知语义切分”优先按标题层级h1/h2/h3、段落边界、列表项来切实在没有明确边界才用字符窗口兜底。我写了一个分块器核心逻辑大概是def split_semantic(text: str, max_chunk: int 800, overlap: int 100) - list[str]: sections split_by_headings(text) # 按标题层级切分 chunks [] for sec in sections: if len(sec[content]) max_chunk: chunks.append(f{sec[heading]}\n{sec[content]}) else: # 长段落内再按段落/列表切 sub_sections split_by_paragraphs(sec[content]) buffer [sec[heading]] current_len len(buffer[0]) for para in sub_sections: if current_len len(para) max_chunk and buffer: chunks.append(\n.join(buffer)) buffer [sec[heading], para] current_len len(buffer[0]) len(para) else: buffer.append(para) current_len len(para) if buffer: chunks.append(\n.join(buffer)) return chunks这里我把标题作为块的前缀保留下来是因为向量检索时标题本身就是很好的语义锚点。实测下来这种分块方式比纯字符切割的召回命中率高出不少。具体数字我后面会放一张测试表。2.3 索引层向量检索 关键词过滤的“混合召回”向量检索擅长语义相似但不擅长精确词匹配。比如用户问“满 300 减 50 吗”如果页面里写的是“每满 300 元可享受 50 元优惠”向量检索没问题但如果页面里写的是“满减规则”用户只记得“300”“50”这种数字纯向量检索可能把数字权重压得很低。所以 PageIndex 的查询端设计成混合召回先用关键词做一次轻量过滤在元数据里匹配标题和正文摘要再对过滤结果做向量召回最后合并打分。关键词层我用的是 SQLite 的 FTS5向量层目前用 numpy 暴力检索。很多项目一上来就接向量数据库其实早期数据量不到十万条的时候numpy 暴搜完全够用还少一个运维组件。def search(query_vector: np.ndarray, top_k: int 5, keyword_filter: str ): if keyword_filter: meta_ids fts_search(keyword_filter) if not meta_ids: return [] mask np.array([i for i, m in enumerate(metadata) if m[id] in meta_ids]) vecs vectors[mask] scores vecs query_vector idx np.argsort(scores)[-top_k:][::-1] return [metadata[mask[i]] for i in idx] else: scores vectors query_vector idx np.argsort(scores)[-top_k:][::-1] return [metadata[i] for i in idx]这个设计保证了精确词不会丢语义扩展又能兜住。2.4 技术选型总览做决定之前我把候选方案列了一张表最终选择的原因写得很直白环节候选方案最终选择理由静态抓取requests BeautifulSoup / trafilaturatrafilatura正文提取质量高参数少中文网页表现也不错动态渲染Playwright / SeleniumPlaywrightAPI 现代化内置 wait 机制容错更好文本切分LangChain 切分器 / 自研结构切分自研结构切分能保留标题层级便于检索锚定向量生成云厂商 embedding API / 本地模型OpenAI 兼容接口默认支持本地 Ollama不绑定厂商本地也能跑向量存储Qdrant / Chroma / numpynumpy SQLite 元数据万级数据量内最省事零运维查询框架直接函数调用 / FastAPIFastAPI顺手就暴露成 HTTP 服务了你可能注意到我没有选任何重量级向量数据库这在项目初期是一个刻意的“偷懒”。因为工具的价值在于把流程跑通而不是一开始就背上一个分布式系统的运维负担。3. 从空仓库到可用服务PageIndex 的搭建与核心 API 实现3.1 工程结构一个模块只负责一件事项目保持了一个非常直白的模块划分你一看目录就知道哪个环节在哪里改pageindex/ ├── pyproject.toml ├── README.md ├── config.example.json ├── pageindex/ │ ├── __init__.py │ ├── cli.py # 命令行入口 │ ├── server.py # FastAPI 服务 │ ├── fetcher.py # 抓取层静态动态 │ ├── cleaner.py # 清洗和分块 │ ├── embedder.py # 向量化 │ ├── store.py # 向量存储与检索 │ └── utils.py # 日志、文件处理 ├── tests/ └── data/ # 索引持久化目录这个结构的好处是你可以单独替换任何一层。比如你想把fetcher.py换成分布式爬虫或者把store.py从 numpy 暴搜换成 Qdrant都不会影响其他模块。我一开始就把接口抽象得很细后面改起来会轻松很多。3.2 核心模块实现抓取、清洗、分块、向量化、持久化抓取模块前面已经展示了大半这里补充清洗环节。清洗不是简单地把标签去掉而是要保留正文里的表格、代码块、列表结构。trafilatura有一种xml输出模式比直接抽纯文本好因为它保留了段落和标题层级后面的分块器可以在此基础上做结构感知切分。我实际保存正文时用的是include_tablesTrue对于文档站表格往往是信息密度最高的地方不能丢。embedder 模块我封装了一个本地优先的向量化客户端class Embedder: def __init__(self, base_url: str http://localhost:11434, model: str nomic-embed-text): self.base_url base_url self.model model self._dim None def embed(self, texts: list[str]) - np.ndarray: resp httpx.post( f{self.base_url}/api/embed, json{model: self.model, input: texts}, timeout60, ) resp.raise_for_status() vectors resp.json()[embeddings] self._dim len(vectors[0]) return np.array(vectors, dtypenp.float32)为什么选本地模型优先一方面是不想绑死一个厂商另一方面是很多用户的业务数据不想出内网。nomic-embed-text这个模型在中文语义上的表现虽然不如大参数云模型但胜在免费、可离线、隐私安全。如果你想换云厂商的 embedding 服务只要它兼容 OpenAI 的/embeddings接口改一个配置项就行不需要动代码。持久化层我用 SQLite 存元数据URL、标题、块文本、hash、时间戳用.npy文件存向量矩阵。每次新增索引加载矩阵算出新向量拼上去再落盘。这个方案听起来原始但胜在逻辑极其透明你打开 SQLite 就能查到每一行到底存了什么。我甚至写了一个很小的 PeerIndex 查看命令可以随便查一个 chunk 对应哪个 URL、哪一段原文。3.3 把服务跑起来FastAPI 端点与请求示例服务层没有做花哨的东西就三个核心端点app.post(/index/url) def index_url(payload: IndexRequest): result pipeline.index_single(payload.url) return {status: ok, chunks: len(result[chunks])} app.post(/index/batch) def index_batch(payload: BatchRequest): results [] for url in payload.urls: try: results.append({url: url, status: ok}) except Exception as e: results.append({url: url, status: error, message: str(e)}) return {results: results} app.post(/search) def search(payload: SearchRequest): hits pipeline.search(payload.query, top_kpayload.top_k or 5) return {hits: hits}启动命令是uvicorn pageindex.server:app --port 8000然后你就可以用一个简单的requests.post完成索引和查询。这里有一个容易被忽略的点/index/batch端点我故意设计成逐条返回状态而不是某个链接失败就整个批次报错。网页抓取是最不稳定的环节一个超时、一个 403都不应该中断其他页面的索引。3.4 参数经验值为什么这么设很多工具写出来用户第一个问题就是“参数我调多少”。我直接给一张经验值表参数默认值设定逻辑chunk_max800 字主流 embedding 模型对 512~1024 token 的文本语义表达最好中文 800 字约等于 600~800 token落在这个区间chunk_overlap100 字防止边界信息被裁掉又不至于造成大量冗余embedding_batch16 条批量请求向量化接口兼顾速度与内存占用fetch_timeout15 秒超过 15 秒的页面大概率是慢接口或反爬不值得等min_text_len200 字正文太短的页面直接跳过大概率是空壳或验证页这个表不是拍脑袋来的。我在做项目的时候跑过一组对比chunk 大小从 300 到 1500 字检索命中率和回答完整度呈现一个倒 U 形500~900 字是甜区。overlap 的大小对检索结果影响没那么大但 100 字左右能保证“标题下一段开头”不丢失关键信息。4. 把 URL 列表变成可查询索引CLI 批处理与效果实测4.1 命令行设计JSON 配置驱动服务端适合单条或小批量索引但你要一次性索引二十个 URL 的时候还是命令行更方便。PageIndex 的 CLI 用 JSON 配置驱动你只需要维护一份配置{ urls: [ https://example.com/docs/start, https://example.com/docs/faq, https://example.com/blog/2025/01/01/roadmap ], embedding: { provider: local, model: nomic-embed-text }, index_dir: ./data }然后执行pageindex index --config my_sites.jsonCLI 会逐条抓取、清洗、分块、向量化、入库并把进度打印到终端。4.2 一次真实的索引过程下面是我跑一个技术博客列表时的真实输出我做了脱敏处理你可以感受一下整个流程的节奏[12:00:01] 开始索引任务: 3 个 URL [12:00:01] 抓取: https://example.com/docs/start [12:00:02] 静态抓取成功正文长度 3,248 字符 [12:00:02] 分块: 共 6 块平均块长 541 字符 [12:00:02] 向量化: 6 条耗时 420ms [12:00:03] 写入索引: 当前总量 34 chunks [12:00:03] 抓取: https://example.com/docs/faq [12:00:04] 静态抓取成功正文长度 5,102 字符 [12:00:04] 分块: 共 8 块平均块长 637 字符 [12:00:05] 向量化: 8 条耗时 510ms [12:00:05] 写入索引: 当前总量 42 chunks [12:00:07] 抓取: https://example.com/blog/2025/01/01/roadmap [12:00:07] 静态抓取正文为空升级到 Playwright 渲染 [12:00:11] 渲染成功正文长度 8,930 字符 [12:00:12] 分块: 共 11 块平均块长 812 字符 [12:00:12] 向量化: 11 条耗时 720ms [12:00:13] 写入索引: 当前总量 53 chunks [12:00:13] 任务完成共用时 12 秒第三个 URL 就是典型的动态渲染页面静态抓取拿不到内容自动升级到 Playwright 之后就正常了。如果我不做这个兜底这一页就会被静默跳过而用户完全不知道知识库里缺了一块。4.3 检索效果实测语义召回与关键片段命中为了验证效果我用一个包含 20 个页面的小站点做测试对比了三种方案固定 500 字符切块、LangChain 的 recursive 切分器、以及 PageIndex 的结构感知切分。每个方案各问 10 个问题记录 top5 里是否包含真正相关的片段查询示例固定切块Recursive 切块PageIndex 结构切块满减活动的起止时间3/104/109/10退款是否支持拆封商品4/106/1010/10支持的支付方式有哪些5/106/109/10新用户优惠码怎么领取2/104/108/10这里 PageIndex 的优势主要来自两点一是分块时把“标题内容”作为一个整体摘要和结论容易被同时召回二是对列表和表格的保留让“支付方式”“优惠码”这种条目型信息不会在切割时被拦腰截断。4.4 索引增量更新与去重策略网页会变索引不能一次建完就放着不管。PageIndex 给每个页面内容算了一个 hash索引写入之前先比对库里的content_hash没有变化就跳过有变化就整页重新分块入库。new_hash sha256(page_text.encode(utf-8)).hexdigest() old_hash get_page_hash(url) if old_hash and old_hash new_hash: return {url: url, status: skipped, reason: content_unchanged}然后你可以用一个简单的定时任务每天跑一遍pageindex index --config my_sites.json只有发生变更的页面才会产生新的 embedding 请求成本很低。对于高频更新的页面你还可以在配置里给 URL 加refresh_interval字段做差异化同步。5. 我踩过的坑与后续扩展思路工具写完只是第一步真正让它在环境里稳定跑起来靠的都是踩坑之后的修补。我挑了四个最典型的坑应该能帮你少走不少弯路。5.1 坑一robots.txt 不是摆设风控和频率都要考虑我第一次批量抓取二十个页面时没有做任何频率控制结果有一个页面返回了 429。当时的第一反应是“目标网站反爬太严”后来仔细一想是我自己在短时间内发起了一连串请求换位思考一下任何一个站长都会把它当成攻击行为。现在的 PageIndex 在批处理时会自动读取目标域名的robots.txt把不允许抓取的路径过滤掉同时给同一个域名的请求之间加一个随机延迟默认 0.5~1.5 秒。这样既尊重对方规则也减少自己被封的概率。这里我想多说一句做网页索引并不是当爬虫控制频率和尊重 robots 协议本质上是在保护自己的 IP 和项目的可持续性。哪怕你是内部工具也应该默认带上一套“有礼貌”的抓取策略。还有一个细节不同网站对“静态页面”和“动态页面”的容忍度不一样。有些站点同一套 URL静态请求就 200Playwright 请求就跳验证码这时候我反而建议用静态方案先跑通正文不全再单独评估要不要继续补。5.2 坑二网页结构变了索引不会自动失效网页索引有一个很隐蔽的问题如果你的上游页面改版了比如 URL 没变但内容从“帮助中心”改成了“产品公告”而同步任务因为 hash 相等直接跳过那你的知识库里就是一份过期内容。这是我实际遇到过的某个活动页上线后内容换过一轮但页面上有动态时间戳导致每次抓取的content_hash都在变我还以为是在正常更新。后来我把 hash 的计算范围从“整个页面文本”缩小到“正文文本”并且在 hash 之外增加一个updated_at字段。每次抓取时先看正文长度是否有剧烈抖动超出偏差就标记为“疑似改版”需要人工确认。这个保守策略牺牲了一点点自动化率但换来了知识库内容可信度。说白了索引工具的最终目标不是省事而是不让错误信息流进答案里去。5.3 坑三embedding 的维度诅咒与存储膨胀本地模型nomic-embed-text的向量维度是 768 维text-embedding-3-small是 1536 维。看起来不大但如果你索引一万个 chunk每个 chunk 平均 800 字向量文件本身也就是几十 MB问题不大。真正膨胀的是元数据表每个 chunk 都存了一份原文、路径、URL、标题、各种时间戳加上索引字段大小很快超过向量文件本身。我一开始把整块原始文本都塞进了 SQLite 的同一个表里查询的时候虽然只取前五条但数据库每次都要扫描那一大坨文本字段速度越来越慢。后来我把元数据拆成两张表一张存chunk_id, url, title, timestamp另一张存chunk_id, content检索时才联表取内容。这个改动让一万 chunk 规模的查询耗时直接从数百毫秒降到了几十毫秒。对这种小规模应用存储优化不是省硬盘而是在省查询延迟。另外向量矩阵我落地成float32而不是float64文件的体积直接砍半。内存里加载的时候也只用float32别小看这个改变几万个向量累加起来差距非常可观。5.4 后续可以扩展的方向这个项目现在的定位是“轻量、透明、够用”但它的接口已经为将来留好了口子。我计划在下面几个方向继续迭代把store.py的搜索引擎抽象成协议增加 Qdrant 后端让数据量超过十万条时能平滑迁移。增加“定时巡检 变更通知”模式让索引服务主动监控一批页面的更新时间而不是靠外部 cron 驱动。在查询端加 rerank 环节用一个轻量交叉编码器对召回结果重排进一步提升 top1 的准确率。支持站点地图批量导入你只需要给一个sitemap.xml就能自动把整个站点接进来。这个功能对文档站特别实用。不过说实话这些扩展我不会急着全做。工具最怕的就是功能膨胀到不可维护。现在的 PageIndex 保持在一个“我能清楚说出每一行代码在干什么”的状态这才是它最初的价值所在。我个人在实际使用中最深的感觉是网页索引这个领域难点从来不在于某个单独的技术而在于把抓取、清洗、分块、向量化、存储、查询这些环节拼成一个稳定的整体并且让每一步都可观察、可调试。PageIndex 现在的体量刚好能做到这一点。如果你也在搭 RAG 应用或者做知识库不妨从这套流程里挑几个思路先用最小的成本把你的 URL 列表变成能检索的东西再考虑要不要引入更重的组件。真到那时候你对每一层的问题已经有感觉了就不会再被“向量数据库能解决一切”这种说法带偏。