基于Playwright与向量化技术构建AI知识库:从网页采集到RAG应用实战
1. 项目概述从零构建一个“会思考”的AI知识库最近在折腾一个挺有意思的项目想给我的团队搞一个垂直领域的AI知识库。这玩意儿不是简单的文档堆砌而是希望它能“理解”我们行业内的专业内容比如最新的技术博客、竞品动态、行业报告然后能像专家一样回答我们的问题。想法很美好但第一步就卡住了——数据从哪来总不能靠人工一篇篇去复制粘贴吧那效率太低了而且很多信息源是动态更新的网页。于是我盯上了网页内容自动化采集。市面上工具很多从简单的requestsBeautifulSoup到无头浏览器Puppeteer、Selenium。经过一番折腾我最终选择了Playwright。理由很简单它够新、够快、够稳。微软出品原生支持异步对现代单页应用SPA的渲染支持近乎完美而且跨浏览器Chromium, Firefox, WebKit的特性让它在不同场景下的兼容性更有保障。最关键的是它的API设计非常人性化写起采集脚本来有种行云流水的感觉。这个项目就是围绕Playwright打造一套从网页内容抓取、清洗、结构化到最终灌入向量数据库为大模型提供“养料”的完整流水线。它解决的不仅仅是“把网页存下来”的问题更是如何高效、准确、自动化地为AI知识库准备高质量数据源的问题。无论你是想构建个人学习笔记库、企业内部的FAQ系统还是垂直领域的智能问答助手这套实战经验都能给你提供一条清晰的路径。2. 核心思路与架构设计为什么是Playwright向量化构建AI知识库尤其是面向大模型RAG检索增强生成的知识库核心矛盾在于如何将海量、非结构化的网页信息转化为大模型能够高效“消化”和“回忆”的结构化知识片段。整个过程可以拆解为四个核心环节采集 - 解析 - 处理 - 存储与检索。我的技术选型正是围绕这四个环节展开的。2.1 采集层Playwright的压倒性优势为什么不用更轻量的requests因为现代网页太“狡猾”了。大量内容通过JavaScript动态加载简单的HTTP请求只能拿到一个空壳HTML。Selenium虽然老牌但启动慢、资源占用高在需要高并发采集时显得笨重。Playwright的优势在这里凸显无头浏览器驱动能完整执行页面JavaScript获取渲染后的最终DOM对付React、Vue等框架构建的SPA页面毫无压力。自动等待机制内置智能等待可以等待元素出现、网络请求完成或页面加载完毕大大减少了编写复杂等待逻辑的代码量提升了脚本的稳定性。强大的选择器支持CSS、XPath、Text等多种定位方式甚至可以通过get_by_role、get_by_label进行可访问性定位编写采集规则更精准。网络拦截与模拟可以拦截和修改网络请求这对于处理反爬机制如验证码图片请求或直接抓取接口数据XHR/Fetch提供了可能。并发与上下文隔离通过创建多个Browser Context可以在一次浏览器实例中模拟多个完全独立的会话高效且节省资源。注意虽然Playwright功能强大但它毕竟启动了完整的浏览器环境资源消耗内存、CPU远高于requests。因此在目标网页是纯静态或服务端渲染SSR的情况下优先考虑requestsparsel/BeautifulSoup的组合效率会高出一个数量级。我的原则是能静态解析的绝不启动浏览器。2.2 处理与存储层从HTML到向量采集到的原始HTML是“脏数据”包含导航栏、广告、页脚等噪音。直接扔给大模型效果会大打折扣。因此解析与清洗至关重要。我使用BeautifulSoup或lxml进行HTML解析结合自定义规则如根据CSS类名、标签结构提取正文内容。更关键的一步是文本分割Text Splitting。一篇长文章直接嵌入成一个大向量检索精度会很低。我们需要将其切分成有语义关联的片段Chunks。这里我采用了递归字符分割与语义分割相结合的策略递归字符分割按固定长度如500字符分割并设置一段重叠区如50字符防止句子被生生切断。语义分割利用langchain的RecursiveCharacterTextSplitter或sentence-transformers的语义模型尝试在句子的自然边界处进行分割保证chunk的语义完整性。处理后的文本片段通过嵌入模型Embedding Model转化为高维向量。我推荐使用开源模型如bge-large-zh-v1.5中文或all-MiniLM-L6-v2英文它们效果不错且可以在本地部署。这些向量最终存入向量数据库如Chroma、Milvus或Qdrant。向量数据库的核心能力是近似最近邻搜索ANN当用户提问时将问题也转化为向量并快速从库中找出最相关的几个文本片段作为上下文提供给大模型生成答案。整个架构的流程图如下[目标URL列表] - [Playwright 采集器] - [原始HTML/截图] - [解析与清洗模块] - [纯净文本] - [文本分割器] - [文本片段(Chunks)] - [嵌入模型] - [向量] - [向量数据库] ↑ [用户提问] - [嵌入模型] - [查询向量] - [ANN检索] - [相关片段] - [大模型] - [答案]3. Playwright采集实战从安装到编写健壮爬虫理论说再多不如一行代码。我们直接进入实战环节。3.1 环境搭建与初始化首先安装Playwright。建议使用Python版本。pip install playwright # 安装Playwright自带的浏览器Chromium, Firefox, WebKit playwright install chromium我通常只安装Chromium因为它在性能和兼容性上最平衡。如果需要测试页面在不同浏览器下的表现可以再安装其他的。一个最基本的采集脚本骨架如下import asyncio from playwright.async_api import async_playwright async def main(): async with async_playwright() as p: # 启动浏览器headlessFalse可以看到浏览器界面调试时非常有用 browser await p.chromium.launch(headlessFalse, slow_mo50) # slow_mo 让动作慢下来方便观察 # 创建一个浏览器上下文可以模拟独立的会话cookies, localStorage隔离 context await browser.new_context( viewport{width: 1920, height: 1080}, user_agentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ... ) page await context.new_page() try: # 导航到目标页面 await page.goto(https://example.com/article/123, wait_untilnetworkidle) # 等待到网络空闲 # 在这里进行内容提取操作... # ... except Exception as e: print(f抓取页面失败: {e}) finally: await browser.close() asyncio.run(main())3.2 核心内容提取策略与代码示例提取内容的核心在于定位元素。Playwright提供了多种方式。场景一提取特定标题和正文假设我们要抓取一篇博客文章。# 等待文章标题区域加载 await page.wait_for_selector(article h1, statevisible) # 提取标题 title await page.text_content(article h1) # 提取正文 - 假设正文在 article 标签下的所有 p 标签里 # 使用 page.query_selector_all 获取元素列表 paragraph_elements await page.query_selector_all(article p) # 将每个段落的文本内容合并 body_text \n.join([await el.text_content() for el in paragraph_elements if await el.text_content()]) print(f标题: {title}) print(f正文: {body_text[:200]}...) # 打印前200字符场景二处理分页或“加载更多”很多网站采用分页或滚动加载。# 方法1点击“下一页”按钮直到没有下一页 has_next_page True all_articles [] while has_next_page: # 提取当前页所有文章链接 article_links await page.query_selector_all(.article-list a.title) for link in article_links: href await link.get_attribute(href) all_articles.append(href) # 尝试找到并点击“下一页”按钮 next_button await page.query_selector(a.next-page) if next_button: await next_button.click() # 等待新内容加载通常可以等待某个新出现的元素或网络请求 await page.wait_for_load_state(networkidle) # 或者等待列表区域更新 # await page.wait_for_selector(.article-list a.title:last-child) else: has_next_page False # 方法2模拟滚动触发加载针对无限滚动页面 import time previous_height await page.evaluate(document.body.scrollHeight) while True: # 滚动到页面底部 await page.evaluate(window.scrollTo(0, document.body.scrollHeight)) # 等待新内容加载 await page.wait_for_timeout(2000) # 等待2秒网络请求可能更可靠 # 也可以等待某个加载动画消失 # await page.wait_for_selector(.loading-spinner, statehidden) new_height await page.evaluate(document.body.scrollHeight) if new_height previous_height: break # 高度不再变化说明已加载完毕 previous_height new_height # 滚动结束后再提取所有内容场景三应对反爬与复杂交互有些网站需要登录或有复杂验证。# 1. 处理登录以账号密码为例 await page.goto(https://example.com/login) await page.fill(input[nameusername], your_username) await page.fill(input[namepassword], your_password) # 有时需要等待一下再点击或者勾选复选框 await page.click(button[typesubmit]) # 等待登录成功后的跳转或某个登录后特有的元素出现 await page.wait_for_selector(#user-avatar, statevisible) print(登录成功) # 2. 保存登录状态Cookies避免每次采集都登录 # 登录成功后保存上下文状态 await context.storage_state(pathauth_state.json) # 下次启动时可以直接加载这个状态来恢复登录会话 context2 await browser.new_context(storage_stateauth_state.json) page2 await context2.new_page() await page2.goto(https://example.com/dashboard) # 此时应该已是登录状态 # 3. 处理弹窗或验证码简单情况 # 监听对话框alert, confirm, prompt page.on(dialog, lambda dialog: dialog.accept()) # 对于图形验证码通常需要引入第三方OCR服务或手动处理这超出了自动化范畴。 # 更优的策略是寻找无需验证码的接口或使用更专业的反爬解决方案。3.3 高级技巧网络拦截与性能优化为了提高采集效率和应对特殊场景Playwright的网络拦截功能非常有用。# 拦截并记录或修改请求/响应 async def handle_route(route): # 可以在这里修改请求头例如添加Referer或User-Agent headers route.request.headers headers[my-custom-header] my-value # 继续请求 await route.continue_(headersheaders) # 或者直接返回一个模拟的响应 # await route.fulfill(status200, bodyMocked response) await page.route(**/*, handle_route) # 拦截所有请求 # 更精确的拦截例如只拦截图片请求以节省带宽 await page.route(**/*.{png,jpg,jpeg}, lambda route: route.abort()) # 性能优化禁用不必要的资源加载 browser await p.chromium.launch( headlessTrue, args[--disable-images, --disable-javascript] # 谨慎使用可能破坏页面功能 ) # 更好的方式是通过路由选择性拦截 async def block_media(route): if route.request.resource_type in [image, media, font, stylesheet]: await route.abort() else: await route.continue_() await page.route(**/*, block_media)4. 从原始HTML到知识库Chunk数据处理流水线采集到HTML只是第一步接下来是更繁琐但至关重要的数据处理。4.1 解析与正文提取我使用BeautifulSoup进行解析因为它容错性好API友好。from bs4 import BeautifulSoup import re def extract_clean_content(html, url): 从HTML中提取纯净的正文内容。 :param html: 原始HTML字符串 :param url: 页面URL可用于特定站点规则 :return: 字典包含标题、正文、发布时间等元数据 soup BeautifulSoup(html, lxml) # 1. 提取标题 - 多种后备方案 title # 方案1: 查找 title 标签 if soup.title and soup.title.string: title soup.title.string.strip() # 方案2: 查找 Open Graph 或 Twitter 的 title 属性 og_title soup.find(meta, propertyog:title) if og_title and og_title.get(content): title og_title[content].strip() # 方案3: 查找最大的标题标签 (h1) if not title: h1 soup.find(h1) if h1: title h1.get_text().strip() # 2. 提取正文 - 核心挑战 # 简单策略移除所有 script, style, nav, footer, header 等标签 for tag in soup([script, style, nav, footer, header, aside, iframe]): tag.decompose() # 复杂策略使用 readability-lxml 或 trafilatura 等专用库 # 这里演示一个启发式方法寻找包含最多文本的连续块 body_text # 假设正文在 article 或 main 标签内 article soup.find(article) or soup.find(main) or soup.body if article: # 获取所有文本并合并多余空白 body_text .join(article.get_text().split()) # 3. 提取元数据如发布时间 publish_time None # 查找常见的发布时间标签属性 time_tag soup.find(time) if time_tag and time_tag.get(datetime): publish_time time_tag[datetime] else: # 尝试从 meta 标签中找 meta_pub soup.find(meta, propertyarticle:published_time) if meta_pub: publish_time meta_pub[content] # 4. 基础清洗 # 移除多余的空白字符、换行符 body_text re.sub(r\s, , body_text).strip() return { url: url, title: title, content: body_text, publish_time: publish_time, source: web_crawler }4.2 文本分割的艺术与策略直接将整篇文章存入向量数据库检索时很可能因为内容太泛而找不到重点。文本分割的目标是创建语义上连贯、大小适中的片段。from langchain.text_splitter import RecursiveCharacterTextSplitter # 或者使用 tiktoken 进行精确的token计数分割适用于GPT模型 def split_text_into_chunks(text, chunk_size500, chunk_overlap50): 使用递归字符分割器将长文本切分成块。 :param text: 待分割的文本 :param chunk_size: 每个块的最大字符数 :param chunk_overlap: 块之间的重叠字符数防止语义断裂 :return: 文本块列表 # 初始化分割器 text_splitter RecursiveCharacterTextSplitter( chunk_sizechunk_size, chunk_overlapchunk_overlap, length_functionlen, # 使用字符长度对于中文更合适。英文可用 tiktoken 计算token。 separators[\n\n, \n, 。, , , , , , ] # 中文分隔符优先级 ) chunks text_splitter.split_text(text) return chunks # 实际应用 cleaned_data extract_clean_content(html, url) if cleaned_data[content]: text_chunks split_text_into_chunks(cleaned_data[content]) for i, chunk in enumerate(text_chunks): print(fChunk {i1} (长度: {len(chunk)}): {chunk[:100]}...)分割参数的心得chunk_size不宜过大或过小。太小如100会丢失上下文太大如2000会降低检索精度。对于通用问答500-1000字符是个不错的起点。这大致对应GPT等模型上下文窗口的一小部分能容纳一个完整的观点或事实。chunk_overlap至关重要。设置为chunk_size的10%-20%。这能确保一个句子或关键概念如果恰好在边界处不会完全丢失相邻的chunk会包含它保证了上下文的连续性。分隔符separators定义了分割的优先级。上面的列表表示先按双换行分再按单换行再按句号...以此类推。对于中文将句号、问号等标点加入分隔符列表非常重要。4.3 元数据关联让Chunk“有据可查”每个文本块Chunk不能孤立存在必须携带来源信息这样当大模型引用时我们可以追溯到原文。def create_chunks_with_metadata(full_data, text_chunks): 为每个文本块附加元数据。 :param full_data: 从 extract_clean_content 返回的字典 :param text_chunks: 分割后的文本块列表 :return: 包含元数据的块字典列表 chunks_with_meta [] for idx, chunk_text in enumerate(text_chunks): chunk_data { id: f{full_data[url]}#chunk_{idx}, # 唯一ID text: chunk_text, metadata: { source: full_data[url], title: full_data[title], chunk_index: idx, publish_time: full_data.get(publish_time), total_chunks: len(text_chunks) } } chunks_with_meta.append(chunk_data) return chunks_with_meta这样每个chunk都包含了原文的URL、标题、发布时间以及它在原文中的位置。在后续的RAG流程中这些元数据可以随答案一起返回给用户增加可信度。5. 向量化与入库构建可检索的知识核心处理好的文本块需要转化为向量并存入专门的数据库。5.1 嵌入模型选择与本地部署对于中文场景我强烈推荐BAAI/bge-large-zh-v1.5模型。它在中文语义相似度任务上表现优异且完全开源。# 使用 sentence-transformers 库 from sentence_transformers import SentenceTransformer import torch # 指定模型名称会自动下载首次 model_name BAAI/bge-large-zh-v1.5 # 如果你有GPU device cuda if torch.cuda.is_available() else cpu print(f正在加载嵌入模型: {model_name} 设备: {device}) embedding_model SentenceTransformer(model_name, devicedevice) # 将文本列表转换为向量 texts [这是第一个文本块, 这是第二个文本块] embeddings embedding_model.encode(texts, normalize_embeddingsTrue) # 归一化便于余弦相似度计算 print(f向量维度: {embeddings.shape}) # 例如 (2, 1024)实操心得normalize_embeddingsTrue非常重要。它将向量归一化为单位长度这样后续计算余弦相似度就简化为点积运算速度更快且余弦相似度是衡量语义相似度的更佳指标。5.2 向量数据库选型与操作以Chroma为例Chroma是一个轻量级、易用的向量数据库特别适合原型开发和中小规模项目。import chromadb from chromadb.config import Settings # 1. 初始化客户端和集合Collection # 持久化模式 client chromadb.PersistentClient(path./chroma_db) # 数据将保存在本地目录 # 或者使用内存模式重启后数据丢失 # client chromadb.Client() # 创建一个集合类似于数据库中的表 collection client.get_or_create_collection( namemy_knowledge_base, metadata{hnsw:space: cosine} # 使用余弦相似度作为距离度量 ) # 2. 准备要添加的数据 chunks_data create_chunks_with_metadata(full_data, text_chunks) # 接上一节的数据 ids [] documents [] metadatas [] embeddings_list [] for chunk in chunks_data: ids.append(chunk[id]) documents.append(chunk[text]) metadatas.append(chunk[metadata]) # 注意这里我们提前计算好嵌入向量。Chroma也支持传入模型自动计算但自己控制更灵活。 embedding embedding_model.encode([chunk[text]], normalize_embeddingsTrue)[0].tolist() embeddings_list.append(embedding) # 3. 批量添加到集合 if ids: collection.add( idsids, documentsdocuments, # 原始文本 metadatasmetadatas, embeddingsembeddings_list # 预计算的向量 ) print(f成功添加 {len(ids)} 个文本块到知识库。) # 4. 查询找到与问题最相关的片段 query 如何安装Playwright query_embedding embedding_model.encode([query], normalize_embeddingsTrue)[0].tolist() results collection.query( query_embeddings[query_embedding], n_results3, # 返回最相关的3个结果 # include[documents, metadatas, distances] # 指定返回的内容 ) print(检索结果:) for i, (doc, meta, dist) in enumerate(zip(results[documents][0], results[metadatas][0], results[distances][0])): print(f\n--- 结果 {i1} (距离: {dist:.4f}) ---) print(f来源: {meta[title]} ({meta[source]})) print(f内容: {doc[:200]}...)距离distances这里返回的是余弦距离1 - 余弦相似度。值越小表示语义越相似。通常距离小于0.2可以认为是高度相关。5.3 流程自动化与调度将以上所有步骤串联起来形成一个自动化流水线脚本。同时需要考虑定时采集和增量更新。import asyncio import hashlib from urllib.parse import urlparse import json import os class KnowledgeBaseCrawler: def __init__(self, start_urls, chroma_path./chroma_db, model_nameBAAI/bge-large-zh-v1.5): self.start_urls start_urls self.visited_urls set() self.chroma_client chromadb.PersistentClient(pathchroma_path) self.collection self.chroma_client.get_or_create_collection(nameweb_knowledge) self.embedding_model SentenceTransformer(model_name) # 用于去重或记录状态的简单文件 self.state_file crawler_state.json async def crawl_and_process(self, url): 针对单个URL的完整抓取处理流程 if url in self.visited_urls: return print(f正在处理: {url}) # 1. 使用Playwright抓取 html_content await self.fetch_with_playwright(url) if not html_content: return # 2. 解析清洗 cleaned_data extract_clean_content(html_content, url) if not cleaned_data.get(content): print(f 警告: 未从 {url} 提取到有效内容) return # 3. 文本分割 text_chunks split_text_into_chunks(cleaned_data[content]) # 4. 生成向量并入库 chunks_with_meta create_chunks_with_metadata(cleaned_data, text_chunks) ids_to_add [] embeddings_to_add [] metadatas_to_add [] documents_to_add [] for chunk in chunks_with_meta: # 检查是否已存在通过ID或内容哈希 chunk_id chunk[id] # 简单查重检查ID是否已存在更严谨的做法是检查内容哈希 existing self.collection.get(ids[chunk_id]) if existing[ids]: # 已存在 print(f 跳过已存在的块: {chunk_id}) continue embedding self.embedding_model.encode([chunk[text]], normalize_embeddingsTrue)[0].tolist() ids_to_add.append(chunk_id) embeddings_to_add.append(embedding) metadatas_to_add.append(chunk[metadata]) documents_to_add.append(chunk[text]) # 批量添加 if ids_to_add: self.collection.add( idsids_to_add, embeddingsembeddings_to_add, metadatasmetadatas_to_add, documentsdocuments_to_add ) print(f 已添加 {len(ids_to_add)} 个新块) self.visited_urls.add(url) async def fetch_with_playwright(self, url): Playwright抓取封装 # ... (实现细节包含错误处理、重试等) pass def save_state(self): 保存爬取状态 with open(self.state_file, w) as f: json.dump({visited: list(self.visited_urls)}, f) def load_state(self): 加载爬取状态 if os.path.exists(self.state_file): with open(self.state_file, r) as f: state json.load(f) self.visited_urls set(state.get(visited, [])) async def run(self): 主运行循环 self.load_state() async with async_playwright() as p: browser await p.chromium.launch(headlessTrue) context await browser.new_context() # 将browser和context保存为实例变量供fetch_with_playwright使用 self.browser browser self.context context for url in self.start_urls: await self.crawl_and_process(url) # 可以在这里添加延时避免请求过快 await asyncio.sleep(1) await browser.close() self.save_state() # 使用示例 async def main(): start_urls [ https://playwright.dev/python/docs/intro, https://example.com/tech-article-1, # ... 更多初始URL ] crawler KnowledgeBaseCrawler(start_urls) await crawler.run() # 定时任务可以使用 APScheduler 或 Celery 进行调度6. 避坑指南与性能优化实战在实际操作中你会遇到各种各样的问题。以下是我踩过的一些坑和总结的经验。6.1 常见问题与解决方案问题可能原因解决方案Playwright 超时页面加载慢、网络差、等待条件未满足1. 增加timeout参数如page.goto(url, timeout60000)。2. 使用wait_for_selector等待特定元素而非networkidle。3. 设置更宽松的等待条件如wait_untildomcontentloaded。元素找不到页面结构变化、元素动态生成、iframe1. 使用更稳定的选择器如>被网站屏蔽请求频率过高、User-Agent 被识别、IP 被封1. 在请求间添加随机延时await asyncio.sleep(random.uniform(1, 3))。2. 轮换 User-Agent 和浏览器上下文。3. 使用代理 IP需谨慎确保合规。4. 模拟人类行为如随机滚动、鼠标移动。提取内容杂乱正文提取算法不准包含过多噪音1. 使用更专业的库如readability-lxml或trafilatura。2. 针对特定网站编写定制化的提取规则XPath/CSS选择器。3. 训练一个简单的机器学习模型来识别正文区域成本较高。向量检索不准chunk 分割不合理、嵌入模型不匹配、相似度阈值设置不当1. 调整chunk_size和chunk_overlap。2. 尝试不同的嵌入模型针对中文/英文。3. 在检索后对结果进行重排序Re-ranking。4. 设置相似度阈值过滤掉低质量结果如distance 0.3。数据库性能下降向量数量过多、索引未优化1. 对于 Chroma确保使用持久化模式并定期清理。2. 对于大规模数据10万条考虑使用 Milvus 或 Qdrant它们为大规模 ANN 搜索优化。3. 建立合适的索引如 HNSW。6.2 性能优化技巧异步并发采集Playwright 原生支持异步利用asyncio.gather可以并发抓取多个页面极大提升效率。async def fetch_url(url, context): page await context.new_page() try: await page.goto(url, wait_untilnetworkidle) content await page.content() return url, content finally: await page.close() async def main(): async with async_playwright() as p: browser await p.chromium.launch() context await browser.new_context() urls [url1, url2, url3] tasks [fetch_url(url, context) for url in urls] results await asyncio.gather(*tasks, return_exceptionsTrue) # 处理 results await browser.close()复用浏览器上下文创建和销毁浏览器实例开销很大。在整个采集任务中尽量复用同一个browser和多个context。选择性加载资源如前所述通过路由拦截禁用图片、字体、CSS等非必要资源可以显著加快页面加载速度并减少带宽消耗。嵌入模型批处理sentence-transformers的encode方法支持批量输入。一次性传入一个文本列表进行向量化比循环调用单次encode快得多。增量更新与去重每次采集前检查 URL 或内容哈希是否已存在于知识库中避免重复工作和存储浪费。可以在元数据中增加last_updated字段实现基于时间的增量更新。6.3 关于合规性与道德的思考虽然技术很强大但我们必须合法合规地使用。遵守robots.txt在采集前检查目标网站的robots.txt文件尊重网站所有者设置的爬虫规则。控制请求速率在请求间添加延迟避免对目标服务器造成过大压力这既是道德要求也能降低被封禁的风险。识别公开数据与个人数据只采集公开可访问的信息绝不触碰需要登录才能访问的个人隐私数据或受版权严格保护的内容。注明数据来源在最终的知识库应用呈现答案时尽可能附上原文链接尊重内容创作者。构建AI知识库的自动化采集系统是一个将软件工程、数据工程和机器学习结合起来的综合项目。从Playwright的精准抓取到文本处理的细致清洗再到向量化的语义升华每一步都影响着最终知识库的“智商”。这套流程并非一成不变你需要根据目标网站的特点、数据的性质以及最终应用的需求进行灵活调整和优化。最关键的还是动手去试在真实的数据流中发现问题、解决问题你的知识库才会越来越“聪明”。