☰
Python+Jieba+Whoosh:从爬虫到毫秒级中文全文搜索实战
2026/10/15 5:06:50 网站建设 项目流程

那阵子我在整理某个技术教程站点上的几百篇文章,想搜“爬虫 反爬 策略”却没一个像样的入口,文件夹翻了几轮后就开始烦躁。痛定思痛,我花了几天用 Python 搭了一套完整的小系统:爬虫负责把散落的文档收集起来,Jieba 负责把中文拆成能检索的词,Whoosh 负责把这堆词建成倒排索引,最后得到一个响应稳定在几十毫秒的中文全文检索服务。这套方案不依赖任何重型服务,装完依赖就能跑,特别适合想要给个人知识库、站内文档或小规模内容站点加搜索功能的人。

这篇文章会把整条链路完整讲一遍,从数据采集、正文清洗、中文分词、索引设计到搜索接口和性能调优,每一步都写清楚“为什么这么做”。

1. 整体设计与技术选型

1.1 这个项目到底解决什么问题

先说清楚需求边界。我要做的是一个面向中文文本的站内搜索引擎,数据来源是某个公开站点上累计的大约两万篇文章。这个规模有个明显特征:单篇文档只有几KB到几十KB,但总量加起来有几百MB,普通的字符串匹配已经没法用了,因为你需要知道“爬虫”和“反爬”这两组词同时出现在哪些文档里,还要按相关度排序,传统数据库 LIKE 查询在这个场景下既慢又不好排序。

这个场景其实很适合全文检索。搜索引擎的核心思路不是去文档里逐字找,而是牺牲一点索引构建的时间,提前把每篇文档切词、排序、建成一张“词到文档”的映射表。查询的时候直接查这张表,再用相关度算法打分。整个过程跟查新华字典很像:你不必从第一页开始翻,只需要按拼音定位到那一页,再在页内找对应条目。

我的目标很具体:用户输入一段中文,系统能在几百毫秒内返回按相关度排序的标题列表,最好还能按时间倒序。这个目标听起来不大,但里面涉及到爬虫采集、编码处理、分词、索引存储、查询性能一套完整的问题链,每一步都会成为瓶颈。

1.2 为什么是 Whoosh + Jieba,而不是 Elasticsearch

聊这个方案之前,一定会被问到为什么不直接用 Elasticsearch。我说一下我的取舍理由。ES 确实强,分布式、高可用、生态完善,但对我来说有三个问题:第一,它依赖 Java 运行时,部署成本对一个个人项目来说偏高;第二,它是独立服务,还得处理数据同步、连接管理、内存配置这些额外问题;第三,我这两万篇文档,ES 的优势完全发挥不出来,属于杀鸡用牛刀。

Whoosh 是纯 Python 实现的全文检索引擎,pip 安装后直接 import 就能用,索引以文件形式存放在本地目录里,不需要额外进程,也没有网络连接。它在设计上就是给中小规模场景用的,官方文档也说对大型生产环境有局限。但对我这种几万到几十万文档的知识库场景,它非常合适。

真正需要动脑子的是中文分词。Whoosh 内置的 ChineseAnalyzer 其实只是基于简单词典做正向匹配,对专有名词、网络词汇的处理不够灵活。而 Jieba 的词典更全、支持自定义词表、支持精确模式与搜索引擎模式,而且和 Whoosh 的组合方案已经比较成熟。所以最终方案就是:用 Jieba 做分词,把分词后的词流交给 Whoosh 建立索引和查询。这套组合在中小规模中文全文检索场景下,可以说是最省心的黄金搭档。

1.3 数据链路全貌

整个系统的数据流可以分成五个环节。

第一步是采集。用 requests 请求目标页面,拿到 HTML 后用 lxml 解析出标题和正文。

第二步是清洗。去掉 HTML 标签、空白字符、广告导航残留,得到纯净文本。

