
简介一个基于Python的搜索引擎设计与实现源码包面向有一定Python基础、希望系统掌握信息检索与搜索引擎搭建的开发者完整覆盖数据抓取、网页预处理、文本分析、倒排索引、查询处理与结果展示等环节。压缩包内共129个文件39个.py脚本对应爬虫、索引、排序等核心逻辑27个.pyc便于直接运行调用HTML/CSS/JS文件构成展示前端CSV、SQLite等承担数据存储整体大小仅1.07MB。项目结合Scrapy/BeautifulSoup爬虫、jieba中文分词、sklearn的TF-IDF、Whoosh索引、BM25相关性排序以及Flask/Django界面形成一套可直接运行的搜索系统。已有1629人学习适合作为搜索引擎课程设计、Python综合实训或信息检索入门项目的参考材料。借助源码与配套说明开发者能快速理解各模块间的协作关系并针对分词、排序或前端展示进行二次开发与功能扩展。 搜索引擎这个东西平时用着没什么感觉真正自己动手用Python写一遍才会发现它拆开了就是一个采集、清洗、索引、查询的四段式流水线。这篇文章是我实现一个mini搜索引擎的全过程记录从爬虫采集、中文分词、倒排索引到BM25排序和Flask查询页面每一步都有可直接运行的代码。适合想搞懂搜索原理的Python开发者、准备做垂直搜索的团队以及面试前想快速串一遍检索知识的人。下面进入正题我会把设计和踩坑一起讲不绕弯子。1. 搜索引擎搭建前的整体设计思路1.1 搜索引擎的核心链路拆解搜索引擎本质上做的是预整理。你可以把整个系统想象成一个图书馆采集是到处收书清洗是把书的腰封、广告页去掉、编好页码索引是图书管理员做卡片目录查询是读者报书名管理员通过目录快速定位书架。没有索引的情况下每次查询都得全馆跑一遍那就不是搜索引擎是遍历器。所以我动手之前先把链路画了出来网页采集 - HTML解析 - 文本清洗 - 分词 - 构建倒排索引 - 查询解析 - 相关性排序 - 结果返回。整条链路里最难的不是爬虫也不是Flask接口而是倒排索引和排序模型。很多教程只讲爬虫爬到一大堆数据就停手了等要搜索的时候傻眼——缺的就是中间那两环。记住一句话索引是搜索引擎和爬虫的分界线没有索引爬下来的数据只是一堆没法查的废料。这个设计思路也决定了开发顺序。我会先写采集和清洗保证原始数据质量然后构建索引和检索排序这是引擎的大脑最后才套上Flask做查询入口。每一步都能独立验证出现问题容易定位不会到快上线才发现是源头的数据格式出了问题。1.2 技术栈选型和取舍逻辑这个项目我选的环境比较朴素Python 3.10requests发HTTP请求BeautifulSoup解析HTMLjieba做中文分词Flask提供查询接口。这里有个重要的选择没有上Scrapy也没有接Elasticsearch。原因很简单我要验证的是搜索引擎算法本身而不是验证自己会调框架。把每个核心环节用普通库手写一遍踩过坑之后再回头看Elasticsearch那些高级特性你才能真正理解它为什么那样设计。这个取舍对业务场景也很友好。垂直搜索场景比如站内文档搜索、商品搜索、资料库检索数据量在十万级以下时这套mini搜索引擎完全能顶住。真要涉及百万级文档、需要分布式前面的代码就变成了迁移到Elasticsearch的逻辑蓝本你早就知道自己的query该从哪里来、排序公式在算什么的。换句话讲手写一遍不是做轮子是在给未来用重型工具打地基。还有一个小提醒环境准备阶段建议把Python和编辑器一次配好我用的是VS Code Python插件调试和看变量都方便。如果是从零开始先确认python和pip能跑通再往下走后面所有代码都依赖这套运行环境。2. 网页采集模块给搜索引擎提供素材2.1 采集器的工作原理与实现采集模块我选择从一个种子页面开始广度优先爬取。用deque维护待抓取URL队列每次弹出一个URL请求页面解析出里面的链接再放回队列。代码很直白from collections import deque from urllib.parse import urljoin, urlparse import time import requests from bs4 import BeautifulSoup def simple_crawler(seed_urls, max_pages100): queue deque(seed_urls) visited set() page_data {} while queue and len(visited) max_pages: url queue.popleft() if url in visited: continue try: resp requests.get( url, timeout10, headers{User-Agent: Mozilla/5.0} ) resp.encoding resp.apparent_encoding except Exception as e: print(fetch failed:, url, e) continue visited.add(url) soup BeautifulSoup(resp.text, html.parser) page_data[url] soup for a in soup.find_all(a, hrefTrue): next_url urljoin(url, a[href]) if urlparse(next_url).netloc urlparse(seed_urls[0]).netloc: queue.append(next_url) time.sleep(0.5) return page_data, visited我实测下来有几个点必须处理否则爬虫跑不了半小时就废掉。一是请求头里的User-Agent必须设置很多站点会拦截默认UA。二是网页编码不能偷懒用默认值resp.encoding resp.apparent_encoding这个操作对中文页面尤其重要不然后面分词全是乱码。三是time.sleep一定要加把抓取频率降下来既是礼貌也是保护自己不然很快会被限制访问。2.2 链接队列与去重策略如果不加控制地把链接往队列里塞会遇到两个典型问题死循环和重复下载。同一个页面可能被几十条链接指到不查重的话同样的内容会被反复抓取磁盘和带宽都吃不消。我用visited集合存储已经处理过的URL进入队列之前先做一次判断。但这里有个很容易踩的坑URL去重不能只看字符串是否完全一致。很多URL只是参数顺序不同或者多了一个跟踪参数实际上指向同一个页面。所以我先把URL做规范化再去重from urllib.parse import urlparse def normalize_url(url): parsed urlparse(url) # 去掉锚点 if parsed.fragment: parsed parsed._replace(fragment) # 域名转小写 parsed parsed._replace(netlocparsed.netloc.lower()) # 去掉常见的跟踪参数 query_parts [ item for item in parsed.query.split() if item and not item.split()[0].lower() in ( utm_source, utm_medium, utm_campaign, from ) ] parsed parsed._replace(query.join(query_parts)) return parsed.geturl()规范化之后爬虫抓到的URL去重率能提高不少。我在实际项目中跑一个中型站点规范化和去重后大约能减少三分之一以上的重复请求效果非常明显。提示生产环境的爬虫去重量级一上来内存里放set可能不够建议改用布隆过滤器Bloom Filter。我这套是教学实现先用set把逻辑讲清楚将来数据量大了换数据结构即可。3. 文本清洗与中文分词3.1 从HTML到纯文本的归一化处理搜索引擎的检索质量很大程度取决于这一步做得干不干净。HTML页面里塞满了导航、广告、脚本和底部版权信息这些对搜索没有帮助反而是噪音。我先把script、style、nav、footer这类标签整体拆掉再提取title、meta description和剩余正文合并成一段干净的文本。import re def extract_text(soup): for tag in soup([script, style, nav, footer, aside]): tag.decompose() title soup.title.get_text(stripTrue) if soup.title else meta_desc meta soup.find(meta, attrs{name: description}) if meta and meta.get(content): meta_desc meta[content].strip() body soup.get_text( , stripTrue) text .join([title, meta_desc, body]) text re.sub(r\s, , text) return title, meta_desc, text这里最容易被忽略的是空白字符处理。HTML源码里缩进和换行特别多直接get_text会得到一堆空行和碎片分词时会出现大量空token。最后一步用正则把所有连续空白压缩成单个空格这个细节对后面的分词效果影响很大千万别省。3.2 中文分词选型与倒排索引构建中文和英文不一样词与词之间没有天然空格所以必须借助分词工具。我选jieba纯粹是省事精度对mini引擎来说足够。但注意一个细节在搜索引擎场景里我推荐用jieba.cut_for_search而不是默认的jieba.cut。两者差异在于cut_for_search会对长词做细粒度切分。比如搜索引擎这词切出来会有搜索、引擎、搜索引擎召回率更高。代价是索引体积变大一点但搜索引擎里召回优先先保证相关内容都搜得到再考虑排序和筛选。分词之后还要过滤噪音。数字、单字、通用停用词都要处理不然倒排索引会被这些词塞满import jieba STOP_WORDS { 的, 了, 和, 是, 在, 我, 你, 他, 这, 那, 都, 而, 及, 与, 或, 一个, 没有, 我们, 你们, 他们, 就是, 但是, 因为 } def tokenize(text): words jieba.cut_for_search(text) result [] for w in words: w w.strip().lower() if len(w) 2: continue if w.isdigit(): continue if w in STOP_WORDS: continue result.append(w) return result停用词表不用一开始就追求完整先建立基础词表跑几个真实查询后不断补充即可。我发现搜索效果变差的时候七成是停用词表没兜住比如一个搞字就可能会让系统把不相关的口语页面都捞出来。4. 倒排索引与检索排序4.1 倒排索引的数据结构设计在讲倒排索引之前先理解什么是正排索引。正排索引就是文档 - 词的映射存了每篇文档里有哪些词。这种方式存起来简单但搜索时效率低因为要遍历所有文档才能找到包含某个词的页面。倒排索引恰好反过来是词 - 文档的映射。查爬虫这个词直接取出所有包含爬虫的文档ID列表速度极快。搜索引擎快就是靠这一步打底的。我用Python的嵌套dict实现了一个迷你倒排索引from collections import defaultdict, Counter class InvertedIndex: def __init__(self): # term - {doc_id: term_frequency} self.postings defaultdict(dict) # doc_id - doc_len self.docs {} def add_document(self, doc_id, text): tokens tokenize(text) freq Counter(tokens) self.docs[doc_id] len(tokens) for term, count in freq.items(): self.postings[term][doc_id] count这里的doc_len记录了每篇文档的总词数后续计算BM25需要它。如果文档量再大一点可以把postings写成词 - 有序数组每个元素是一个(文档ID, 词频)二元组然后按文档ID排序查询时就能用二分或跳表加速。现阶段用Python dict足够逻辑也直观。4.2 TF-IDF/BM25评分实现有了倒排索引只能说明哪些文档包含这个词但没法回答哪篇文档更相关。我用BM25来做相关性排序。BM25是对TF-IDF的一个改进版本它考虑了三个因素词频越高越相关、文档越短越相关、以及一个词的文档频率越高越不稀缺。具体到公式主要控制参数是两个k1用来控制词频饱和的程度b用来控制文档长度的影响力度。我给一个BM25的Python实现import math class BM25: def __init__(self, index, k11.5, b0.75): self.index index self.k1 k1 self.b b self.N len(index.docs) self.avgdl sum(index.docs.values()) / max(1, self.N) def score(self, query_terms, doc_id): doc_len self.index.docs[doc_id] score 0.0 for term in query_terms: postings self.index.postings.get(term, {}) freq postings.get(doc_id, 0) if freq 0: continue df len(postings) idf math.log((self.N - df 0.5) / (df 0.5) 1) denom freq self.k1 * (1 - self.b self.b * doc_len / self.avgdl) score idf * (freq * (self.k1 1)) / denom return scoreBM25的默认参数k11.5、b0.75在绝大多数场景下表现都很好不用轻易调。少数情况下如果检索结果里长文档权重过高可以稍微调大b如果感觉词频没起作用可以调大k1。但每次只动一个参数观察结果变化避免凭感觉乱调。5. 查询解析与搜索接口5.1 查询处理流程用户输入查询词之后内部的处理流程和建索引时高度一致分词、过滤停用词得到查询词列表然后到倒排索引里去找候选文档最后用BM25给候选文档打分按分数排序返回。我把查询和排序封装成独立的函数方便复用def search(query, index, ranker, top_n10): terms tokenize(query) if not terms: return [] # 取并集满足任意一个查询词的文档都算候选 candidates set() for term in terms: candidates.update(index.postings.get(term, {}).keys()) results [] for doc_id in candidates: score ranker.score(terms, doc_id) results.append((doc_id, score)) results.sort(keylambda x: x[1], reverseTrue) return results[:top_n]候选文档集合这里做的是OR合并也就是只要包含任意一个查询词就进入候选池再由BM25决定排名。这种方式召回率高但对那些只命中最不重要的查询词的页面BM25的分数自然会压得很低。如果想要必须包含所有词的语义把candidates的更新从update改成intersection_update即可代价是召回率可能下降。实际搜索引擎更多是混合策略先OR召回再通过BM25把不相关的排下去。5.2 搜索API设计与前端展示查询逻辑跑通后我套了一层Flask来对用户提供服务。这里只需要两个路由一个渲染搜索首页一个接收查询参数返回结果。MVP阶段完全不追求复杂前端一个输入框加一个结果列表就行。from flask import Flask, request, jsonify, render_template app Flask(__name__) app.route(/) def index_page(): return render_template(index.html) app.route(/search) def search_api(): q request.args.get(q, ).strip() if not q: return jsonify({error: empty query, results: []}) results search(q, index, ranker) items [ { doc_id: doc_id, title: doc_meta[doc_id].get(title, ), url: doc_meta[doc_id].get(url, ), score: round(score, 4), } for doc_id, score in results ] return jsonify({query: q, total: len(items), results: items})这里的doc_meta是一个辅助字典保存doc_id到标题和URL的映射展示结果时要用。API返回JSON的好处是后续无论做网页前端还是做App接口都不需要改后端逻辑。前端模板就是简单的HTML表单提交后跳到/search接口查询并渲染结果几分钟就能搭出来。6. 常见问题与业务化避坑6.1 索引持久化与增量更新我最早跑通这个系统时遇到过最尴尬的事花大半天爬了上千个页面索引构建完程序一关所有索引没了第二天还得重新爬。后来我加了一层持久化索引构建完就用json.dump落盘启动时检测到索引文件就直接加载省掉重复采集的等待时间。import json def save_index(index, pathindex.json): payload { postings: {k: dict(v) for k, v in index.postings.items()}, docs: index.docs, } with open(path, w, encodingutf-8) as f: json.dump(payload, f, ensure_asciiFalse) def load_index(pathindex.json): with open(path, r, encodingutf-8) as f: payload json.load(f) index InvertedIndex() index.postings defaultdict(dict, { k: {int(doc_id): freq for doc_id, freq in v.items()} for k, v in payload[postings].items() }) index.docs {int(k): v for k, v in payload[docs].items()} return index增量更新相对简单新抓到的页面直接调用add_document即可倒排索引会新增词项和文档ID。但删除旧页面比较麻烦因为某个词项的posting list一旦有节点失效需要重新整理所以小规模项目我通常不做删除而是定期全量重建索引。要做增量删除的话一个方案是给文档加active标记查询时跳过非活跃文档。6.2 检索质量调试的三板斧检索效果不好时先不要怀疑排序公式而是按顺序查这三处。一是停用词表。分词结果里如果混入大量我们因为但是之类的高频词它们会稀释有效关键词的权重先扩充停用词表。二是短词过滤。分词后长度小于2的token直接丢弃能滤掉大量无意义单字。三是标题加权。用户搜索时页面标题命中的权重应该比正文高。实现起来很简单在建索引时给标题字段的词频做放大比如标题中词频乘以1.5再写入倒排索引或者在BM25 score基础上给标题命中词额外加一个固定小分。这个改动小而有效能明显提升搜索结果的第一屏观感。调试工具上我会先往索引里塞几十篇测试文档然后构造一组查询词逐个检查返回的doc_id是否符合直觉。测试文档的内容一定要能覆盖同义词、近义词和长尾词场景不要只塞python爬虫这种和主题完全一致的文本否则验证不出真实问题。6.3 部署环境的小提醒项目跑到本地正常后如果要部署到云服务器有几点容易忽略。一是Python版本要固定建议用requirements.txt锁住依赖版本二是索引文件和数据文件要分开目录管理方便备份和后续迁移三是如果线上页面数量增长记得把Flask的调试模式关掉改用waitress或gunicorn这类正式服务器启动。部署前的环境检查别节省时间pip安装依赖跑一遍再启动应用确认端口能访问后面就能少折腾。最后再分享一个我个人的实操体会。这个项目真正做完之后我对搜索引擎的理解完全变了。以前觉得百度、Google这类系统是黑魔法写完之后发现核心原理其实就在倒排索引和排序公式里所谓的快和准都是靠合理的预计算把昂贵的事提前做完。如果你也想入门搜索方向别一上来就装Elasticsearch先照着这条链路手写一遍中间踩的几个坑比看十篇教程都值。后续想扩展可以从爬虫调度、query纠错、个性化排序这几个方向往下走每一块的原理都能在这个小系统里找到对应位置。本文还有配套的精品资源点击获取