做 Python 爬虫时间久了,就会发现爬虫本身不是终点,把爬回来的数据变成能检索的东西才是。前阵子我帮朋友整理内部资料库,目标很明确:搭一个自动化采集加本地搜索的小系统,让几十万条文档记录在单机上也能秒级返回结果。这篇文章就是把整条链路从头到尾拆开讲一遍,从网页采集、正文提取、结构化入库,到中文分词、倒排索引、搜索接口,最后你会得到一个接近“专属搜索引擎”效果的东西,而不是只能跑通一个页面的玩具 demo。
适合看这篇的人主要有三类:想给自己博客或笔记做站内搜索的;需要给公司内部文档、帮助中心做轻量级检索引擎的;以及刚学完 Python 爬虫基础、想拿一个完整项目练手的人。不需要大数据框架,不需要显卡,一台普通电脑加几个 Python 库就能跑起来。
1. 先把“专属搜索引擎”拆成几个问题
1.1 你的数据源边界在哪
很多教程上来就教怎么写一个万能爬虫,满互联网抓数据。但真正可用的搜索引擎,一定是在一个“可信、可控、可更新”的数据范围内建立索引。
我在设计这个项目时,第一步不是写代码,而是拿纸列数据源。比如你有一个个人博客、某个公开技术文档站、公司内部 Wiki、几个常读的 RSS 源,这就是你搜索引擎的“领地”。边界划清楚以后,爬虫的抓取策略、解析规则、更新频率才能定下来。
如果是几十个 URL 的站内文章,用requests加BeautifulSoup就够了;如果是几千个页面、结构相对统一的公开站点,用Scrapy更省心;如果数据源有 API,优先走 API,因为 API 返回的 JSON 比你解析 HTML 干净得多。我个人的原则是:能拿结构化数据就别去啃 HTML,能用公开 API 就别写爬虫。
1.2 数据量级决定技术选型
搜索引擎的难点在于“规模”,但个人项目的规模往往没你想的那么大。我习惯先按量级估算再选型:
| 数据量 | 推荐存储 | 推荐索引方案 | 理由 |
|---|---|---|---|
| 几千条 | JSONL + SQLite | SQLite FTS5 | 文件即库,依赖最少,复制即备份 |
| 几万到几十万条 | SQLite 或 PostgreSQL | SQLite FTS5 或 Whoosh | 单机仍然扛得住,查询毫秒级 |
| 百万条以上 | PostgreSQL / Elasticsearch | Elasticsearch | 超过单机 SQLite 的舒适区,需要分布式能力 |
我做这个项目时大概有三万篇文档,SQLite FTS5 完全够用。别一上来就上 Elasticsearch,那玩意运维成本很高,个人项目完全没必要。先跑通最小系统,数据量真的涨上去了再迁移,链路是平滑的。
1.3 关键词检索比语义检索更值得先做
现在很多人一提到搜索引擎就想到向量数据库、Embedding、RAG,但我觉得关键词检索才是地基。倒排索引加 BM25 这类经典方案,稳定、可控、不依赖模型,而且能处理长尾词和精确匹配。
语义检索适合“模糊语义”场景,但前提是你已经有一个能用的检索底座。我建议先把关键词搜索做到 80 分,再考虑向量召回。大多数个人知识库场景,关键词检索的体验已经足够让人惊喜了。
2. 爬虫采集:把“能抓什么”和“该抓什么”分清楚
2.1 请求层:User-Agent、限速和 Robots 协议
写爬虫最容易被忽略的其实是“礼貌”。我见过不少爬虫一跑就把目标站点压垮,最后被对方封 IP。合理做法是设置一个清晰可辨的 User-Agent,同时控制请求速率,比如每抓一页间隔 1 到 2 秒。
import requests import time from urllib.robotparser import RobotFileParser from urllib.parse import urlparse UA = "Mozilla/5.0 (compatible; PrivateIndexBot/1.0)" def can_fetch(url: str) -> bool: parsed = urlparse(url) robots_url = f"{parsed.scheme}://{parsed.netloc}/robots.txt" rp = RobotFileParser() rp.set_url(robots_url) rp.read() return rp.can_fetch(UA, url) def fetch_page(url: str) -> str: if not can_fetch(url): print(f"robots.txt 不允许抓取: {url}") return "" resp = requests.get(url, headers={"User-Agent": UA}, timeout=10) resp.raise_for_status() resp.encoding = resp.apparent_encoding time.sleep(1) # 全局限速,避免压垮对方服务器 return resp.textRobotFileParser是 Python 标准库里的机器人协议解析器,用起来很简单。虽然它不保证目标站点真的允许你抓,但能帮你识别对方明确禁止抓取的路径。我一直认为,爬虫的边界不是“你技术上能不能爬到”,而是“你应不应该爬”。公开资料、自己公司内部的文档、明确开放的数据,这些是安全的数据源。
2.2 链接发现与去重:内存集合加布隆过滤器
抓取策略我推荐最朴素的广度优先:从一个种子页面开始,解析页面上所有链接,过滤掉站外链接,逐个往下走。关键是去重,不然爬虫会在几个页面之间死循环。
数据量不大的时候,直接用 Python 的set存已见 URL 就行。但如果链接特别多,内存会涨得很快,这时可以上一个布隆过滤器。布隆过滤器允许少量误判“已访问”,代价极小,非常适合 URL 去重这个场景。
from pybloom_live import BloomFilter seen = BloomFilter(capacity=100_000, error_rate=0.001) def is_first_seen(url: str) -> bool: # 先规范化再判重 normalized = normalize_url(url) if normalized in seen: return False seen.add(normalized) return Truenormalize_url这一步很重要,细节我放到后面的避坑章节讲。这里先记住结论:URL 不去重,爬虫很快变成“爬虫循环制造机”。
2.3 正文提取:比抓取更花时间的环节
网页抓回来只是第一步,真正决定搜索引擎质量的是正文提取。一个 HTML 页面里通常包含导航、侧栏广告、评论、页脚,这些对检索来说全是噪声。
我常用的方案是配合 CSS 选择器做“白名单 + 黑名单”提取。优先用白名单精确定位正文节点,比如很多博客的正文都在article标签或.post-content这类 class 里。再配合黑名单,把script、style、header、footer、aside、nav这些噪音标签直接去掉。
from parsel import Selector def extract_content(html: str): sel = Selector(text=html) # 去掉明显不是正文的标签 for tag in ["script", "style", "nav", "header", "footer", "aside"]: for node in sel.css(tag): node.drop() # 白名单优先:从常见正文容器里取文本 body = sel.css("article") or sel.css(".post-content") or sel.css(".content") if not body: return "", "" title = sel.css("title::text").get("").strip() content_text = body[0].xpath("string(.)").get("").strip() return title, content_textparsel的底层是 lxml,速度很快,而且选择器语法和 CSS 完全一致,写起来非常顺手。这里的关键技巧是xpath("string(.)"):它会取节点内所有文本,并把子标签的文本也拼进来,比一个个取::text要完整得多。如果你对页面结构熟悉,可以针对不同站点写专门的提取规则,准确率会更高。
2.4 增量抓取:别每次都全量重爬
如果搜索引擎的数据源在持续更新,那么“增量抓取”就是刚需。我的方案是给每个 URL 记录一个fetch_time,定期抓一次,只重新抓取那些距离上次抓取时间超过一定间隔的页面。
另外,很多站点会在响应头里返回Last-Modified或ETag,你可以把这两个值存下来,下次请求时带上If-Modified-Since,服务器返回 304 就说明页面没变化,可以直接跳过。虽然实现起来多几行代码,但能省下大量时间和带宽。
headers = {"User-Agent": UA, "If-Modified-Since": last_modified or ""} resp = requests.get(url, headers=headers, timeout=10) if resp.status_code == 304: # 页面未变化,不需要更新索引 return None这里务必要把抓取状态和 HTTP 状态码分开记录。我见过有人把 404 页面也当正文存进索引里的,搜索一搜全是“页面不存在”这种垃圾结果。正确做法是:抓取失败或不可用的 URL,在数据库里记为非 200 状态,后续处理时跳过。
3. 入库与清洗:检索跑得快,靠的是这层地基
3.1 字段设计决定了后面能不能扩展
我习惯把数据模型设计得稍微宽一点,宁可多几个字段,也别后期缺字段又要重新清洗。一个比较通用的页面表结构大概是这样的:
CREATE TABLE pages ( id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT UNIQUE NOT NULL, url_hash TEXT NOT NULL, title TEXT, content_text TEXT, source TEXT, publish_time TEXT, fetch_time TEXT, status INTEGER DEFAULT 200, error_msg TEXT );url加唯一约束是为了幂等。url_hash是为了高效比对链接是否相同。source用来标记数据来源,比如博客、文档站、论坛,这样搜索时可以按来源过滤。status和error_msg是排查问题用的,宁可提前埋好,也别等出了问题再补。
3.2 JSONL、SQLite、PostgreSQL 怎么选
我前面提到按数据量选型,这里再展开说下各自的取舍。
- JSONL:适合几百条到几千条数据,优点是人类可读、可以一行一行追加写入、方便调试。缺点是查询全靠遍历,数据量一大就卡。
- SQLite:单文件、零依赖、支持事务,个人项目和中小型内部系统的最佳选择。配合 FTS5 虚拟表甚至能把全文搜索一起搞定。
- PostgreSQL:适合多用户并发写入、数据量达到百万级、需要复杂查询的场景。如果以后要接向量检索,PostgreSQL 加
pgvector也是条顺路。
我做这个项目时选了 SQLite,原因很朴素:一个.db文件就能携带全部数据,备份、迁移、拷贝到新机器都太方便了。
3.3 幂等写入:重复抓取不产生重复数据
爬虫跑久了必然会遇到“同一个 URL 被重复抓取”的情况。幂等写入的意思是:相同的 URL 只保留一条记录,新内容直接更新旧记录。
SQLite 的ON CONFLICT子句可以非常优雅地处理这个问题:
INSERT INTO pages (url, url_hash, title, content_text, source, fetch_time) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(url) DO UPDATE SET title = excluded.title, content_text = excluded.content_text, fetch_time = excluded.fetch_time;如果一张表已经建好但没有唯一约束,记得先补上:
CREATE UNIQUE INDEX idx_pages_url ON pages(url);有个小坑:如果网页本身有版本历史,你想保留所有历史快照,那就不适合直接对url加唯一约束,而是应该加一个content_hash字段,只有内容真的变了才插入新版本。这个设计决定最好在第一天就做,不然后期改表非常痛苦。
3.4 清洗细节:HTML 实体、空白符、非正文文本
正文提取出来以后,还要做一层清洗。常见的坑有三个:
- HTML 实体,比如
&、<,要用html.unescape转换回正常字符。 - 连续空白和换行符,这些会让后面的分词和索引体积变大。
- 尾部噪声文本,比如“版权声明”“上一篇文章”“分享到微信”这类重复内容,要用正则或自定义黑名单过滤。
import html import re def clean_text(raw: str) -> str: text = html.unescape(raw) # 合并多余空白 text = re.sub(r"\s+", " ", text) # 去掉常见的页脚噪声 patterns = [ r"转载请联系作者.*?$", r"版权声明[::].*?$", r"微信扫一扫.*?$", ] for p in patterns: text = re.sub(p, "", text, flags=re.S) return text.strip()这一步千万别省。你存进去的是“索引的元数据”,噪声文本会直接污染词频统计,导致搜索时莫名其妙匹配到一堆无关结果。
4. 倒排索引与中文分词:从 like 查询到真正的搜索引擎
4.1 为什么不能靠 SQLLIKE查询
有人可能会问:都存到 SQLite 里了,直接WHERE content_text LIKE '%关键词%'不就行了吗?技术上行得通,但性能完全不行。LIKE '%关键词%'是全文扫描,数据量到几万条就会明显变慢,而且无法告诉用户“哪条结果更相关”。
搜索引擎的核心是倒排索引。正常索引是“文档到词”的映射,倒排索引反过来,记录“每个词出现在哪些文档里”。搜索时直接查词对应的文档列表,然后在文档列表里做打分排序,这比扫描全部文档快几个数量级。
打个比方:普通查询像在一本书里逐页翻找某个词,倒排索引像书的末尾直接看“关键词”指向第几页。数据量越大,倒排索引的优势越明显。
4.2 中文分词绕不过去
英文单词天然有空格分词,但中文没有。搜索“分布式事务”时,如果不上分词,索引里存的就是一整句话,用户输入“分布式”就匹配不到。
对中文分词,最常用的库就是jieba。它支持三种模式,搜索引擎场景用默认的精确模式就够了,同时可以加载自定义词典来解决领域术语问题。
import jieba STOPWORDS = {"的", "了", "在", "是", "我", "有", "和", "就", "不", "人", "都"} def tokenize(text: str): words = jieba.lcut(text) return [w.strip() for w in words if w.strip() and w not in STOPWORDS]注意,停用词表很重要。“的、了、在”这类词几乎出现在每一篇文档里,保留它们会让倒排索引的词频统计失真,还会拖慢速度。我会把停用词表单独放在一个文本文件里,方便随时增删。
4.3 方案一:用 SQLite FTS5 做全文索引
SQLite 从 3.9 版本开始内置了 FTS5,它的搜素体验和精度超出很多人的预期,而且支持bm25()排序函数,对中小型项目来说简直是白嫖。
建表时直接创建一个虚拟表:
CREATE VIRTUAL TABLE IF NOT EXISTS docs_fts USING fts5( title, content, tokenize = 'unicode61' );写入数据时,只需把清洗后的文插进去。注意插入时要用INSERT INTO docs_fts(rowid, title, content) VALUES (?, ?, ?),rowid对应pages表的id,这样搜索时能直接反查原始记录。
搜索时,中文需要对用户输入的查询也做一次分词,然后把分词结果用空格拼接成 FTS5 的查询语句。
query = "分布式 事务 补偿 方案" # 原则:分词后空格分隔 sql = """ SELECT rowid, title, snippet(docs_fts, 1, '[', ']', '...', 12) FROM docs_fts WHERE docs_fts MATCH ? ORDER BY bm25(docs_fts) LIMIT 20 """snippet()函数会从匹配位置上下文截取摘要,[和]是自定义的高亮标记,12表示摘要长度。这一套下来,搜索页直接就有高亮了。
4.4 方案二:用 Whoosh 实现更自由的索引
如果以后想自定义中文分析器、给不同字段设置不同权重、做更灵活的增量索引,可以上Whoosh。它是个纯 Python 的全文搜索引擎库,不需要额外装数据库,所有数据写在你指定的目录里。
from whoosh.index import create_in from whoosh.fields import Schema, TEXT, ID from jieba.analyse import ChineseAnalyzer schema = Schema( url=ID(stored=True, unique=True), title=TEXT(stored=True, analyzer=ChineseAnalyzer()), content=TEXT(analyzer=ChineseAnalyzer()), ) ix = create_in("indexdir", schema) writer = ix.writer() writer.add_document(url="https://example.com/a", title="分布式事务", content="...") writer.commit()搜索端:
from whoosh.qparser import QueryParser with ix.searcher() as searcher: query = QueryParser("content", ix.schema).parse("分布式事务") results = searcher.search(query, limit=20) for hit in results: print(hit["url"], hit.score)Whoosh 的优势是灵活,代价是性能。数据量在十万条以内没问题,再往上就要考虑换 Elasticsearch 了。我的建议是:如果只是自用,先上 SQLite FTS5;如果预计后面要处理多种来源、复杂排序、自定义分析器,直接上 Whoosh。
4.5 BM25 打分逻辑
FTS5 默认的bm25()排序,本质是计算“一个词对一篇文档的重要程度”。它主要由三部分决定:
- TF:词频。词在文档里出现得越多,相关性越高。
- IDF:逆文档频率。如果某个词在很多文档里都出现,那它对区分文档的帮助就越小。
- 文档长度归一化。同样是命中一个词,在短文档里的价值高于长文档。
这就是为什么“的、了”这类词必须过滤,因为它们的 IDF 接近 0,对排序毫无帮助。理解了这一点,后面调优时你就知道该从哪儿下手了。
5. 搜索接口与结果优化:最后拼的是检索体验
5.1 一条查询的完整生命周期
用户输入一句自然语言,先经过分词器把句子拆成词,然后过滤停用词,再根据查询词的组合方式构造检索语句。检索完成后,系统按 BM25 打分排序,最后把标题、URL、摘要拼装起来返回给前端。
这个流程里的关键决策是“查询词用 AND 还是 OR 连接”。我建议默认用 OR,因为个人知识库场景下,用户不会像写 SQL 那样严谨,OR 能让结果覆盖更多候选。如果结果太多、精度不够,再切换到 AND 提高匹配门槛。
5.2 让排序更符合直觉:时间衰减和字段加权
BM25 只考虑词频和文档长度,不考虑文档时效。但同样的关键词,一周前的文章通常比三年前的文章更有价值。我以前踩过这个坑,后来加了一道“时间衰减”重排:
def rerank(score, publish_time, hit_days_limit=180): """ 用简单的线性衰减把老文章的分数压低。 完全不用可能让旧文章长期霸榜,权重过大则又忽略经典内容。 """ if publish_time is None: return score days = (current_date - publish_time).days if days <= hit_days_limit: return score + 0.05 * (1 - days / hit_days_limit) return score同时,标题的命中比正文的命中价值更高。SQLite FTS5 原生没提供字段权重,但可以靠“查询扩展”变通:如果查询词在标题里出现了,就把这部分的匹配次数额外重复一次,权重自然就上来了。
5.3 用最少代码暴露一个搜索接口
我用 Flask 做了一层最薄的 HTTP 接口,方便浏览器、命令行、甚至以后做前端页面调用。搜索接口只有几十行代码:
from flask import Flask, request, jsonify import sqlite3 app = Flask(__name__) @app.route("/search") def search(): q = request.args.get("q", "") if not q: return jsonify({"results": []}) tokens = tokenize(q) match_query = " OR ".join(tokens) conn = sqlite3.connect("index.db") rows = conn.execute(""" SELECT p.url, p.title, snippet(docs_fts, 1, '[', ']', '...', 12) FROM docs_fts JOIN pages p ON p.id = docs_fts.rowid WHERE docs_fts MATCH ? ORDER BY bm25(docs_fts) LIMIT 20 """, (match_query,)).fetchall() results = [ {"url": r[0], "title": r[1], "snippet": r[2]} for r in rows ] return jsonify({"results": results}) if __name__ == "__main__": app.run(host="127.0.0.1", port=8000)启动以后,浏览器访问http://127.0.0.1:8000/search?q=分布式事务就能看到 JSON 结果。这个接口已经能接入任何前端,甚至可以用 curl 在命令行里直接查询。
5.4 建一个小的评估集来校准结果
搜索效果不能靠感觉。我的做法是准备一个 100 条左右的评估集,每条包含一个查询词和若干条预期命中的 URL。每次调整分词器或排序逻辑后,跑一遍评估集,看一下有多少条结果命中预期,记录分数。
这个方法不追求什么高深指标,就是每天观察前 10 条结果是不是越来越准。我发现很多搜索引擎项目不是死在技术上,而是死在“没人迭代效果”上。
6. 跑了几周后遇到的边界情况:一份避坑记录
6.1 URL 规范化:同一个页面有无数种写法
https://example.com/article/1、https://example.com/article/1?from=homepage、https://example.com/article/1#comment,这三个 URL 指向的其实是同一个页面。
如果不做规范化,去重模块会认为它们是三篇文档,索引里就会出现大量重复内容。我踩过这个坑,具体表现是搜索时前十条结果有三个都来自同一篇文章。
处理办法是抓取前对 URL 做四步整理:去掉#fragment,去掉常见的追踪参数(比如utm_*),统一http/https,把路径按标准格式合并。
6.2 编码问题:页面上写着 UTF-8,实际是 GBK
网页的meta charset有时是骗人的。我用requests时吃过不少亏:声明 UTF-8 的页面返回 GBK 乱码,或者声明 GB2312 实际是 GBK。resp.apparent_encoding是救命稻草,它会根据字节内容推测真实编码,比相信声明可靠得多。
另外,所有内容入库前统一转成 UTF-8 存储,索引和分词才能保持一致。输出给前端时再统一使用 UTF-8,这样能避免一半以上的乱码问题。
6.3 HTML 里的隐藏文本会污染索引
有一种页面,正文区域里藏着大量模板代码、注释文字或者隐藏的 SEO 关键词。比如display:none的文本、aria-hidden的辅助说明、还有被注释掉的旧内容。
我在清洗时增加了两道过滤:一是用最终渲染下来的文本长度做阈值,正文小于 200 字的页面直接不建索引,大概率是跳转页或错误页;二是自定义一个黑名单正则,把“上一篇”“下一篇”“相关推荐”这类模板化链接的文本剔除。
6.4 分词词典更新后需要重建索引
我一开始用的是默认jieba词典,搜索结果里“分布式事务”经常被切分成“分布式 / 事务”两个词,不算错,但相关性排序会被削弱。后来我把领域术语加了自定义词典,搜索效果明显提升。
但这里有个坑:词典变了,分词结果就变了,旧索引还是按老词典建的,新查询用新词典,两边就对不上。所以每次更新自定义词典之后,必须重建一次索引。好在几万条数据重建也就是几分钟的事,写个脚本定期执行就好。
6.5 定期监控采集日志,而不是只看搜索结果
爬虫跑一段时间以后,总有一些页面会因各种原因抓取失败:服务器超时、反爬拦截、页面结构改版。如果不记录日志,你会以为数据更新没问题,实际上索引已经开始悄悄“过时”了。
我给每个抓取任务维护了一张日志表,记录成功的 URL 数、失败的 URL 数、失败原因。每天扫一眼,看到失败率突然升高,就能第一时间去排查,而不是等用户搜索时才发现缺了最新内容。
6.6 跑了三个月之后,值得重点说的几点体会
最后说点实操体会。这个项目我维护了三个月,最大的感受是:搜索系统的价值不在抓取,而在“持续的可维护性”。爬虫代码也许只占整个项目两成的工作量,剩下八成全在清洗规则、索引更新、效果评估和边界情况处理上。
对中小型项目,我强烈建议先用 SQLite FTS5 把整条链路跑通,不要一上来就设计复杂的分布式方案。等到数据量、字段复杂度真的逼到墙角,再考虑换 Whoosh 或 Elasticsearch,那时你已经很清楚自己的瓶颈在哪了。另外,给数据的每个字段都记好来源和抓取时间,将来调试时能省掉大量“这个数据是哪来的”这种猜测。
搜索体验这件事,永远是“评估、调优、再评估”的循环。你每调整一次分词规则或排序权重,都应该能用一套固定的查询列表去验证前后差异。坚持做下去,这个本地搜索引擎会越用越顺手,最终变成真正属于你自己的知识入口。