
1. 项目概述从网页到知识库的自动化构建最近在折腾一个AI应用核心需求是让大语言模型LLM能够“读懂”并回答关于特定网页内容的问题。这听起来像是RAG检索增强生成的典型场景但第一步——如何把一篇结构复杂的网页文章变成AI能高效检索和理解的“知识块”——就成了必须解决的工程问题。我选择了Node.js生态下的Cheerio库来完成网页内容的抓取和解析然后通过一套文本处理流水线将文章切割、向量化最终存入向量数据库。整个过程本质上是在为AI构建一个精准、高效的“外部记忆”。这个项目的价值在于它跳过了手动整理资料的繁琐实现了从原始网页到结构化知识库的自动化。无论是技术文档、博客文章还是新闻页面你都可以通过这个流程快速将其转化为一个可供AI查询的知识源。这对于构建垂直领域的问答机器人、智能客服助手或者个人知识管理工具都是一个非常实用的基础模块。接下来我就把这次实战中的核心思路、技术选型、踩过的坑以及优化心得完整地分享出来。2. 技术栈选型与核心思路拆解面对“让AI读网页”这个目标我们需要拆解出几个关键步骤获取网页、提取正文、处理文本、向量化存储、最后是检索查询。每个环节的技术选型都直接影响到最终系统的效果和效率。2.1 为什么是Cheerio轻量级DOM操作的胜利在Node.js环境下爬取和解析网页的库有很多比如Puppeteer、Playwright功能强大但重量级或node-fetch搭配正则表达式轻量但脆弱。我选择Cheerio核心原因在于它完美契合了本次任务的需求我们只需要静态内容。掘金、知乎、博客园这类技术社区的文章页面其核心正文内容在页面初次加载Server-Side Rendering或静态生成时就已经存在于HTML中了。这意味着我们不需要等待JavaScript执行、不需要模拟点击、不需要渲染动态生成的内容。Cheerio的工作原理类似于服务器端的jQuery它加载HTML字符串后提供了一个非常熟悉的API如$(‘selector’)来查询和操作DOM节点。相比于Puppeteer等无头浏览器方案Cheerio的资源消耗极低、速度极快因为它完全绕过了浏览器渲染引擎这个庞然大物。具体到掘金文章通过浏览器开发者工具分析可以发现文章正文通常包裹在一个具有特定class如.article-content的div中。使用Cheerio我们几乎可以用一行代码精准提取const $ cheerio.load(htmlString); const articleContent $(‘.article-content’).text(); // 提取纯文本这种基于CSS选择器的精准定位是正则表达式难以稳定维护的。正则表达式在处理嵌套的HTML标签、多变的class名时非常容易出错而Cheerio则能稳健地处理各种DOM结构。注意Cheerio的局限性也很明显。如果目标网页的内容严重依赖JavaScript异步加载例如单页面应用SPACheerio抓取到的HTML可能只是一个空壳。在这种情况下Puppeteer或Playwright才是更合适的选择。因此在项目开始前务必先手动检查目标网页的源代码确认所需内容是否存在于初始HTML中。2.2 向量库与EmbeddingAI理解文本的基石提取出纯文本只是第一步如何让计算机尤其是LLM理解这些文本并快速找到相关内容这就需要用到向量库Vector Database和文本嵌入Embedding技术。Embedding向量化你可以把它想象成一种“翻译”。它将一段文字一个词、一句话或一篇文章转换成一个固定长度的、高维度的数值向量比如一个由1536个浮点数组成的数组。这个向量的神奇之处在于语义相近的文本其对应的向量在数学空间中的距离通常用余弦相似度衡量也会很近。例如“如何学习编程”和“编程入门指南”这两个句子的向量就会非常接近。向量库顾名思义就是专门用于存储和检索这些向量数据的数据库。它核心的能力是近似最近邻搜索ANN Search。当用户提出一个问题比如“RAG如何分块”我们将问题也转化为向量然后向量库能从上百万个存储的文档向量中快速找出与之最相似的几个。传统的关系型数据库如MySQL进行这种高维向量的相似度计算是极其缓慢且不专业的。我这次选用了Chroma一个轻量级、易上手且功能强大的开源向量数据库。它既可以作为内存数据库快速原型验证也可以持久化到磁盘。它的Python/JavaScript API设计得非常友好与LangChain等框架集成度也很高非常适合快速搭建RAG系统。// 使用Chroma的简单示例 import { Chroma } from ‘chromadb’; const client new Chroma(); const collection await client.createCollection({ name: “juejin_articles” }); // ... 后续将文档向量存入collection其他常见的向量库还有Pinecone云服务省心、Weaviate功能全面自带GraphQL、QdrantRust编写性能优异等。选择Chroma主要是看中其开发体验和足够应对中小规模数据的需求。2.3 RAG流程全景与本次项目定位RAG的全流程通常包含以下步骤索引构建Indexing从源网页、PDF、Word等加载文档 - 将文档分割成更小的“块”Chunk - 将每个块通过Embedding模型转化为向量 - 将向量和块的元数据存入向量库。检索与生成Retrieval Generation用户提问 - 将问题通过同样的Embedding模型转化为向量 - 在向量库中检索出最相关的K个文本块 - 将这些文本块作为“参考依据”和原始问题一起提交给LLM - LLM生成基于这些参考依据的答案。我这次的项目核心聚焦在索引构建的前半段即如何高质量地完成“从网页到向量库”的管道。这是整个RAG系统的基石这部分数据的质量内容是否干净、分块是否合理直接决定了后续检索和生成答案的准确性。很多RAG效果不佳的问题根源往往就出在这里。3. 核心实现从网页抓取到向量存储的完整流水线下面我将以爬取一篇掘金前端技术文章为例拆解每一步的具体实现和关键考量。3.1 步骤一使用Cheerio精准抓取与内容清洗首先我们需要获取网页的原始HTML。这里使用axios或node-fetch进行网络请求。为了更稳定建议设置合理的超时时间和User-Agent模拟浏览器访问。import cheerio from ‘cheerio’; import axios from ‘axios’; async function fetchArticle(url) { try { const response await axios.get(url, { headers: { ‘User-Agent’: ‘Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36’ // 模拟浏览器 }, timeout: 10000 // 10秒超时 }); const html response.data; const $ cheerio.load(html); // 1. 提取标题 - 通常位于title或特定的h1标签 const title $(‘title’).text().trim() || $(‘h1’).first().text().trim(); // 2. 精准定位正文容器 - 需要手动分析目标网站结构 // 以掘金为例正文可能在 ‘article-content’, ‘post-content’, ‘.markdown-body’ 等class中 let content ‘’; const possibleSelectors [‘.article-content’, ‘.post-content’, ‘.markdown-body’, ‘main article’]; for (const selector of possibleSelectors) { if ($(selector).length 0) { content $(selector).html(); // 保留HTML结构便于后续处理代码块等 break; } } if (!content) { // 备用方案提取所有p标签文本但效果较差 content $(‘body’).find(‘p’).text(); } return { title, content, rawHtml: html }; } catch (error) { console.error(抓取网页失败: ${url}, error.message); return null; } }实操心得直接使用.text()方法会丢失所有格式这对于技术文章是灾难性的因为代码块、内联代码、加粗强调等信息都非常重要。更好的做法是先提取包含HTML标签的正文容器然后进行有选择的清洗。我们可以用Cheerio移除导航栏、侧边栏、广告、页脚等无关元素的标签只保留正文相关的标签如p,h1-h6,pre,code,ul,ol,li。这样能在保留必要语义结构的同时去除噪音。3.2 步骤二文本分割Text Splitting的艺术与科学拿到清洗后的HTML文本后下一个关键决策是如何把它切成小块Chunk这是RAG项目中最容易被低估也最容易出问题的环节。为什么不能直接把整篇文章存成一个向量精度问题LLM的上下文长度有限如4096、8192 tokens。当用户问一个具体细节时整篇文章的向量是一个“平均化”的表示很难精准定位到相关段落。召回问题检索时问题向量与整篇文章向量匹配可能因为文章主题宽泛而匹配成功但实际答案可能只在某一段落中导致检索不准。信息稀释长文本中包含了多个主题向量表征会变得模糊降低检索相关性。因此我们必须进行分块。但分块并非简单的“每200个字切一刀”。1. 分块策略的核心考量块大小Chunk Size这是最重要的参数。太小如50字可能割裂完整的语义单元如一个完整的步骤描述太大如1000字又回到了精度不高的问题。一个常见的起点是256到512个tokens约等于200-400汉字。这个大小通常能容纳一个完整的自然段或一个小节的内容。块重叠Chunk Overlap为了避免一个完整的句子或概念被生硬地切分在两块之间导致检索时上下文缺失相邻的块之间需要设置一个重叠区域。通常重叠大小设置为块大小的10%-20%。例如块大小为500字符重叠可以设为50-100字符。分割符Separators基于字符分割如\n\n是最简单但最笨的方法。更优的方法是按照语义边界进行分割例如按照段落\n\n、标题h2、Markdown的二级标题##或者句子结束符.!?进行分割。这能最大程度保证每个块的语义完整性。2. 利用LangChain的TextSplitter手动实现一个鲁棒的语义分割器并不容易。我强烈推荐使用LangChain提供的RecursiveCharacterTextSplitter。它内置了智能的分割逻辑import { RecursiveCharacterTextSplitter } from ‘langchain/text_splitter’; // 将清洗后的HTML转换为纯文本但保留段落信息 const plainText convertHtmlToPlainTextPreservingLines(cleanedHtml); const splitter new RecursiveCharacterTextSplitter({ chunkSize: 500, // 目标块大小字符数 chunkOverlap: 50, // 块间重叠字符数 separators: [‘\n\n’, ‘\n’, ‘。’, ‘.’, ‘ ‘, ‘’], // 分割符优先级列表 }); const chunks await splitter.splitText(plainText); console.log(文章被分割成 ${chunks.length} 个块。);它会优先用\n\n空行通常代表段落分隔来分割如果分割后的块还是太大就降级使用\n依此类推。这比固定长度切割要智能得多。踩坑记录对于技术文章要特别处理代码块。如果代码块被任意分割其向量表示将毫无意义。一种改进方案是在分割前先用正则表达式或Cheerio将precode…/code/pre标签内的内容提取出来替换为一个特殊占位符如[CODE_BLOCK_1]。待文本分割完成后再将代码块内容作为元数据metadata附加到对应的文本块中或者在后续嵌入时进行特殊处理。3.3 步骤三生成嵌入向量并存入Chroma文本块准备好后就需要将它们转化为向量。我们需要一个Embedding模型。对于中文场景OpenAI的text-embedding-3-small或text-embedding-ada-002是省心且效果不错的选择。国内也有诸如通义千问、智谱AI、百度文心等提供的Embedding API。如果想本地部署可以选用BGE-M3、text2vec等开源模型。这里以使用OpenAI API为例需准备API Keyimport { OpenAIEmbeddings } from ‘langchain/openai’; import { Chroma } from ‘chromadb’; // 1. 初始化Embedding模型 const embeddings new OpenAIEmbeddings({ openAIApiKey: ‘your-api-key’, modelName: ‘text-embedding-3-small’, // 指定模型 }); // 2. 初始化Chroma客户端并创建集合Collection const chromaClient new Chroma({ path: ‘./chroma_db’ }); // 数据持久化到本地目录 const collection await chromaClient.createCollection({ name: “juejin_articles_collection”, embeddingFunction: async (texts) { // 这里需要将Chroma的调用适配到LangChain的Embeddings接口 // 实际中可以使用LangChain的Chroma集成更简单 // import { Chroma } from “langchain/community/vectorstores/chroma”; }, }); // 3. 使用LangChain的Chroma集成简化流程 import { Chroma } from “langchain/community/vectorstores/chroma”; const vectorStore await Chroma.fromTexts( chunks, // 文本块数组 chunks.map((_, idx) ({ chunk_id: idx, source: articleTitle })), // 元数据数组 embeddings, // Embedding模型 { collectionName: “juejin_articles”, url: “http://localhost:8000”, // Chroma服务器地址如果本地运行 } ); console.log(“向量数据已成功存入Chroma数据库。”);在这个过程中chunks数组中的每一段文本都会通过Embedding模型变成一个向量然后连同这段文本本身以及我们附加的metadata如块ID、来源文章标题、原始URL等一起被存储到Chroma指定的集合中。元数据Metadata的重要性不要小看这些附加信息。在后续检索时我们不仅能拿到相似的文本块还能拿到它的出处。这对于构建可信的AI回答例如显示“该信息来源于XXX文章”至关重要。你还可以添加段落序号、文章分类等信息便于更精细的过滤和检索。4. 效果评估、常见问题与优化策略完成基础管道后我们如何知道它工作得好不好一个简单的方法是进行检索测试提出几个文章内明确涉及的问题看系统返回的文本块是否精准包含答案。4.1 常见问题与排查清单问题现象可能原因排查与解决方案检索到的内容与问题完全不相关1. Embedding模型不匹配如用英文模型处理中文。2. 文本块污染严重包含大量导航文本、广告。3. 分块完全破坏了语义。1. 确认使用支持多语言或针对中文优化的Embedding模型。2. 加强Cheerio清洗步骤用更精准的选择器移除无关元素。3. 调整分块策略尝试更小的chunkSize或启用chunkOverlap使用RecursiveCharacterTextSplitter。检索到的内容相关但找不到具体答案细节块大小Chunk Size设置过大。逐步减小chunkSize例如从500调到300观察检索精度是否提升。答案上下文断裂不完整块重叠Chunk Overlap设置过小或分割符不合理导致句子或概念被切断。增加chunkOverlap例如从50调到100。检查分割符列表确保包含了中文句号。。代码相关的查询永远检索不到分割时未特殊处理代码块导致代码被分割且向量化后失去意义。实现预处理逻辑将代码块整体保留或作为元数据附加。检索速度很慢1. 向量库未建立索引或索引类型不合适。2. 检索的K值返回数量设置过大。1. 查阅向量库文档确认是否支持并已创建HNSW等近似索引。2. 根据需求合理设置K值通常3-5个块足够。4.2 进阶优化策略分层索引与混合检索对于长文章可以建立两级索引。第一级是“小节”级的大块如按h2标题分割用于快速定位相关章节第二级是“段落”级的小块如500字符用于精确定位答案。检索时可以先查大块再在大块内部查小块或者将两者的结果融合混合检索。重排序Re-ranking向量检索是“粗排”它可能返回前K个相似度最高的块。但相似度最高不一定代表最能回答问题。可以引入一个轻量级的、专门做文本相关性判别的重排序模型如BGE-Reranker对粗排结果进行重新打分和排序将最可能包含答案的块排到最前面再送给LLM。这能显著提升最终答案的质量。元数据过滤在检索时除了向量相似度还可以结合元数据进行过滤。例如用户可以指定“只检索来自某位作者的文章”或“只检索2023年之后的文章”。Chroma等向量库都支持在检索时添加元数据过滤条件。多路召回不要只依赖向量检索。可以同时使用关键词检索如BM25算法。向量检索擅长语义匹配“编程”匹配到“coding”关键词检索擅长精确字面匹配。将两者的结果融合能覆盖更全面的召回需求。5. 从项目到产品工程化与扩展思考完成一个单篇文章的入库demo只是起点。要将其产品化还需要考虑更多工程问题。1. 管道自动化与调度你需要一个任务调度系统如Apache Airflow, Prefect来定期爬取目标网站的新文章自动执行清洗、分割、嵌入、入库的全流程。同时要处理好去重问题避免同一篇文章被重复入库。2. 错误处理与监控网络请求可能失败Embedding API可能有速率限制向量库可能连接超时。管道中每一步都需要健壮的错误处理重试、降级、告警和日志记录方便问题追踪。3. 嵌入模型的选择与成本如果数据量很大使用OpenAI等付费API的Embedding成本会迅速增加。需要评估效果与成本的平衡。对于中文场景可以测试BGE-M3、text2vec-base-chinese等开源模型它们可以在本地部署虽然效果可能略逊于顶级商用模型但对于很多场景已经足够且成本极低。4. 数据更新与删除知识不是静态的。当源文章被修改或删除时你的向量库如何同步更新这需要设计一个版本管理或增量更新机制。Chroma支持按ID更新或删除文档。我个人在实践中的体会是构建RAG系统初期最容易犯的错误就是过于关注LLM选型和Prompt工程而忽视了数据准备这个“脏活累活”。事实上一个经过精心清洗、合理分块、准确嵌入的知识库即使用一个普通的LLM也能产生可靠的回答。反之如果喂给LLM的是杂乱无章、支离破碎的上下文再强大的模型也无力回天。这个用Cheerio搭建的网页处理流水线正是夯实数据地基的关键一步。它也许不酷炫但绝对扎实、有效是任何希望构建可靠AI知识应用开发者必须掌握的技能。