ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用AI编程工具从零实现自用搜索引擎baigle的完整实践

用AI编程工具从零实现自用搜索引擎baigle的完整实践 做一个自用的搜索引擎听起来像是一个重新发明轮子的徒劳项目。但如果你真的把一个搜索请求从头到尾拆开看——从你按下回车到页面展示十条结果——你会发现里面藏着不少有意思的工程问题。baigle这个小项目就是冲着这些问题去的它不是一个挑战Google的野心计划而是一个用几天时间、借助Trae自动编程快速落地、完全自己掌控的迷你搜索引擎。这篇文章记录的是baigle第一阶段的完整实现过程怎么用一个AI编程工具在合理的提示词和人工审查配合下把爬虫、索引、检索引擎、结果页这整套链路跑通。如果你正在学搜索引擎原理或者想试试Trae这类自动编程工具到底能在真实项目里干多少活这篇文章应该能给你一个很具体的参考。我会从系统边界聊到代码细节再聊到Trae生成代码时那些看起来对、跑起来错的坑尽量把实测经验都写出来。1. 为什么自研一个玩具搜索引擎baigle的定位与第一阶段目标1.1 一个搜索请求背后的完整链路先说清楚一件事搜索引擎不是只有爬虫。很多人一想到搜索引擎第一反应就是派程序去抓网页但实际上一个最基本的搜索请求从用户按下回车到看到结果背后至少要经过四个环节。第一个环节是抓取。爬虫从种子URL出发下载网页内容解析出页面里的链接再顺着链接继续抓。这个环节的核心问题是如何高效且礼貌地拿到足够多的网页。第二个环节是清洗。网页HTML里充斥着导航、广告、脚本、样式搜索引擎需要的是干净的正文文本这个环节要解决如何从乱七八糟的HTML里抽出正文。第三个环节是索引。拿到清洗后的文本要做分词、建立倒排索引也就是建立词→文档的映射关系。这个环节决定了搜索引擎能不能在几百毫秒内找到包含某个词的所有文档。第四个环节是检索排序。用户输入查询词后把查询词拆开、去倒排索引里找候选文档、按相关度打分、排序再截取一段正文作为摘要展示给用户。baigle第一阶段的全部工作就是把这条链路从零到一完整走一遍。我不追求它能和商业搜索引擎比速度、比覆盖量但要求每个环节的原理都能在代码里看到、能解释清楚。1.2 第一阶段做什么、不做什么动手之前划定边界比动手之后反复返工更重要。baigle第一阶段的明确范围是这样的做不做支持从少量种子URL出发宽度优先爬取网页不支持分布式爬虫、不支持增量更新支持对HTML页面抽取标题和正文不做正文抽取的复杂算法如基于视觉布局的抽取支持中文文本分词构建内存型倒排索引不引入Elasticsearch等外部搜索引擎组件使用TF-IDF做相关度排序不做PageRank类链接分析不做机器学习排序提供本地Web搜索页面和JSON接口不做用户系统、搜索历史、个性化推荐这个边界是我故意划的。第一阶段的目的是把核心原理跑通而不是造一个能商用的东西。比如排序环节TF-IDF在小型语料上已经能给出像样的结果而PageRank需要依赖大量链接关系数据在第一阶段只会拖慢进度。等后面语料规模大了再作为第二阶段增量加入这个节奏更合理。2. 技术选型与工程骨架让Trae从空目录跑起第一版2.1 为什么选择Python requests BeautifulSoup这套组合选型这件事我直接说结论第一阶段用Python爬虫用requests和BeautifulSoup检索服务用Flask。这个组合可能不够炫但它是Trae这类AI编程工具生成质量最高、最不容易跑偏的组合。理由有三个。第一Python的中文文本处理生态成熟后面做分词、计算词频这些操作标准库加少量第三方库就能完成。第二requests加BeautifulSoup的组合做小规模爬虫足够用代码量小容易读懂出了bug也容易定位。我不需要Scrapy这样的重型框架因为第一阶段的目标语料就是几百个页面Scrapy的并发调度、中间件、Pipeline这些概念反而会模糊掉核心逻辑。第三也是我实际测试后的感受Trae对Python主流技术栈的训练语料最充分让它写requests爬虫、Flask接口生成代码的准确率明显高于冷门框架。2.2 用一份需求说明让Trae生成项目骨架Trae自动编程的第一步不是让它直接写代码而是先给它一份完整的工程说明。我一开始犯过的错误是只丢一句帮我写个搜索引擎结果它给我推荐了一整套Elasticsearch方案——那是第二阶段的东西不是现在要的。后来我把需求文档改成了下面这样直接粘贴到Trae对话框里请帮我搭建一个名为baigle的本地搜索引擎项目第一阶段满足以下要求 1. 使用Python实现目录结构如下 - crawler.py爬虫模块 - indexer.py索引模块 - search.py检索模块 - server.pyFlask服务 - templates/index.html搜索页面 2. 爬虫从种子URL列表开始使用requests下载页面BeautifulSoup解析页面中的a标签提取链接用集合去重限制最多抓取200个页面请求间隔2秒。 3. 每个页面保存标题、URL、清洗后的正文到data/pages/目录下的JSON文件。 4. 索引模块读取JSON文件对正文做中文分词按二元分词即可构建倒排索引保存到data/index.json。 5. 检索模块实现TF-IDF打分输入查询词输出按分数排序的结果列表包含标题、URL、摘要。 6. server.py提供HTTP接口返回HTML页面。 请先生成项目骨架和各个模块的初版代码。这段提示词的价值在于它限定了技术栈、目录结构、数据格式、实现深度还把中文分词用什么级别这个容易过度设计的点直接钉死在二元分词上。Trae生成的骨架总体可用但我没有直接让它全自动跑完而是按模块逐个生成、逐个检查。2.3 目录结构与数据流最终确定的目录结构是这样baigle/ ├── crawler.py # 爬虫种子URL - 清洗后页面JSON ├── indexer.py # 索引页面JSON - 内存倒排索引 ├── search.py # 检索查询词 - 排序结果 ├── server.py # Flask服务HTTP接口 ├── templates/ │ └── index.html # 搜索页 ├── data/ │ ├── pages/ # 爬虫产出的JSON页面文件 │ └── index.json # 序列化后的倒排索引 └── urls.txt # 种子URL列表数据流是一条直线urls.txt → crawler.py → data/pages/ → indexer.py → data/index.json → search.py → server.py → 浏览器。这个清晰的数据流让每个模块都能独立测试后续如果某个环节出了bug只需要重跑那一个模块不用从头再来。3. 爬虫模块从种子URL到干净正文的完整管道3.1 宽度优先爬取与去重策略爬虫模块我让Trae实现的是宽度优先遍历。所谓宽度优先就是先把种子页面的所有链接抓完再去抓这些链接指向页面的子链接一层一层往外扩。这种策略对小型搜索爬虫很实用它能快速覆盖一个站点的核心页面不像深度优先那样容易陷进某个深层目录出不来。去重策略是爬虫的关键。URL去重是最基本的用一个集合记录已经访问过的URL避免重复抓取。但URL去重有个漏洞同一个页面可能通过不同URL访问到比如http://example.com/a和http://example.com/a?frombaigle后者带了个跟踪参数却指向同一份内容。所以我额外加了一层内容去重抓下来之后对正文做MD5哈希如果哈希值已经在集合里就丢弃这个页面。这里有一个我在第一版代码里踩过的坑Trae生成的URL去重只做了字符串精确匹配结果一个页面带和不带www前缀被抓了两次索引里出现了大量重复内容。后来我在提示词里明确要求URL规范化后去重把URL里的协议、域名大小写、默认端口、跟踪参数统一处理问题才解决。3.2 网页编码与正文抽取的细节处理网页编码是所有爬虫都会遇到的第一个实际问题。中文网站里GBK编码的页面仍然不少如果用UTF-8去解码直接得到乱码后面的索引全白做。解决方法是先用requests拿到response.encoding如果解析出来是None或者明显不对就从HTTP响应头或者HTML的meta charset...里取编码再重新解码。正文抽取是清洗环节中最有技术含量的一步。第一版我用的策略非常朴素先用BeautifulSoup移除script、style、nav、footer这些显然不是正文的标签然后取body的纯文本把连续空白压缩成空格。这个策略对结构简单的技术文章、博客页面效果很好但对门户首页这类充斥着大量链接和推荐位的页面就抓瞎了——抓出来的正文里混着大量导航文本索引质量明显下降。更稳妥一点的做法是文本密度过滤把正文按块级元素拆成若干个文本块只保留文本长度超过某个阈值比如50个汉字的块。Trae生成的代码里本来没有这个逻辑是我在评审时发现它对一个演示站首页抽出了三百多字的导航菜单于是补了这段密度过滤。补完之后baigle索引里的正文质量提升了一个台阶。3.3 实践中的数据质量与访问策略小型爬虫和大型爬虫有一个本质区别大型爬虫要担心的是调度效率小型爬虫要担心的是别把对方网站抓挂了以及别被对方ban掉。我在baigle里做了几条硬性约束策略具体做法原因请求间隔每个请求之间至少sleep 2秒降低对目标站点的压力避免被封请求头设置真实浏览器User-Agent有些服务器会拦截无UA的请求超时控制每次请求设置10秒超时避免某个响应慢的页面卡住整个流程失败重试对超时和5xx错误最多重试2次网络抖动是常态但不过度纠缠robots协议在爬取前读取robots.txt按Disallow规则跳过这是基本的网络礼貌也是底线实际抓下来的数据质量和我预期还是有差距。200个页面的配额里大约有15%的页面是重复的或者正文太短被过滤掉的最终进入索引的有效页面在160到170个左右。对于第一阶段来说这个数据量已经完全够用了——它足够支撑一个像样的倒排索引又小到任何操作都能秒级完成。4. 索引与检索搜索引擎的核心价值就在这里4.1 内存型倒排索引的构建思路清洗完的页面存成了JSON文件接下来要做的就是把文档变成索引。核心数据结构是倒排索引它的核心映射关系是词→包含该词的文档列表。普通索引是从文档找词倒排索引反过来从词找文档这样查询时就不用遍历所有文档直接通过词找到候选集合速度从O(所有文档)降到O(包含该词的文档数)。中文分词是第一道坎。英文单词有天然的空格分隔中文是连续书写必须人为切分。我明确要求只做二元分词也就是把连续的几个字符切成重叠二元组。比如搜索引擎原理会被切成搜索 索引 引擎 原理这样一组词。这种做法在信息检索里叫n-gram它不需要词典也不依赖分词库缺点是会产生一些无意义的词但优点是实现极简对技术类文档的检索效果完全够用。下面是索引构建的核心代码是Trae生成后我略微调整的import json import math import re from pathlib import Path class InvertedIndex: def __init__(self): self.postings {} # 词 - {doc_id: term_freq} self.doc_count 0 # 文档总数 self.doc_lengths {} # doc_id - 文档总词数 self.doc_meta {} # doc_id - {title, url, snippet} def tokenize(self, text): text re.sub(r\s, , text) chars list(text) # 二元分词两个相邻字符组成一个词 return [.join(chars[i:i2]) for i in range(len(chars) - 1)] def build(self, pages_dir): idx 0 for json_file in Path(pages_dir).glob(*.json): with open(json_file, encodingutf-8) as f: page json.load(f) tokens self.tokenize(page[content]) if len(tokens) 10: continue self.doc_count 1 self.doc_lengths[idx] len(tokens) self.doc_meta[idx] { title: page[title], url: page[url], snippet: self.make_snippet(page[content]) } seen {} for token in tokens: seen[token] seen.get(token, 0) 1 for token, tf in seen.items(): self.postings.setdefault(token, {})[idx] tf idx 1 def save(self, path): data { postings: self.postings, doc_count: self.doc_count, doc_lengths: self.doc_lengths, doc_meta: self.doc_meta, } with open(path, w, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse)这里有个细节值得提一下postings里我保存的是{doc_id: term_freq}而不是单纯的[doc_id]词频后面计算TF-IDF时要用如果只存文档ID到时候还得重新读一遍文档浪费IO。这是我在评审Trae生成的代码时补上的它第一版生成的是postings[token] [doc_id]没有保存词频。4.2 TF-IDF打分与结果排序的实践索引建好后检索排序是搜索引擎的另一个核心。baigle采用的是TF-IDF也就是词频-逆文档频率加权方案。简单解释一下这两个概念。TF词频衡量一个词在一篇文档里出现的频繁程度词在文档里反复出现说明这篇文档很可能和这个词相关。IDF逆文档频率衡量一个词的区分能力一个词出现在很多文档里比如我们功能这类通用词它的区分能力就很低需要降低权重反过来说一个词只在少数文档里出现比如倒排索引说明这个词能精准定位到特定主题应该提高权重。具体的打分公式我直接说这里的实现版本和教科书略有差异但更实用词条t在文档d中的TF 该词在文档d中出现的次数 / 文档d的总词数词条t的IDF ln((文档总数 1) / (包含该词条的文档数 1)) 1文档d对查询词q的得分 ΣTF × IDF加1是为了避免文档数为0时的除零问题而IDF整体加1是为了让IDF值不为负数。为什么不用单纯的词频匹配用一个例子说明假设有三篇文章A文里搜索引擎出现5次、的出现50次B文里搜索引擎出现10次、的出现40次。如果只按词频匹配的在所有文档里都高排序结果会被的这种废话词主导。引入IDF后的出现在所有文档里IDF值趋近于1而搜索引擎只出现在部分文档里IDF值更高真正影响排序的是主题词而不是语气词。对中文来说二元分词天然地削弱了单字高频词的干扰但加上IDF仍然能显著改善排序质量。实际测试时我做了个对比去掉IDF只用词频搜索搜索引擎时排第一的居然是一篇每句话都带的的长文因为它的总词频高加上IDF之后排第一的变成了标题和正文里搜索引擎都出现且分布均匀的文章效果一下就不一样了。4.3 多词查询与摘要生成baigle的查询处理逻辑简单直接用户输入查询词先做同样的二元分词得到若干个查询片段然后去倒排索引里取出每个片段对应的文档集合把所有集合合并对同一文档的得分累加最后按总分降序排列取前10个结果。合并逻辑要小心一个细节不要用多个set取交集而是应该给每个文档设立一个累加器。比如用户搜索搜索引擎分词后是搜索索引两个词一篇文档可能只命中搜索没命中索引如果做交集它就永远出不来了。用累加器的话它仍然能出现在结果里只是分数低一些。对于两三个字的短查询宽匹配加打分排序的表现比严格交集要好得多。摘要生成我用了最朴素的办法把正文按句号、问号、感叹号拆成句子找到包含查询词的那个句子取出它以及它前后各一个句子拼接起来作为摘要。如果正文里没有直接命中查询词的句子就取正文开头的第一句。这个方法的效果取决于正文清洗的质量——正文越干净摘要越像样。有一个意料之外的小问题当查询词本身比较泛时比如实现摘要常常命中一些无关紧要的转折句让人看得莫名其妙。我的补救办法是限制摘要里命中句的位置如果命中句在正文10%之前或90%之后优先级降低优先取正文中段的内容。5. 接入层与前端本地搜索服务的完整闭环5.1 用Flask暴露检索接口索引和检索逻辑跑通之后还需要一个能让用户在浏览器里访问的入口。我用Flask写了一个轻量服务只暴露两个路由/展示搜索页面/search处理搜索请求。接口设计很关键我没有把HTML拼接直接塞给Flask而是先让/search返回JSON再由前端页面用JavaScript渲染。这样接口和页面解耦以后如果要写客户端、命令行工具直接复用/search的JSON返回。让Trae生成server.py时我在提示词里专门加了一句不要使用flask-restful、不要使用蓝图保持在一个文件内。原因是这个项目就这么大引入过度抽象只会增加阅读负担。生成的代码基本够用我只补充了一个细节搜索请求的超时控制和空查询处理。空查询直接返回提示信息避免后端去空倒排索引里检索导致报错。5.2 搜索页的实现与交互细节搜索页我用了一个极简的HTML模板一个输入框加一个按钮搜索结果显示在下方列表。JS部分用fetch调用/search?q关键词拿到JSON后动态渲染。有几个交互细节值得记录搜索关键词要经过encodeURIComponent编码否则中文和特殊符号会破坏URL。渲染结果时必须对标题和摘要里的HTML标签做转义处理否则搜索结果里的script内容会直接执行这是XSS漏洞不能存侥幸心理。结果里把命中的关键词用mark标签高亮视觉上清楚很多。做法是把摘要文本里的查询词替换成mark查询词/mark同样要注意先转义再替换。空结果不显示抱歉没有找到这种干巴巴的提示而是把用户搜索的词原样返回推荐几个相近词汇方便用户换个角度再搜。实测下来baigle面对160多个文档的索引单次搜索响应时间基本在20毫秒以内用户感知是秒出结果。这部分性能完全够用暂时不需要引入缓存。5.3 索引的持久化与复用索引构建不是每次启动服务都要做一遍的。爬虫跑一次可能要好几分钟因为设置了2秒请求间隔索引构建也要几秒钟如果每次搜索都要重新走这两个流程体验会非常差。我的做法是索引构建完成后用json.dump序列化到data/index.json服务启动时先检查这个文件是否存在如果存在就直接加载到内存不存在才提示需要先跑爬虫和索引构建。加载索引时有一个坑JSON序列化会把整数键转成字符串。倒排索引里postings[token]的键是doc_id是整数但存进JSON再读出来键就变成字符串了。我在加载代码里统一做了一次类型转换把doc_id转回整数同时把嵌套字典转成collections.defaultdict避免后续访问不存在的键时抛出异常。这个坑看起来小但第一次运行的时候确实让我排查了好一阵子。6. Trae自动编程实战哪些能全自动哪些必须人审6.1 Trae的高效用法把大任务拆成小任务逐段验收整个baigle做下来我对Trae的定位是一个速度快但需要盯着的结对工程师。它的代码生成速度确实惊人但前提是你要把任务拆到足够小并在每个模块完成后亲自验收。我的工作节奏是这样先把系统架构和数据流在文档里定死画清楚每个模块的输入输出。把架构拆成独立小任务爬虫、索引、检索、接口、页面逐个交给Trae。每个模块生成后先做静态审查再跑单元测试最后用一个真实样例验证结果。发现问题的把报错信息和期望行为直接粘贴给Trae让它修改。全部模块通过后把工程完整跑一遍回归整体流程。为什么不能一口气把整个项目甩给Trae我试过一次它生成的代码表面上是完整的五个模块全都有但模块之间的接口约定不一致crawler.py输出的JSON字段名和indexer.py读入的字段名对不上server.py用的函数名和search.py里定义的函数名也差了半截。这种全局跑不通的代码排查起来比从头写还累。一次让AI干一件具体的事反而更快。6.2 实测中遇到的生成问题与修正方式Trae的自动生成不是完美的这一周里我踩了几个有代表性的坑列出来给准备用Trae做项目的朋友一个参考。第一个问题是过度依赖。让Trae实现检索打分时它给我推荐了scikit-learn的TfidfVectorizer理由是代码更简洁、更标准。但baigle的语料只有一百多个文档去学一个依赖包、还要处理稀疏矩阵的转换完全不值得。我最后拒绝了它的方案坚持用标准库手写TF-IDF代码也就几十行。第二个问题是边界条件处理弱。爬虫模块初版对requests.exceptions.RequestException的捕获不够细超时和DNS错误混在一起处理导致个别页面卡住了整个爬取队列。我在提示词里补充了必须按异常类型分别处理超时重试其他异常跳过并手动添加了requests.adapters.HTTPAdapter的重试配置。第三个问题是**看起来对但跑起来错**。最典型的一次是它写了死循环爬虫里while queue and len(visited) MAX_PAGES:但在更新visited的逻辑里只有成功解析出链接的页面才会被标记为已访问如果某个页面没有链接它就会被反复从队列里弹出来重试成了一个死循环。这种逻辑错误需要仔细读代码才能发现纯靠单元测试很难测出来。6.3 人与工具的分工节奏用Trae做完整项目之后我的感受是工具越强人工审查的标准越要清晰。自动编程不会消除工程师而是把工程师的重心从写代码转移到定义问题、审查实现、确认边界。具体到baigle这个项目架构和数据流是我自己定的这部分我绝不让AI代劳每个模块的接口签名是我先写出来给AI遵守的AI只是按规格实现内部逻辑而排序公式、分词策略这类影响核心质量的算法我会亲自动手调过一遍确保每一步都理解。交给AI的是那些量大但模式固定的部分比如写爬虫的下载函数、写HTML页面的样式、做JSON解析这类体力活。这样做的好处是效率极高baigle第一阶段从零到跑通实际只用了大约一个工作日的时间大部分时间花在做数据清洗的质量验证上而不是写基础代码。如果一边学搜索引擎原理一边开发这个时间会翻三倍以上。用Trae做搜索引擎项目的一点个人体会最后一个模块跑通的时候我在搜索框里输入索引看着本地页面返回了十几条按相关度排好的结果那种感觉和我平时用百度或Google搜到东西完全不一样——因为这个搜索结果里的每一个字节从抓网页的爬虫到排名的公式都是我看着它一步步建起来的。我的建议是如果你打算用Trae这类工具做一个完整项目先从搜索引擎这种链路清晰、模块独立、非常适合拆解的类型入手。它不像一个App那样有复杂的交互逻辑也不像一个算法项目那样过度依赖数学推导它就是一个典型的流水线工程——爬虫、清洗、索引、检索、展示每个环节都可以独立验收你在验收的过程中既搞懂了原理又摸清了AI生成代码的脾气。后面等baigle数据量大了我想再给它加上链接分析和增量爬取让排序更聪明一点。第一阶段的经验告诉我这些功能不用急着在起步时就塞进来先把核心链路跑顺后面每一个改进都会变得有据可依。
返回列表