第三步是去重入库。标题相似或正文重复的要过滤掉,把有效文档存进 SQLite。

第四步是索引构建。从数据库读文档,用 Jieba 分词,Whoosh 按 Schema 建索引。

第五步是检索。用户输入关键词,经 Jieba 分词后去索引里查,返回结果。

每个环节之间通过数据表或磁盘文件解耦。这样即使爬虫只跑了一半,索引仍然可以基于已经入库的数据构建,互不影响。这也是我比较推荐的做法,别把爬虫和索引写进同一个进程里强行耦合,分开跑你的调试成本会低很多。

2. 数据收集与清洗

2.1 爬虫部分的合规边界与设计

先明确一个原则:本项目的爬虫只采集公开页面,严格遵守目标站点的 robots.txt 和版权说明,请求频率控制在很低的水位,数据仅用于个人学习和研究。这不是套话,是实际操作前必须做的基本功。

动手之前我会先用 requests 拉一下 robots.txt,看有哪些路径不允许爬取,然后设置一个合理的 User-Agent 和请求间隔。简单来说,如果你要采集的站点是个人博客或小型内容站,频率控制在每秒一次以内基本不会对对方造成压力。再把超时时间设短一点,避免某个慢请求长时间占用线程。

这个项目的爬虫部分我采用了很朴素的多线程队列模型:主线程读一个 URL 列表,工作线程负责请求和解析,结果写入一个待清洗队列。这里没有引入 Scrapy,因为我只需要处理一个站点的固定页面结构,requests + lxml 足够的轻量。如果目标是多站点泛采集,我会考虑 Scrapy,但那种复杂度对当前场景没有必要。

2.2 抓取与正文提取

抓取逻辑的核心代码不长,真正花时间的是页面结构适配。每个站点都有自己的 HTML 结构,有的站点标题在 h1,有的在 meta property="og:title",正文所在的 div class 也不一样。为了减少对单一选择器的依赖,我同时写了几个候选 Fallback 策略。

代码大致是这样的:

import requests from lxml import html, etree headers = { "User-Agent": "Mozilla/5.0 (compatible; MySearchBot/1.0; +https://example.com/bot)" } def fetch_page(url): resp = requests.get(url, headers=headers, timeout=10) resp.raise_for_status() if resp.encoding and resp.encoding.lower() in ("gbk", "gb2312"): resp.encoding = "gbk" else: resp.encoding = resp.apparent_encoding return resp.text def extract_content(html_text): doc = html.fromstring(html_text) title = ( doc.xpath("//h1[1]/text()") or doc.xpath("//meta[@property='og:title']/@content") or [""] )[0].strip() nodes = doc.xpath("//div[contains(@class,'article')]//p//text()") if not nodes: nodes = doc.xpath("//div[contains(@class,'content')]//text()") content = "\n".join([t.strip() for t in nodes if t.strip()]) return title, content

这里有个细节很多人会忽略:页面编码。中文网站经常用 gbk 或 gb2312,如果 response 没有正确解码,后面清洗出来的就是乱码。 requests 拿到的原始编码可能不准,我习惯用apparent_encoding做兜底,这个字段是 requests 通过字节流内容推测的,对中文页面命中率很高。

2.3 清洗去重与落库

正文抽取出来之后,字符串里通常残留不少噪声。我的清洗流程分三步:先去掉多余空白字符和换行,再用正则把一些常见的“上一篇/下一篇/相关推荐”这类导航文字去掉,最后按标题哈希做去重。如果同一标题出现多于一次,基本可以判定是重复内容,直接丢弃。

正文相似度去重我用了一个更简单的思路:取正文前 100 个字符计算哈希值。如果两篇文章前 100 字完全相同,基本可以断定内容是同一篇。这个方法在大多数场景下够用,又能避免做全文相似度计算带来的额外开销。

清洗之后的数据落入 SQLite,建表语句很简洁:

