拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

基于Python的搜索引擎设计与实现:从爬虫到倒排索引的完整实战

基于Python的搜索引擎设计与实现:从爬虫到倒排索引的完整实战 做毕设的时候我选了“基于Python的搜索引擎设计与实现”这个题目。说实话刚开始心里挺没底的因为搜索引擎这东西听起来就像是个巨头才能搞的项目百度谷歌那是多大的工程。但真正把一个能用的搜索引擎从零写出来之后我才发现毕设级别的搜索引擎核心并不在于海量数据和高并发而是在于你是否真正吃透了“检索”这件事本身。这个项目做完我对Python的理解、对数据结构的理解、对整个软件工程流程的理解都上了一个台阶。这篇文章我就把整个项目的设计思路、核心模块实现、踩过的坑和排查技巧全部整理出来。不管你是正在为毕设选题发愁还是想深入了解搜索引擎内部原理这篇文章都应该能给你一个完整、可落地的参考而且代码方案都可以直接抄作业。1. 搜索引擎整体设计与架构拆解1.1 搜索引擎的本质把无序变有序你平时用百度搜东西背后是数以千亿计的网页但你的毕设搜索引擎不需要去爬整个互联网。毕设搜索引擎的本质是让你理解从“抓取数据”到“建立索引”再到“查询返回”的完整链路。简单说搜索引擎干的事情就是三块数据从哪来、数据怎么存、用户搜的时候怎么把最相关的结果算出来。很多人把搜索引擎和数据库混淆。数据库是存什么取什么你查“id 1”它就返回id为1的记录。搜索引擎不一样你得处理“模糊的、自然语言的、带有相关性语义”的请求。比如用户搜“Python爬虫教程”他不是要一个精确值他想要的是一个排好序的列表而且最前面的一定是最相关的。这个“排序”的能力才是搜索引擎的灵魂。毕设级别的搜索引擎我用的是经典的“爬虫 分词 倒排索引 TF-IDF排序”架构。这套架构非常成熟业界主流搜索引擎包括Lucene、Elasticsearch的底层核心思想都离不开它。你只要把这条链路走通面试的时候聊搜索相关的岗位你都能接得住。1.2 技术选型为什么全栈用Python选型阶段我最纠结的是核心模块用Python但是不是有些部分要用C写后来我想明白一件事毕设的目的是验证思路、展示能力不是生产环境做性能比拼。全栈Python的好处有几点开发效率极高。爬虫用requestsBeautifulSoup分词用jiebaWeb框架用Flask全是Python生态里最成熟的轮子一天的开发量顶C一周。代码可读性好。答辩的时候老师翻开你的代码Python的语法接近伪代码解释起来非常轻松。无缝对接数据分析。后期你想做搜索日志分析、点击率模型Pandas和Scikit-learn直接就能用起来。当然Python不是没有坑。GIL全局锁让多线程爬虫在CPU密集场景下乏力所以我在爬虫部分用的是多进程 异步IO的组合后面会细说。另外纯Python的处理速度确实比C慢但毕设的数据量级几千到几万网页Python的处理能力完全够用而且逻辑清晰远比速度重要。1.3 适合毕设的模块化架构我的项目分成了四个独立的模块每个模块都可以单独运行和测试这也是我后来答辩时的一个加分项。四个模块分别是数据采集模块爬虫负责从种子URL开始抓取网页内容。内容解析与预处理模块清洗HTML、提取正文、中文分词、去除停用词。索引模块构建倒排索引计算TF-IDF权重。检索与排序模块接收查询词返回排序后的搜索结果。这四个模块间的数据流是单向的非常清晰。爬虫产出原始网页文件预处理产出分词后的文档索引模块产出索引文件检索模块读索引并提供服务。单向下游的好处是任何一个模块坏了之前的产出还在你可以断点调试不用每次从头跑。2. 网页数据采集爬虫模块的设计与实现2.1 从零构建一个小型爬虫框架爬虫是整个搜索引擎的“上游”。你的数据源质量直接决定了搜索效果。如果爬回来的都是乱码、广告、噪声内容后面分词和索引做得再好也白搭。我这里的爬虫目标站点选的是几个知名技术博客和新闻网站注意要选允许爬取或者有公开API的站点遵守robots协议是基本素养。核心代码其实很短我用的是requests.Session()保持会话状态避免频繁握手。解析用lxml而不是BeautifulSoup因为xpath在复杂HTML结构下定位更精准速度也快不少。import requests from lxml import etree from urllib.parse import urljoin class Crawler: def __init__(self): self.session requests.Session() self.session.headers.update({ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 }) def fetch(self, url): try: resp self.session.get(url, timeout5) resp.encoding resp.apparent_encoding return resp.text except Exception as e: print(f抓取失败 {url}: {e}) return None def parse_links(self, html, base_url): tree etree.HTML(html) hrefs tree.xpath(//a/href) links set() for href in hrefs: full_url urljoin(base_url, href) if full_url.startswith(http): links.add(full_url) return links这里有个细节resp.encoding resp.apparent_encoding非常重要。很多网站用的是utf-8但有些老站是gbk或者gb2312如果你不动态识别编码中文部分全是乱码后面分词直接崩。实测下来加上这行代码能解决90%的编码问题。2.2 布隆过滤器去重的核心原理爬虫最怕的是什么重复抓取。你抓了一个页面的链接AA里面又指向BB里面又指向A如果不做去重爬虫就会在两个页面之间死循环。我在这里采用的是**布隆过滤器Bloom Filter**做URL去重。布隆过滤器的核心原理是用多个哈希函数把URL映射到一个位图数组的多个位置上全部置为1表示该URL可能存在过。它的好处是空间占用极小、查询速度极快代价是有一定的误判率把没访问过的URL误判为已访问但不会漏判已访问的一定能识别出来。对于爬虫去重来说少量误判意味着偶尔少爬一个URL完全不影响整体效果。import hashlib import bitarray class BloomFilter: def __init__(self, size1000000, hash_count7): self.size size self.hash_count hash_count self.bits bitarray.bitarray(size) self.bits.setall(0) def _hashes(self, url): result [] for i in range(self.hash_count): digest hashlib.md5(f{i}:{url}.encode()).hexdigest() result.append(int(digest, 16) % self.size) return result def add(self, url): for pos in self._hashes(url): self.bits[pos] 1 def contains(self, url): for pos in self._hashes(url): if self.bits[pos] 0: return False return True布隆过滤器的两个参数需要注意。位图大小size和预估的URL数量有关公式是m -n * ln(p) / (ln2)^2其中n是预计元素数量p是可接受的误判率。如果预计爬5万个URL误判率控制在1%的话位图大小大概需要-50000 * ln(0.01) / 0.48 ≈ 479,000位也就是约58KB哈希函数个数k (m/n) * ln2 ≈ 7。这就是代码里size1000000, hash_count7的来历拍脑袋是拍不出这个参数的。2.3 爬虫调度策略与反爬应对爬虫的调度策略我用了广度优先BFS。用一个队列管理待抓取URL每次从队列头部取出一个URL抓取后把页面里的新链接加入队列尾部。这样能保证搜索结果的覆盖面广一些不至于沿着一条链接一路走到黑。至于反爬我的经验是不要硬刚。毕设爬虫的目标是“拿到足够的合法数据”不是和网站管理员斗智斗勇。我的策略是设置随机延时time.sleep(random.uniform(1, 3))避免请求频率过高使用轮换的User-Agent池模拟不同浏览器访问对页面体积做限制超过2MB的页面直接丢弃防止内存被撑爆如果触发验证码或返回403立刻停止对该域名的爬取切换到其他源站。很多同学一开始写爬虫很兴奋把目标站点爬得风生水起结果对方服务器直接给你IP封了整个项目停摆。爬虫模块的正确思路是“稳”而不是“快”。3. 中文分词与倒排索引搜索引擎的核心3.1 中文分词为什么是难点搜索引擎处理英文和中文有一个巨大的区别英文单词之间有空格天然分隔而中文句子里的词之间没有明显的边界。比如“武汉市长江大桥”分词可以是“武汉/市长/江大桥”也可以是“武汉市/长江大桥”这个歧义是中文分词的核心难点。我用的是jieba分词库它是目前Python中文分词的事实标准。jieba支持三种模式精确模式、全模式和搜索引擎模式。精确模式适合文本分析搜索引擎模式适合构建索引。注意我构建索引时用的是搜索引擎模式它会在精确模式的基础上对长词再次切分提高召回率。import jieba def tokenize(text): # 搜索引擎模式提高召回率 tokens jieba.lcut_for_search(text) # 过滤停用词和单字 stopwords load_stopwords() return [t for t in tokens if t not in stopwords and len(t.strip()) 1]停用词表是必须的。中文里的“的、了、是、在、和”这些词在几乎每个文档里都出现它们对相关性排序没有帮助还占用大量索引空间。停用词表网上有很多开源版本也可以在实验过程中自己积累把高频且无实义的词不断加进去。3.2 倒排索引的数据结构与构建流程倒排索引是搜索引擎的“命根子”。它的设计思路直接决定了检索速度能快到什么程度。正排索引是“文档ID - 包含的词”倒排索引反过来了是“词 - 包含这个词的文档ID列表”。这就是“倒排”两个字的由来。为什么要倒排用户搜“Python爬虫”系统查倒排索引表直接定位到python这个词对应的文档列表再定位到爬虫这个词对应的文档列表然后取交集就能知道哪些文档同时包含这两个词。如果用了正排索引你得遍历每一篇文档看看它是否包含“Python”和“爬虫”那效率就是灾难级的。我用的索引结构是Python的字典 列表# 倒排索引结构 # { 词项: [(文档ID, 词频TF), (文档ID, 词频TF), ...] } inverted_index {} def build_index(doc_id, token_list): token_count {} for token in token_list: token_count[token] token_count.get(token, 0) 1 for token, count in token_count.items(): if token not in inverted_index: inverted_index[token] [] inverted_index[token].append((doc_id, count))这里每个词项后面存的不是单纯的文档ID而是**文档ID 词频TF**的元组。词频是后面计算相关性权重的重要输入。你在一篇5000字的文章里提了50次“Python”和在一篇500字的短文里提了5次“Python”显然前者的相关度更高当然归一化后是后者更高所以词频必须记录。索引构建完成后我把它用JSON序列化保存到本地文件。Python的字典序列化非常方便但注意数据量大时JSON的读写效率不高你可以改用picklePython原生二进制格式或者sqlite3轻量级数据库。我做毕设时数据量不大JSON完全够用而且答辩时直接打开文件向老师展示数据结构和存储格式非常直观。3.3 TF-IDF权重计算与实现建立好倒排索引之后面临的关键问题是**怎么判断哪个文档更相关**这里我用的是经典算法 TF-IDF全称Term Frequency-Inverse Document Frequency翻译过来就是“词频-逆文档频率”。TF-IDF的直觉很简单它由两部分构成TF词频词在文档中出现次数越多越相关。但纯看次数不公平长文档天然比短文档容易积累更多词频所以一般做归一化处理比如除以该文档的总词数。IDF逆文档频率词在整个文档集合中越罕见携带的信息量越大。比如“优化”这个词在技术文章中到处都是而“布隆过滤器”这个词只出现在少数几篇深入文章中那后者对区分文档的贡献更大。数学公式是TF-IDF(t, d) TF(t, d) * IDF(t)其中IDF(t) log(N / df_t)N是文档总数df_t是包含词t的文档数。为什么取log因为文档总数和包含该词的文档数的比值可能非常悬殊取log可以压缩数值范围避免某个词因为过于稀缺而权重爆炸。import math class Indexer: def __init__(self, inverted_index, doc_count): self.inverted_index inverted_index self.doc_count doc_count def compute_tfidf(self, token, doc_id, doc_token_count): # TF词在文档中出现的次数 / 文档总词数 doc_posting dict(self.inverted_index[token]) tf doc_posting.get(doc_id, 0) / doc_token_count # IDFlog(总文档数 / 包含该词的文档数) df len(self.inverted_index[token]) idf math.log((self.doc_count 1) / (df 1)) 1 return tf * idf这里有个小细节是(self.doc_count 1) / (df 1)) 1为什么都加1因为如果一个词在所有文档中都出现了比如某些高频词没被停用词表完全清洗掉idf log(N/N) 0这个词的权重就清零了。加1是为了做平滑处理防止除数为零或权重归零的情况。4. 检索排序与查询处理从输入到结果的完整链路4.1 查询解析与检索流程用户输入查询词“Python爬虫怎么入门”这个查询词不能直接拿去检索。检索模块需要经过和索引时完全相同的预处理流程分词 - 过滤停用词 - 得到查询的Token列表。这个一致性非常重要如果你索引时用的是“python爬虫”这种处理方式查询时却用了另一种方式两边就对不上了召回率会惨不忍睹。匹配阶段我采用的是“包含所有查询词AND”策略。也就是说搜“Python爬虫”返回的文档必须同时包含“Python”和“爬虫”两个词经过分词后查询Token可能包含多个。这个策略的好处是结果集非常精确坏处是如果用户输入了三个以上的关键词可能一个文档都匹配不上。实际使用中AND策略对毕设项目更合适因为你们的文档集本身就小宁可少结果也要保证权威相关。业界搜索引擎用的是“包含任一查询词OR)”召回再用排序模型把最相关的顶上去那是工程上的选择毕设阶段掌握AND其实够了。在Python中实现AND检索已经简化为先从倒排索引里拿到每个查询词对应的文档ID列表然后做交集操作。写完这一瞬你就能体会到倒排索引的效率优势了。def search(self, query_tokens): if not query_tokens: return [] # 得到每个词的文档ID集合 doc_sets [] for token in query_tokens: posting self.inverted_index.get(token, []) doc_set set([doc_id for doc_id, _ in posting]) if not doc_set: return [] # 有一个词没匹配到直接返回空 doc_sets.append(doc_set) # 取交集所有查询词都出现的文档 result_docs set.intersection(*doc_sets) return list(result_docs)4.2 排序算法的设计与优化拿到候选文档集合后下一步就是排序。最开始我直接用“匹配词数量”排序也就是谁的文档里包含的关键词种类更多谁排前面。这个策略在只有一个查询词时完全失效因为所有人的匹配词数量都一样。后来我引入了经典方案把每个查询词的TF-IDF分值相加作为文档的最终相关度。def rank_docs(self, candidate_docs, query_tokens, doc_token_count_map): scores {} for doc_id in candidate_docs: total_score 0.0 for token in query_tokens: total_score self.compute_tfidf(token, doc_id, doc_token_count_map[doc_id]) scores[doc_id] total_score # 按得分降序排列 ranked_docs sorted(scores.items(), keylambda x: x[1], reverseTrue) return ranked_docs这个算法的效果怎么样直观地说如果一篇文章在开头、正文、结尾等多个位置多次出现“Python”和“爬虫”且这些词在你的整个文档集中不算特别烂大街那它的得分就会很高排名自然靠前。这个排序算法虽然朴素但已经是“基于内容的检索排序”的标准范式了。优化的方向我调研过很多比如给标题的匹配词加更高的权重标题权重系数2.0因为文章标题往往是内容的浓缩比如引入文档长度归一化防止长文刷分比如引入PageRank需要链接关系数据爬虫模块里可以顺手抽取外链。我实际做了标题加权和长度归一化效果提升非常明显推荐大家至少做到这一步。4.3 检索接口与前端展示后端我用的是Flask一个轻量级的Web框架。接口设计遵循RESTful风格前端用一个简单的HTML页面 fetchAPI完成异步搜索。搜索框、结果列表、耗时统计、结果数量四要素一个不能少。app.route(/api/search, methods[GET]) def api_search(): query request.args.get(q, ) start time.time() results search_engine.search(query) elapsed time.time() - start return jsonify({ query: query, elapsed_ms: round(elapsed * 1000, 2), total_results: len(results), results: results[:50] })前端展示里有一个容易被忽略的点关键词高亮。把搜索词在结果标题和摘要中高亮显示会极大地提升用户体验。实现方式很简单拿到查询词列表后把结果文本中的这些词用HTML标签包裹加上突出颜色。注意高亮时的分词粒度要和查询一致否则会高亮不上。还有一个经验是结果页里一定要显示搜索耗时。这不仅是为了好看更是为了向答辩老师直观展示倒排索引的查询性能。我做的毕设项目中在5000篇文档的索引下一次搜索在10毫秒以内完成这种“肉眼可见的快”比任何PPT上的性能对比图都有说服力。5. 常见问题与排查技巧实录5.1 爬虫模块的典型问题问题1抓回来的网页全是二进制乱码。原因通常是响应内容被gzip压缩了而你没有解压。requests库其实自带解压功能但如果你用Session且手动处理了响应流就可能绕过这层。排查方法是打印resp.headers.get(Content-Encoding)看到gzip就说明需要解压。request库正常情况下会自动处理这个问题多数出现在你把streamTrue打开之后又手动读取了原始流。问题2XPath定位不准确匹配不到链接。很多同学直接复制浏览器F12里看到的XPath结果在代码里跑不通。原因是浏览器里复制出来的XPath往往包含了很多div[2]/div[3]这样的层级索引页面结构稍微一变化就失效。我的建议优先用相对路径和属性定位比如//a[contains(href, article)]这种写法鲁棒性好得多。另外记得用urljoin拼接相对链接HTML里的href/foo是相对路径不拼接的话你抓回来的URL全是残废的。问题3爬虫越跑越慢。大概率是请求超时设置太长或者没有限制单个域名的并发请求。我后来加了每域名延时队列每个域名最多每2秒处理一个请求速度确实慢了但稳定性极大地提升了。5.2 索引与检索的排查思路问题1搜索一个肯定存在的内容却返回空结果。最常见的坑是查询预处理和索引预处理的流程不一致。比如你索引时把小写和大写归并了Python转成python但查询时没有做同样的转换。很多同学喜欢“先跑通再优化”结果优化了一边忘了另一边。排查时用同样的输入分别在索引代码和查询代码里跑一遍对比输出Token列表是否一致。问题2检索结果的排序不符合直觉。比如搜“Python”时一篇只提到一次“Python”的文章比一篇深入讲“Python”的文章排得还靠前。这时候要检查IDF的计算。如果包含“Python”的文档只有2篇而你要搜的文档集合总共只有3篇那idf log(3/2) 0.405几乎没起到区分作用。这就是文档集太小导致的IDF失效。解决方法是扩充文档集或者把IDF分母里包含该词的文档数做平滑处理。问题3检索速度越来越慢。如果索引数据量上来了但检索还是几十毫秒甚至几百毫秒八成是你在检索时做了重复计算。比如每次查询都把整个索引重新load一遍或者把TF-IDF计算放在查询链路里而不是构建时预先算好。正确的做法是索引构建时就把每个词在每个文档中的TF-IDF预计算好存下来查询时只做查表求和不做任何乘法和开方运算。5.3 性能优化与扩展方向毕设答辩时老师最喜欢问的问题是“如果让你继续做你会怎么优化”这里给你三个方向既能展示你的思考深度又不会给自己挖坑方向一引入向量空间模型VSM和余弦相似度。把每个文档表示成一个词向量查询也变成一个词向量两者夹角越接近说明越相关。这个思路是TF-IDF的自然延伸代码实现也就几十行但写进论文里能显著提升理论高度。方向二构建多级索引加速查询。对倒排索引按照文档ID排序存储可以方便做跳表加速skip pointer。用户在查询时先在热词索引里捞结果冷门词再走全量索引降低耗时。这个方向涉及算法和数据结构的知识深度。方向三搜索结果缓存。用户搜索的词频分布高度倾斜一小部分热门查询占据了绝大多数流量。把热门查询的结果缓存到内存里能大幅减少重复计算。这个方向明显有工程实践的价值而且容易和Redis等知识点联动起来。在我实际做项目的过程里把爬虫数据量从2000篇扩展到1万篇时明显感受到存储和速度的数据压力。当时我也想过用数据库比如MySQL或SQLite来存倒排索引后来发现Python的json存储和加载在1万篇文档下仍在毫秒级就没多折腾。如果你想要一个更加“正式”的版本来应对答辩也可以用SQLite存词项和倒排列表顺便展示一下你的数据库设计能力也是加分项。结语 | 关于这个项目的一些个人体会最后说点实在的。做这个毕设项目技术上我能总结的东西很多但最大的收获反而不是技术本身。搜索引擎这个题目它像是一个“算法放大器”你在数据结构课上学的每一种抽象哈希、树、图在搜索引擎里都有真实的、迫切的用武之地。以前我学布隆过滤器觉得是为了考试当我看到爬虫死循环的那一刻我才明白它解决的到底是什么问题。当时踩过的坑和调整后的经验现在复盘后整理成下面这几个建议给正在做类似项目的同学作为参考不要一开始就追求“大而全”。先抓100个网页跑通整个搜索流程然后再逐步扩展数据量。你要知道整个系统能够“转起来”带给你的信心远比你闷头优化某个模块要大得多。写代码时养成“模块可单独验证”的习惯。爬虫抓下来的数据是否完整、分词结果是否正确、索引是否可加载——每一步都要能独立验证。项目后期的调试时间几乎都花在“回溯定位是哪一层出了问题”上模块验证能帮你节省至少一半时间。项目进度要及时备份。第一次我写完索引构建代码后清理了爬虫缓存发现索引文件被误删了整个索引要重新构建。从那以后我都是把关键模块的产出物备份到不同目录这个习惯帮我在答辩前避免了很多麻烦。搜索这个领域表面上是技术工程实际上还牵扯到对“用户意图”的理解对信息的组织方式等等挑战。你的Python毕设搜索项目做完之后如果对这个领域还保持着兴趣往Elasticsearch的方向了解就是一个很好的延伸选择。毕竟从自己写一个“五脏俱全”的搜索引擎开始你会对检索这件事形成更深层的肌肉记忆这个东西的价值是长远的。
返回列表