尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Python搜索引擎设计实战:从爬虫到倒排索引与TF-IDF排序

Python搜索引擎设计实战:从爬虫到倒排索引与TF-IDF排序 每年毕设季被问到最多的选题清单里“基于Python的搜索引擎设计与实现”绝对排得上前三。这个题目看起来唬人不少同学第一反应是“搜索引擎不是百度、谷歌那种级别才能做的吗”但毕设真正要做的并不是做一个跟大厂抗衡的全网搜索引擎而是把一条“网页抓取—清洗—索引—检索—排序—展示”的完整链路用代码走通。今天这篇就把我做完这个项目以后的全部思路、核心实现、代码片段和踩过的坑整理出来给正被选题和中期检查逼着赶进度的同学一条能直接照做的路线。先交代我这个项目做了什么爬虫模块负责抓取指定领域网页清洗模块把HTML转成干净纯文本索引模块做中文分词并构建倒排索引检索模块接收用户查询、计算TF-IDF相关度、返回排序后的TopN结果最后用Flask写了一个带搜索框的Web界面展示效果。全程没依赖任何商业搜索API也没有直接用Elasticsearch这类现成检索引擎题目里的“设计与实现”就体现在这些模块都是自己写的。语言层面我没用Java、Go选了Python原因很现实Python在爬虫、数据处理、Web开发上都有成熟库毕设周期内能快速把原型跑起来。更关键的是不管用什么语言搜索引擎的核心骨架不会变语言只是工具真正值钱的是倒排索引的设计、TF-IDF排序的数学逻辑以及整套模块怎么组织在一起。1. 整体设计与思路拆解1.1 为什么选“搜索引擎”这个题目选题直接决定后面半年是边做边学还是边做边哭。我选搜索引擎是因为它天然具备三个优势第一业务闭环完整能交付一个从数据采集到用户搜索的可见系统而不是只提交一堆算法代码第二理论点集中倒排索引覆盖数据结构TF-IDF覆盖概率统计和线性代数爬虫覆盖网络编程每个模块答辩时都能展开讲第三工作量好拆爬虫、索引、检索、展示四个模块能独立推进最坏情况砍掉某个锦上添花的功能也不影响主线。相比之下有些同学选“智能购物推荐系统”后期容易变成“核心是什么都说不清到处贴机器学习模型”选“XX管理系统”又太像课设作业答辩时缺少亮点。搜索引擎卡在一个舒服的位置既有工程实践又不缺算法深度开题、中期、终期三个阶段都有东西可讲。1.2 不是做“全网搜索”而是做垂直搜索很多人在开题报告里写“设计一个高性能搜索引擎”这个说法太危险。你不可能在一个学期里做出百度导师心里也清楚。把范围界定清楚项目才有做成的可能。我当时采取的做法是做垂直搜索选一个明确领域比如编程技术博客、新闻资讯或开源文档种子URL控制在几十个以内抓取规模控制在几千到几万个页面。垂直搜索的好处非常明显数据量可控抓取入口明确清洗规则只需适配少数几个站点的HTML结构最终人工核对搜索结果的相关度也方便。全网搜索引擎面对的是亿级网页垂直搜索面对的只是几千篇文档这个量级下用纯Python手写倒排索引单次查询能达到十几毫秒演示完全流畅。1.3 技术选型自己实现索引而不是无脑上Elasticsearch搜索引擎领域有个绕不开的诱惑——Elasticsearch。性能强、生态好部署完等于直接拥有一个能用的搜索引擎那我还写什么问题在于毕设题目是“搜索引擎的设计与实现”如果直接用ES搭一个答辩时导师一句“请解释一下你是怎么实现倒排索引的”就能把你问住。我的选择是核心引擎完全自实现把Elasticsearch放在“项目展望”章节当对比方案说明自己知道业界方案是什么也清楚自研引擎的边界在哪里。自研引擎的定位是单机、小数据集、清洗过的HTML数据集合。用Python手写倒排索引和TF-IDF排序。性能上限不高但能把原理完整展示出来学习收获完全不一样。如果你论文里能把这层取舍写明白相当于提前回答了一个高频答辩问题为什么不直接用ES。1.4 系统整体架构与两条数据流整个项目可以拆成两条链路。数据构建链路爬虫从种子URL出发抓取网页解析HTML里的标题、正文和链接把干净正文存入文档库索引器读取文档做中文分词和停用词过滤统计词频和文档频率构建倒排索引并落盘。查询检索链路用户在Web界面输入query检索服务对query做同样的分词处理依据倒排索引取出候选文档计算每篇文档与query的相关度分数按分数倒序返回TopN结果渲染标题、摘要、链接。模块职责可以整理成一张对照表模块输入输出核心职责爬虫种子URL列表原始HTML文件页面抓取、URL去重、请求限速清洗解析原始HTML结构化文档库提取标题正文、处理编码索引构建文档库倒排索引文件中文分词、词频统计、IDF计算检索排序用户查询词TopN文档列表候选召回、相关度打分Web展示搜索框输入结果页面结果渲染、摘要高亮这样的分模块结构每个模块职责单一排查问题时能做到哪儿坏了就查哪儿。后面所有代码也按这条链路来组织。2. 核心技术细节解析2.1 爬虫模块入口、去重、解析搜索引擎的数据源是爬虫抓回来的。如果一开始就把爬虫写复杂后期一定会返工。我拆成三个子能力URL管理器、下载器、解析器。URL管理器维护一个待抓取队列和一个已访问集合。待抓取队列用list就行数据量大再换queue已访问集合用Python的set存URL字符串几千页规模下内存完全没问题。去重时要注意URL规范化比如去掉#锚点、统一协议头否则同一页面可能因为尾部参数不同被抓多次。下载器直接用requests库设置合理的User-Agent请求间隔控制在1秒以上。容易被忽略的一点requests.get()不校验编码时中文网页容易乱码。正确做法是先检查响应头里的charset再用对应的编码解码文本。如果是gbk页面却按utf-8解正文直接变成乱码后面的索引和搜索全白搭。解析器用BeautifulSoup提取标题、正文段落和链接。提取链接时相对路径要用urljoin转绝对地址否则链接队列里混入一堆无法请求的残缺URL。正文提取不要简单把所有p标签拼起来很多站点的导航栏和广告区也放在p标签里需要按站点的实际结构做微调。我后来专门为三个核心站点各写了一套抽取函数数据质量立刻上了一个台阶。2.2 中文分词没有分词就没有检索英文搜索引擎分词简单按空格和标点切就行中文没有天然分隔必须引入分词器。我的项目用jieba带词性标注和自定义词典足以应对毕设场景。调用方式很简单jieba.lcut(text)返回切好的词列表。分词选择会直接影响检索效果。比如“北京大学生”这个词串切成“北京/大学生”和“北京/大学/生”是两种完全不同语义。jieba的HMM模型能处理不少新词但仍建议维护领域自定义词典把“机器学习”“深度学习”“反向传播”这类术语放进去。搜索引擎这个项目里jieba关键词本身认识但这不代表百度里那些最新技术名词都能准确切分要养成建自定义词典的习惯。分词之后必须做停用词过滤。中文里“的、了、在、是、和”几乎出现在每篇文档里留着只会加大索引体积、拉低相关度。我第一次建索引时偷懒没过滤搜“Python的爬虫原理”时“的”被当成核心词参与计分排名前几的全是和爬虫无关但高频出现“的”的页面。这种教训一次就够。2.3 倒排索引搜索引擎的数据结构之魂搜索引擎和非搜索系统的本质区别就在这。如果采用“正排索引”每篇文档存一个词列表查询时只能一篇篇扫描文档文档量上来以后延迟不可控倒排索引是“词到文档”的映射每个词项对应一个带权重的文档列表查询时直接依据词定位到一个小候选集合而不是全表扫描。举个例子。文档1内容为“Python 爬虫 入门”文档2内容为“搜索引擎 Python 基础”。正排结构大概是doc1 - [Python, 爬虫, 入门] doc2 - [搜索引擎, Python, 基础]查询“Python”时需要遍历全部文档。但倒排结构是这样Python - [(doc1, tf1), (doc2, tf1)] 爬虫 - [(doc1, tf1)] 搜索引擎 - [(doc2, tf1)]查询“Python 爬虫”时只需取Python和爬虫两个倒排链表做归并doc1是两词都命中的交集排到最前。这就是倒排索引能支撑海量数据毫秒级检索的原因数据规模变大时候选集合的增长远小于全量文档的增长。落盘存储是索引模块的重要一环。我当时用pickle把整个dict序列化到文件简单直接用json会更占空间。这里提醒一句如果想把索引规模做大应参考Lucene的分段合并思路把内存索引分段存储定期合并落盘。这个知识点写进论文的优化方向很加分。2.4 TF-IDF权重计算为什么这样算分词、索引都建好了接下来最关键的问题一篇文档命中多个词时怎么排序经典方案是TF-IDF。先看词频TF某个词在一篇文档里出现越多说明文档与这个词越相关再看逆文档频率IDF这个词在全库出现在越少的文档里区分度越高。两者相乘就是词对文档的权重。我采用的是Lucene的经典变体。TF部分用对数平滑防止长文档因为词多而碾压短文档。IDF部分写法是IDF ln((N 1) / (df 1)) 1其中N是文档总数df是包含该词的文档数。加1一方面防止分母为0另一方面保证所有词的IDF都是正的查询时不会出现负分。查询得分就是把query中每个词对文档的TF-IDF贡献累加Score(d, q) sum over each query term of IDF(term) * (1 ln(TF(term, d)))用一个实际数字演示。假设文档总数N2000“python”出现在120篇文档中某篇文档里出现3次那么IDF ln(2001/121) 1 ln(16.54) 1 ≈ 2.806 1 3.806TF部分 1 ln3 ≈ 2.098词贡献约7.99。假设query里还有“爬虫”df60文档出现1次IDF ln(2001/61) 1 ≈ 3.49 1 4.49TF1贡献4.49。总得分约12.48。可以看到即使“爬虫”在文档里只出现一次只要它在全库里足够稀缺也能获得不小的权重。这就是IDF的意义用全库统计去平衡单篇文档的词频。这个模型没考虑词的位置、文档长度惩罚和BM25的词频饱和但对毕设够用。核心理解是TF强调单篇文档内的突出程度IDF强调全库范围内的稀缺程度两个维度缺一不可。2.5 为什么用TF-IDF而不是BM25做完TF-IDF后我也想过要不要换成BM25。BM25对长文本和词频饱和的控制确实更好现代检索引擎默认也是BM25。但我当时的目标是把原理讲清楚TF-IDF公式简单、可解释性强答辩时可以手推。实际效果上小数据集里两种方法差异没有想象那么大。如果你是中期以后想提升实用效果可以把排序替换成BM25两个算法的替换成本不高。我的建议是追求可解释性和论文推导方便留在TF-IDF你想在答辩现场展示自己会做优化可以加一个BM25对比实验结果。3. 实操过程与核心环节实现3.1 环境准备与项目目录我的环境是Python 3.9依赖只有这几个requests、beautifulsoup4、jieba、flask。如果还需要做数据分析图再加pandas和matplotlib但项目主体的四个模块并不依赖它们。目录结构建议按模块拆每个模块一个文件search_engine/ ├── crawler.py # 爬虫入口 ├── indexer.py # 索引构建 ├── searcher.py # 检索与排序 ├── webapp.py # Flask界面 ├── config.py # 配置参数 └── data/ ├── pages/ # 原始网页 ├── docs.json # 清洗后的文档库 └── index.db # 倒排索引文件这种一文件一模块的写法对毕设文档和答辩演示都很友好。导师看代码时能顺着文件名一路看下去不需要花力气理解你整个项目是从哪个迷宫绕出来的。3.2 完整爬虫实现下面是爬虫核心逻辑省略了部分异常处理细节但保留关键框架。import requests import json import time import hashlib from bs4 import BeautifulSoup from urllib.parse import urljoin class Crawler: def __init__(self, seed_urls, output_dir): self.to_crawl list(seed_urls) self.crawled set() self.output_dir output_dir self.docs {} def normalize(self, url): # 去掉锚点和尾部斜杠防止重复抓取 url url.split(#)[0] if url.endswith(/): url url.rstrip(/) return url def download(self, url): headers {User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)} resp requests.get(url, headersheaders, timeout10) resp.encoding resp.apparent_encoding return resp.text def parse_page(self, html, current_url): soup BeautifulSoup(html, html.parser) title soup.title.get_text(stripTrue) if soup.title else paragraphs [p.get_text(stripTrue) for p in soup.find_all(p)] text \n.join(paragraphs) links [] for a in soup.find_all(a, hrefTrue): absolute urljoin(current_url, a[href]) links.append(self.normalize(absolute)) return title, text, links def run(self, max_pages2000): while self.to_crawl and len(self.crawled) max_pages: url self.to_crawl.pop(0) url self.normalize(url) if url in self.crawled: continue try: html self.download(url) except Exception as e: print(download error:, url, e) continue title, text, links self.parse_page(html, url) if text: page_id hashlib.md5(url.encode()).hexdigest()[:8] self.docs[page_id] {url: url, title: title, text: text} with open(f{self.output_dir}/docs.json, w, encodingutf-8) as f: json.dump(self.docs, f, ensure_asciiFalse) self.crawled.add(url) for link in links: if link not in self.crawled: self.to_crawl.append(link) time.sleep(1.0)几个要点说明。time.sleep(1.0)是对目标网站的基本礼貌避免IP被频繁请求封掉。毕设爬取规模控制在几千页这个速度完全够。异常处理里单个页面失败要让程序继续跑不要因为一个坏链接导致整条链路中断。合规问题必须多说一句数据抓取要尊重网站条款robots.txt不允许抓的页面不要碰抓下来的数据只用于学习研究不要对外发布。这不是场面话答辩时主动说明这点证明你考虑过数据伦理反而加分。3.3 索引构建的完整代码索引构建是全文核心我贴一个结构完整但做了简化处理的版本读取文档库对每篇文档分词、统计词频再更新倒排表。import jieba import json import math from collections import defaultdict STOPWORDS set([的, 了, 在, 是, 和, 与, 及, 等, 之, 于]) def load_documents(doc_file): with open(doc_file, r, encodingutf-8) as f: return json.load(f) # 返回结构: {doc_id: {url: ..., title: ..., text: ...}} def build_index(documents): postings defaultdict(list) df defaultdict(int) doc_count len(documents) for doc_id, doc in documents.items(): text doc[title] doc[text] tokens jieba.lcut(text) tokens [t.strip() for t in tokens if t.strip() and t not in STOPWORDS] term_freq defaultdict(int) for token in tokens: term_freq[token] 1 # 关键df按文档去重计数 for token in term_freq.keys(): df[token] 1 for token, tf in term_freq.items(): postings[token].append({doc_id: doc_id, tf: tf}) return {postings: postings, df: df, doc_count: doc_count}这里最容易写错的就是df的统计位置。df全称document frequency统计的是“包含某词的文档数”而不是“某词在所有文档里的出现总次数”。如果把df的计数放在inner loop里也就是每出现一次就加一次那么长文档里一个词出现20次就会对df贡献20IDF计算彻底失真得分被文档长度彻底带偏。这可是一个我在调索引正确性的时候光找bug就花了半天的位置。3.4 检索与排序的实现检索模块要做的事先把query分词再在倒排索引里取每个词的候选列表计算分数排序截取TopN。from collections import defaultdict import math import jieba def search(query, index, top_k10): postings index[postings] df index[df] N index[doc_count] terms [t for t in jieba.lcut(query) if t not in STOPWORDS] scores defaultdict(float) for term in terms: if term not in postings: continue idf math.log((N 1) / (df[term] 1)) 1 for item in postings[term]: tf item[tf] score idf * (1 math.log(tf)) scores[item[doc_id]] score ranked sorted(scores.items(), keylambda x: x[1], reverseTrue) return ranked[:top_k]核心循环只有几行却把“查找候选”“计算权重”“累加排序”三件事一次做完。如果内存装不下整份索引可以在构建时把倒排表按词项排序后分段存盘查询时按需加载这就是Lucene分段思想的简易版本。毕设阶段你可以把这个优化写进论文的后续工作不用真的实现。3.5 Flask界面和交互展示没有界面的搜索引擎答辩时说服力会弱很多。我用最简单的Flask渲染一个带搜索框的页面from flask import Flask, request, render_template from searcher import search app Flask(__name__) app.route(/) def index(): return render_template(index.html) app.route(/search) def do_search(): query request.args.get(q, ) if not query: return render_template(index.html) results search(query, load_index()) return render_template(results.html, queryquery, resultsresults)前端不需要复杂一个居中搜索框加一个结果列表就行。建议把当前查询耗时显示在页面上方类似“搜索用时 0.021 秒”这个小细节会显得系统是真实可测的。结果列表里要展示文档标题、URL和命中摘要。这里有个展示层面的心得摘要不要直接截取文档开头最好从包含最多查询词的那一段里截取并对命中词做高亮。这个功能实现不复杂但答辩演示时搜索“python爬虫”能在摘要里直接看到命中词高亮比一坨纯文本有说服力得多。用户也更容易确认“系统真的找到了相关内容”。3.6 效果评测证明你的搜索“确实能用”光说“我做了个搜索引擎”不够毕设要有评测。最简单的方案是“人工标注的静态测试集”准备20个查询词人工判定哪些文档是相关结果然后计算P10即前10条结果中相关文档的比例。页面放一张表格配3到5条典型查询的检索结果截图效果就很实。我当时的真实数据大概是垂直站点约2000篇文档20个查询的平均P10在0.65到0.8之间。这个数字不算漂亮但对没有调参、没有训练的TF-IDF引擎来说很正常对毕设来说已经及格。答辩时主动承认这个上限并解释TF-IDF为什么在小规模数据下表现尚可、在什么场景下会失效这个客观评测的态度反而会让导师认可。4. 常见问题与排查技巧实录4.1 搜索不到结果或者召回率特别低头号原因是分词后的query词表和文档词表对不上。比如用户搜索“python爬虫教程”jieba切出几个词但索引里的文档可能把“python”存成了全小写、把“教程”和“教 程”切法不一致。排查顺序是先把query和文档的分词结果分别打印出来对比再检查大小写统一、全半角统一、停用词是否误删了核心词。第二个常见原因是数据量太少。索引只有几百篇文档时匹配到任意一个词的文章都不多某些生僻词命中的文档数可能为0。我刚搭完系统时搜索一个冷门技术词返回0条自查发现是爬虫只抓了不到200篇有效页面词表覆盖不够。扩大爬取范围后问题自然解决。4.2 索引构建时内存暴涨文档量到万级以后用dict存全量索引会面临内存压力。Python的dict开销本来就大每个词条、每个文档ID都消耗不少内存。我当时试着索引3万篇文章索引加载后直接吃掉1.5GB内存开发机差点死机。解决办法有三条一是用list存储倒排表并排序后压缩成数组不再嵌套dict二是用sqlite存倒排表查询时按词项查表三是加一层内存缓存存高频词项。归纳成一个经验法则当词典规模超过10万词、文档数超过1万时就该考虑换存储结构。如果只是几千篇文档直接dict完全没问题。4.3 爬来的页面乱码或正文太脏乱码基本是编码判断问题。requests里resp.apparent_encoding有时会误判尤其当页面meta标签和响应头charset不一致时。更稳妥的做法是对比响应头、apparent_encoding、以及页面源码里的meta声明三处信息再根据实际文本内容检测。我当时的经验是最少要兼容utf-8和gbk两类常见站点的编码。正文太脏的问题根源在HTML结构不规律。我记得一个特别典型的案例某资讯站点的正文被拆在几十个div标签里用所有p标签提取出来的文本量非常少导航区文本反而多。解决办法是按站点写一套定制提取函数用CSS选择器选中真正的正文容器。这种定制工作写一遍整个项目的数据质量就会显著提升。4.4 排序结果前几名总是不理想TF-IDF的明显局限是不考虑词的位置、也不做文档长度惩罚长文档、重复词多的页容易排到前面。一个屡试不爽的优化是给标题加权重标题里命中query词的文档分数整体乘1.5。这个操作不到10行代码效果立竿见影因为标题相关性和用户意图的相关度相当高。如果分词把“python入门”切成“python”和“入门”但有的文档写的是“python从零开始学习”没有“入门”这个词就召回不到。解决思路之一是把query整体当短语文档包含完整短语时给一个额外加分。这样处理后相关度又提升了一截。还有一个容易踩的坑停用词表误删。某些通用停用词表会把“基础”“入门”“学习”这类词也加进去过滤后本来相关的文档被排除。调优时可以先让系统输出每个候选词的IDF值检查是不是IDF太低导致没有区分度再考虑调整停用词表。4.5 答辩时高频出现的隐藏问题技术之外补几个答辩层面的问题。第一“你这套系统和Elasticsearch有什么区别”。诚实回答是关键ES是分布式、大规模、生产级的Lucene应用我这里实现的是单机、小规模、教学级的核心机制包括倒排索引和TF-IDF排序。紧接着补一句“但ES的底层核心也是倒排索引我实现的是它的简化版”既展示了解又保护了工作量。第二“数据规模翻100倍系统怎么扩展”。这个问题要答两点分布式抓取和索引分片。索引分片参考分库分表思想按词项哈希到不同机器查询时广播合并结果排序层可以升级到BM25、字段权重、语义向量。不要求做到但必须有思路。第三也是我最想提醒的论文里不要只贴代码要把设计决策写出来。为什么选TF-IDF而不选BM25为什么定时爬取而不实时更新索引为什么把数据限定在垂直领域。这些决策背后的原因正是“设计”二字的体现也是分数拉开差距的地方。结尾·实操体会写完这个项目我最大的感受是搜索引擎不是一个需要高深数学才能碰的题目也不是必须靠大数据集群才能演示的工程。我用一台普通笔记本、几千篇网页和不到一千行的Python代码完整走通了“网页—文本—词项—倒排表—相关度—结果页面”的全链路。特别是第一次在浏览器里敲下一个词看到一个排序好的结果列表跳出来时确实会有一种“原来它就是这样工作”的通透感这是单纯读信息检索课程换不来的体验。最后给后来人一个很实在的建议把时间分配得更极端一些前期多花时间在数据清洗上后期检索逻辑会轻松很多。我的项目里最耽误进度的就是初始阶段抓下来的文本太脏导致索引建了又删、搜了又改。先把文档库做干净后面倒排索引和排序模型就是水到渠成的事。
返回列表