CREATE TABLE IF NOT EXISTS docs ( id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT UNIQUE, title TEXT, content TEXT, created_at DATETIME );

用 SQLite 而不是 MySQL 的原因很直白:零运维、单文件、跨平台,几万条记录完全扛得住。后面跑索引时用SELECT分页读出来就行。这个库不是给用户查询用的,它是给索引构建提供数据源,职责单一,不需要更强的性能。

3. 索引构建:全文检索的灵魂

3.1 倒排索引与中文分词的配合

建造索引之前,我建议先把倒排索引这个概念消化掉。普通存储是“文档->词”的结构,一篇文档存着一串词;倒排索引反过来,它存储“词->文档列表”的关系。拿“爬虫”这个词举例,索引里会记录它出现在文档 1、文档 5、文档 37 中。查询时直接说“给我包含‘爬虫’的文档”,索引引擎拿起词表查一下就能返回文档 ID 列表。

中文和英文在这里的差异非常大。英文按空格和标点天然分词,中文没有这个边界。“爬虫实战”这四个字,英文思维下是一个完整 token,中文思维下可能是“爬虫”“实战”两个词,也可能是“爬”“虫”“实战”“战”,取决于你怎么切。切得不好,查“爬虫”就找不到“爬虫实战”这篇文章。这也是 Jieba 的价值所在,它用统计模型和词典把中文切得更接近语义边界。

这里还要注意一点:Whoosh 建索引和查询必须使用相同的分词方式。如果建索引时用 Jieba 切成“爬虫/实战”,查询时用默认分析器把“爬虫实战”当成一个完整词,那必然什么都搜不到。这个错误特别隐蔽,我见过不少人在开发环境明明有数据,搜索却返回空,最后排查半天发现是两边分词器不一致。

3.2 Schema 设计:字段和类型怎么选

Whoosh 的索引结构叫 Schema,相当于数据库表结构。每个字段都需要明确类型,类型决定了这个字段能不能分词、能不能排序、要不要存储原始值。我的 Schema 设计如下:

from whoosh.fields import Schema, ID, TEXT, DATETIME from whoosh.analysis import Tokenizer, Token schema = Schema( doc_id=ID(unique=True, stored=True), url=ID(stored=True), title=TEXT(stored=True, analyzer=jieba_analyzer, field_boost=2.0), content=TEXT(analyzer=jieba_analyzer), created_at=DATETIME(stored=True, sortable=True), )

逐个字段解释一下。doc_id是唯一标识,我直接取 SQLite 里的主键,配合unique=True可以在增量更新时做替换。url存的是源地址,检索结果跳转时需要。title和content都用TEXT类型,指定自定义的 Jieba 分析器。注意我给 title 设置了field_boost=2.0,意味着标题命中的文章相关度默认比正文命中的高,这在搜索结果里非常实用。

created_at字段设计成DATETIME并且sortable=True,这样排序可以直接按发布日期执行。如果只想按相关度排序,这个字段可以不加,但在内容搜索场景中,用户往往既想要相关度高、又要内容新,保留时间字段总没错。

还有一点容易被忽视:TEXT字段的stored属性。如果设为 False,Whoosh 索引里不会保留原始文本,查询结果也就读不出标题、正文内容。做站内搜索一般要把标题和摘要存进去,所以标题我设置stored=True,正文一般不需要全部存储,只存一个片段或干脆不存。我的实际做法是正文只进索引不存储,需要正文时再根据 doc_id 回查 SQLite,这样索引体积能小很多。

3.3 自定义 Jieba 分词器

Whoosh 的分词器本质是一个生成器,输入一段文本,输出一串 Token 对象。自定义 Jieba 分词器其实不难,核心代码如下:

