资讯动态

Python+Jieba+Whoosh:从爬虫到毫秒级中文全文搜索实战

发布时间:2026/10/15 5:06:54 来源:尧图企业网站定制
那阵子我在整理某个技术教程站点上的几百篇文章想搜“爬虫 反爬 策略”却没一个像样的入口文件夹翻了几轮后就开始烦躁。痛定思痛我花了几天用 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 propertyog: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, headersheaders, timeout10) 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[propertyog: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_idID(uniqueTrue, storedTrue), urlID(storedTrue), titleTEXT(storedTrue, analyzerjieba_analyzer, field_boost2.0), contentTEXT(analyzerjieba_analyzer), created_atDATETIME(storedTrue, sortableTrue), )逐个字段解释一下。doc_id是唯一标识我直接取 SQLite 里的主键配合uniqueTrue可以在增量更新时做替换。url存的是源地址检索结果跳转时需要。title和content都用TEXT类型指定自定义的 Jieba 分析器。注意我给 title 设置了field_boost2.0意味着标题命中的文章相关度默认比正文命中的高这在搜索结果里非常实用。created_at字段设计成DATETIME并且sortableTrue这样排序可以直接按发布日期执行。如果只想按相关度排序这个字段可以不加但在内容搜索场景中用户往往既想要相关度高、又要内容新保留时间字段总没错。还有一点容易被忽视TEXT字段的stored属性。如果设为 FalseWhoosh 索引里不会保留原始文本查询结果也就读不出标题、正文内容。做站内搜索一般要把标题和摘要存进去所以标题我设置storedTrue正文一般不需要全部存储只存一个片段或干脆不存。我的实际做法是正文只进索引不存储需要正文时再根据 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, positionsFalse, charsFalse, keeporiginalFalse, removestopsTrue, start_pos0, start_char0, **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(limitmb256, procs2) for row in db_rows: writer.add_document( doc_idstr(row[id]), urlrow[url], titlerow[title], contentrow[content], created_atparse_datetime(row[created_at]), ) writer.commit()这里limitmb控制 Writer 的内存缓冲上限procs是并行分词进程数。对 2 万篇文档这个脚本在我的笔记本上大概几十秒完成比逐条提交快了好几个数量级。增量更新是我后来补充的需求。新建索引很简单但后续爬虫又抓了新文章总不能每次都全量重建。Whoosh 提供了update_document方法它会根据uniqueTrue的字段判断如果 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_idstr(row[id]), urlrow[url], titlerow[title], contentrow[content], created_atparse_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, limit20, sort_by_timeFalse): ix open_dir(index_dir) qp MultifieldParser([title, content], schemaix.schema, groupOrGroup) query qp.parse(keyword) with ix.searcher() as searcher: if sort_by_time: results searcher.search(query, limitlimit, sortedbycreated_at, reverseTrue) else: results searcher.search(query, limitlimit) 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_boost2.0也是在这些权重上叠加。实际使用中我观察到的效果是搜“数据库索引”标题中直接包含“数据库”或“索引”的文章排名明显靠前正文里只有零散词反而排在后面。这种对用户很友好因为他们看到标题就能判断结果是否有效。我还实现了一个可选的时间排序模式适合“最新内容优先”的场景。不过要提醒一点sortedbycreated_at会放弃相关度分数排序此时 BM25F 计算的分数没有意义。如果产品上要求相关度和时间兼顾通常的做法是用相关度排序再对近期内容做时间衰减加权。这个动态加权在 Whoosh 里需要自定义 Scoring Function工作量不小对于我的场景暂时没有引入。4.3 毫秒级从何而来我在标题里写了“毫秒级”那这个毫秒到底从哪来很多人以为全文检索一定很快实际上如果索引文件碎片化、查询逻辑乱写几十万文档也可以跑出几秒延迟。我在这个项目里做了四件事把搜索压到几十毫秒内。第一尽量复用 Searcher 对象。Whoosh 的open_dir和searcher()调用都有成本如果每个请求都重新打开索引并构建搜索器性能基本被打回原形。我的做法是在服务启动时全局打开一次索引把 Searcher 对象缓存住需要时直接使用。索引有更新时再重新打开或显式刷新。第二控制返回数量。搜索结果默认只取 20 条这叫 Top-N 检索。搜索引擎不是把所有匹配文档都列出来再排队而是用堆结构只维护前 20 个最佳结果。limit20和limit1000的耗时差异巨大但用户感知不到后者的价值。第三定期执行段合并优化。索引段文件多的时候查询要被拆到多个段上分别执行再合并结果这个过程比单段查询慢很多。我在增量更新累计超过 5000 条之后手动ix.optimize()一次实测优化后的单次查询普遍从几十毫秒降到十几毫秒。第四在 Jieba 分词侧做缓存。Whoosh 建索引时已经对全文做过一次分词但查询时 Jieba 还要对用户输入切词。这个动作通常在 1-5 毫秒内完成但有时系统刚启动、词典冷加载时会卡顿。我把 Jieba 的词典初始化放到服务启动阶段并用一个字典缓存近期查询的分词结果重复查询完全跳过分词环节。我整理了一个简单的优化前后对比数据供参考来自该项目的模拟测试数据量优化前单次查询优化后单次查询1 万文档60-110ms10-20ms3 万文档180-320ms20-45ms5 万文档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 设置 uniqueTrue 并改用 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”。如果你的项目也涉及检索逻辑建议从一开始就保留这种回归测试它能给你省下大量调试时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