import jieba from whoosh.analysis import Tokenizer, Token STOP_WORDS = {"的", "了", "和", "是", "在", "有", "就", "都"} class JiebaTokenizer(Tokenizer): def __call__(self, value, positions=False, chars=False, keeporiginal=False, removestops=True, start_pos=0, start_char=0, **kwargs): for word in jieba.cut(value): if removestops and word in STOP_WORDS: continue word = word.strip() if not word: continue token = Token() token.text = word.lower() if positions: token.pos = start_pos start_pos += 1 if chars: token.startchar = start_char token.endchar = start_char + len(word) start_char += len(word) token.original = word yield token jieba_analyzer = JiebaTokenizer()

这个类有三个关键点需要解释。第一,positions和chars是 Whoosh 用来支持短语搜索和位置计算的参数,我的实现里做了兼容处理,这样保留开发空间。第二,text 统一转小写,避免内容里出现“Python”和“python”在索引里被当成两个不同词。第三,停用词表放了一批高频无意义词,能有效减少索引体积。

还有一个容易忽略的技巧:Jieba 加载词典需要时间,如果你每次启动都要重新 import jieba,第一次建索引会有明显的等待感。解决办法是把词典预加载的全局状态缓存住,或者用一个单例。更简单的做法是首次建索引之后,把分词结果直接落到 Whoosh 索引里,后面查询走索引就不会再走 Jieba 的主流程了。这个在后面性能部分还会提到。

3.4 批量建索引与增量更新

建索引最忌讳逐条add_document+ 反复commit。Whoosh 每次 commit 都要把内存中的段合并并写盘,频繁 commit 会把大量时间浪费在磁盘 IO 上。我的做法是先拿到IndexWriter,循环add_document,最后一次性commit,这样 Whoosh 会用一个较大的段把数据整批写入。

from whoosh.index import create_in, open_dir def build_index(db_rows, index_dir): if not os.path.exists(index_dir): os.mkdir(index_dir) ix = create_in(index_dir, schema) else: ix = open_dir(index_dir) writer = ix.writer(limitmb=256, procs=2) for row in db_rows: writer.add_document( doc_id=str(row["id"]), url=row["url"], title=row["title"], content=row["content"], created_at=parse_datetime(row["created_at"]), ) writer.commit()

这里limitmb控制 Writer 的内存缓冲上限,procs是并行分词进程数。对 2 万篇文档,这个脚本在我的笔记本上大概几十秒完成,比逐条提交快了好几个数量级。

增量更新是我后来补充的需求。新建索引很简单,但后续爬虫又抓了新文章,总不能每次都全量重建。Whoosh 提供了update_document方法,它会根据unique=True的字段判断:如果 doc_id 已存在则替换,否则新增。配合 SQLite 里新增的数据,增量更新大概就是下面这样:

def update_index(new_rows): ix = open_dir(index_dir) writer = ix.writer() for row in new_rows: writer.update_document( doc_id=str(row["id"]), url=row["url"], title=row["title"], content=row["content"], created_at=parse_datetime(row["created_at"]), ) writer.commit()

增量更新跑完以后,索引文件里会多出不少小的段文件。这时候通常要执行一次段合并优化,等积攒到一定量后再做一次ix.optimize(),把零散段合并成一个整体,查询性能会明显回升。

4. 检索层实现与毫秒级性能优化

4.1 查询解析器与搜索函数

索引建好,检索层就水到渠成了。我封装了一个 search 函数,输入查询语句,返回结果列表。

from whoosh.qparser import MultifieldParser, OrGroup from whoosh.index import open_dir def search(keyword, limit=20, sort_by_time=False): ix = open_dir(index_dir) qp = MultifieldParser(["title", "content"], schema=ix.schema, group=OrGroup) query = qp.parse(keyword) with ix.searcher() as searcher: if sort_by_time: results = searcher.search(query, limit=limit, sortedby="created_at", reverse=True) else: results = searcher.search(query, limit=limit) return [{ "title": hit["title"], "url": hit["url"], "score": hit.score, } for hit in results]

这个函数虽然短,但有两个设计决策会影响体验。第一,我用MultifieldParser同时查询 title 和 content 两个字段,借助 title 的 field_boost 实现“标题命中优先”,效果比只搜正文或者只搜标题都自然。第二,我把默认分组设成了OrGroup,意味着用户输入“爬虫 反爬”时,返回的是包含“爬虫”或“反爬”任意一个词的文档。这能保证召回率,不会因为某个词不被分词而返回空。

如果产品策略更偏向精确匹配,可以改成AndGroup,但中文搜索和英文不同,用户输入“爬虫实战教程”时往往期待的是三个词同时出现,并不在乎顺序;改成 And 反而可能因为分词差异误伤结果。折中方案是保留 Or,但在排序时把命中词更多、相关度更高的结果排到前面,Whoosh 默认的 BM25F 相关度算法本身就会这么做。

4.2 排序、权重与召回语义

关于排序我要多说几句。Whoosh 默认用 BM25F 模型计算相关性,这个模型综合考虑词频、文档长度、逆文档频率,和搜索引擎常用的 BM25 一脉相承。比如“Python”这个词出现在很多文章里,逆文档频率就低,对相关性贡献小;而“Whoosh”出现得少,一旦命中就说明文章大概率相关,排序时会往前提。

标题字段的field_boost=2.0也是在这些权重上叠加。实际使用中我观察到的效果是:搜“数据库索引”,标题中直接包含“数据库”或“索引”的文章排名明显靠前,正文里只有零散词反而排在后面。这种对用户很友好,因为他们看到标题就能判断结果是否有效。

我还实现了一个可选的时间排序模式,适合“最新内容优先”的场景。不过要提醒一点:sortedby="created_at"会放弃相关度分数排序,此时 BM25F 计算的分数没有意义。如果产品上要求相关度和时间兼顾,通常的做法是用相关度排序,再对近期内容做时间衰减加权。这个动态加权在 Whoosh 里需要自定义 Scoring Function,工作量不小,对于我的场景暂时没有引入。

4.3 毫秒级从何而来

我在标题里写了“毫秒级”,那这个毫秒到底从哪来?很多人以为全文检索一定很快,实际上如果索引文件碎片化、查询逻辑乱写,几十万文档也可以跑出几秒延迟。我在这个项目里做了四件事把搜索压到几十毫秒内。

第一,尽量复用 Searcher 对象。Whoosh 的open_dir和searcher()调用都有成本,如果每个请求都重新打开索引并构建搜索器,性能基本被打回原形。我的做法是在服务启动时全局打开一次索引,把 Searcher 对象缓存住,需要时直接使用。索引有更新时再重新打开或显式刷新。

第二,控制返回数量。搜索结果默认只取 20 条,这叫 Top-N 检索。搜索引擎不是把所有匹配文档都列出来再排队,而是用堆结构只维护前 20 个最佳结果。limit=20和limit=1000的耗时差异巨大,但用户感知不到后者的价值。

第三,定期执行段合并优化。索引段文件多的时候,查询要被拆到多个段上分别执行再合并结果,这个过程比单段查询慢很多。我在增量更新累计超过 5000 条之后手动ix.optimize()一次,实测优化后的单次查询普遍从几十毫秒降到十几毫秒。

第四,在 Jieba 分词侧做缓存。Whoosh 建索引时已经对全文做过一次分词,但查询时 Jieba 还要对用户输入切词。这个动作通常在 1-5 毫秒内完成,但有时系统刚启动、词典冷加载时会卡顿。我把 Jieba 的词典初始化放到服务启动阶段,并用一个字典缓存近期查询的分词结果,重复查询完全跳过分词环节。

我整理了一个简单的优化前后对比数据供参考,来自该项目的模拟测试:

数据量优化前单次查询优化后单次查询
1 万文档60-110ms10-20ms
3 万文档180-320ms20-45ms
5 万文档350ms 以上35-70ms

这个数据说明,“毫秒级”并不是标题党,但需要配合正确的索引维护策略。如果你发现搜索突然变慢,先怀疑段文件,再怀疑 Searcher 没有复用,千万不要急着换搜索引擎。

5. 常见问题与避坑实录

5.1 高频问题与原因

整个开发过程中我踩了不少坑,有些问题在 Whoosh 社区也经常被问。我把高频问题整理成一个速查表,供遇到类似情况的人快速定位。

现象直接原因解决方案
搜索中文返回空结果分词器不一致或停用词过滤过度确认建索引与查询共用同一 analyzer,检查停用词表
一条 commit 后才查询到数据没有重新打开 Index 或 Searcher改用 writer.commit() 后重新 open_dir,生产环境复用 Searcher 并定时刷新
搜索批次越来越慢索引段文件过多执行 ix.optimize() 合并段
查询带-()等符号报错QueryParser 把符号当语法输入处理时使用 QueryParser 的 escape 方法
索引进程崩溃锁死多进程同时写同一个索引目录用单写多读模型,写入时加锁
索引体积异常膨胀重复插入未用 update_document给 doc_id 设置 unique=True 并改用 update_document

这里面最让我头疼的是第一个问题。有一次我自己改了 Jieba 停用词表,把“实战”误加进了停用词,导致包含“实战”的查询全被过滤掉,一篇文章都搜不到。排查方法很简单:直接在 Python 里list(jieba.cut("爬虫实战"))看分词结果,再对比索引里的词条,一次就能锁定问题。

5.2 可以复制的问题排查清单

如果你也在这个栈上遇到迷之问题,我建议按下面的顺序排查,比盲目改代码高效很多。

第一步,验证分词。分别在 Python 环境里执行jieba.cut和jieba_analyzer,确认输入与输出的一致。分词是索引和查询的公共地基,这里出问题一切都白搭。

第二步,验证索引内容。Whoosh 提供了一个 snippet 字段方法,但更直接的办法是从索引里随机读几条文档,确认标题和正文确实被写入且分词后词条存在。

第三步,验证查询语句。直接打印qp.parse(keyword)的结果,看看 Whoosh 到底把用户输入解析成了什么结构。如果发现一个中文词被整体当成英文 token,八成是自定义 analyzer 没有被正确绑定到字段上。

第四步,验证排序。如果结果看起来“不在预期位置”,先关闭时间排序看相关度排序的表现,确认是算法原因还是自定义权重问题。

第五步,验证性能瓶颈。用python -m cProfile跑一遍查询脚本,看看时间是花在search还是sorter阶段,再决定要不要优化段文件结构。

这套排查逻辑不仅适用于 Whoosh,对任何全文检索引擎都通用。分词、索引、查询、排序四个环节按序拆解,几乎所有问题都能定位到具体某一层。

5.3 代码之外的几点心得

最后分享几个代码之外的经验。第一,爬虫采集的数据质量直接决定搜索引擎的天花板。如果正文抽取时带着大量导航、广告、评论噪声,倒排索引里就会混入一堆无意义词,搜索相关度立刻崩。宁可少采集几篇,也要保证每篇的正文是干净的。

第二,搜索体验不能只看速度。当时我把单次查询压到十几毫秒,但同事试用后仍然觉得“不好用”,因为输入“Python爬虫”后返回的结果里不少是旧文章。后来我加了时间筛选入口并调整了标题权重,体验才有改观。这个教训说明,性能只是体验的一部分。

我在实际使用中还有一个习惯:维护一份查询样例集,大概 30 条有代表性的搜索词。每次调整分词器或排序规则后,就跑一遍样例,人工对比结果变化,防止“改一个 bug 带出三个 bug”。如果你的项目也涉及检索逻辑,建议从一开始就保留这种回归测试,它能给你省下大量调试时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询