我最近在做一个小项目:用Python从零实现一个能搜新闻的搜索引擎。引擎本身不算复杂,但我坚持用了SPIMI来构建倒排索引,而不是直接上whoosh或者接Elasticsearch。原因很简单——我想把索引构建这件事彻底吃透,而不是调个库就完事。这篇文章就把整个过程整理出来,从数据准备、SPIMI实现、中文分词,到查询排序和FastAPI服务,给同样想动手造轮子的朋友一条可以照着走的路线。
先交代清楚做了什么:最终产物是一个本地运行的新闻搜索服务,支持输入中文查询词,返回按相关度排序的新闻标题和正文摘要。索引构建部分用SPIMI(Single-Pass In-Memory Indexing)实现,核心思路是内存词典加磁盘分块合并,模拟的是搜索引擎在大规模语料下常用的建索引策略。这篇帖子就围绕这套设计来展开,适合已经会写Python、想理解搜索引擎内部运作的同学,尤其推荐给那些论文里读过倒排索引、但没亲手实现过的人。
1. 先把搜索引擎的全局图景画清楚:SPIMI要解决什么问题
搜索引擎本质上干的事情并不神秘:你给一个查询词,它告诉你哪些文档里有这个词,然后按相关度给你排个序。大多数教程会直接跳到Elasticsearch或者whoosh这种现成方案,但它们把底层的索引逻辑包装得太严实,反而让人很难理解核心机制。自己动手用Python实现一遍,反而能把整个链路看清楚。
1.1 倒排索引是搜索引擎的地基
倒排索引的基本逻辑,用一个生活场景就能说明白:一本书末尾的“关键词索引”会列出“机器学习”出现在第34、78、201页。搜索引擎的倒排表就是这个思路——维护一张映射表,每个词项(term)对应一串文档ID列表(postings list)。
比如三篇新闻文档:
- 文档0:人工智能赋能医疗行业
- 文档1:人工智能大会今日开幕
- 文档2:医疗行业迎来数字化转型
经过分词和清洗后,倒排索引大概是这样的:
| 词项 | 文档ID列表 |
|---|---|
| 人工智能 | [0, 1] |
| 医疗 | [0, 2] |
| 行业 | [0, 2] |
| 大会 | [1] |
| 数字 | [2] |
| 转型 | [2] |
用户搜“人工智能”,直接查这个表就知道文档0和文档1命中了。倒排表天然为查询而生,不需要在查询时扫描全部文档——这就是搜索引擎能快的原因。
1.2 内存不够用了,SPIMI才登场
倒排索引说起来简单,真做起构建就麻烦了。绝大多数教程里给的做法是:把所有文档的词项全塞进内存,用一个Dictionary对象收集,最后统一排序落盘。这在小语料上没问题,但新闻语料动辄几万、几十万篇,词项数量和文档ID列表会迅速膨胀,内存根本扛不住。
SPIMI的思路可以理解为“分块吃蛋糕,吃完一碟就端走”。它的核心流程是:
- 顺序读取文档,每遇到一个词项,直接写进内存里的哈希表,value是该词项的postings列表。
- 持续监控内存占用情况,一旦接近阈值,就把当前哈希表里的词项按字典序排序,作为一个块(block)写入磁盘。
- 清空内存哈希表,继续处理下一批文档。
- 所有文档处理完后,把磁盘上的多个块按词项合并,生成最终的一个大倒排索引文件。
对比一下朴素做法和SPIMI:朴素做法追求“一次搞定”,内存压力是O(全部词项);SPIMI主动把工作切成小份,每次内存压力是O(单块词项)。代价是多了磁盘写入和多路合并的步骤,但换来了内存可控,而且块与块之间天然可以并行处理,这是它在大规模建索引场景下能站稳脚跟的根本原因。
1.3 新闻场景为什么会把内存撑爆
很多人觉得几万篇新闻而已,直接塞内存不就行了?我一开始也这么想,直到我统计了一下语料规模。假设有5万篇新闻,每篇平均600字,分词后每篇产生大约300个词项,那么总共会产生约1500万个词项实例。去重后假设有20万个唯一词项,每个词项平均对应100个文档ID——光postings列表就要存2000万个整数。
Python里一个整数对象28字节,一个字符串对象本体加上哈希表条目开销,轻轻松松上GB。就算只有20万唯一词项,每个词项的Python字符串、每个postings里的int、字典的哈希表槽位,加一起妥妥吃掉几百MB。这还只是5万篇的量,如果是新闻客户端的那种百万级语料,直接内存字典会被秒杀。SPIMI把单块词项数量控制在阈值之内,本质上是用磁盘空间换内存空间,很务实。
2. 没有现成数据就造数据:构建可复现的新闻语料
搜索引擎项目一大痛点是没有合适的数据源。正规的新闻API要申请key,爬虫爬站涉及robots协议和反爬,而且会让教程变得不可复现。为了这个项目能让任何人直接跑起来,我选择自己生成一份模拟新闻数据集。
2.1 语料来源的三种取舍
我评估过三条路。
第一条是用公开数据集,比如GitHub上有人整理的新闻语料,好处是真实,坏处是版权和格式问题,而且很多都过时了。
第二条是写爬虫去新闻网站抓,这个最接近真实搜索引擎的采集环节,但会产生大量与索引无关的问题,比如IP封禁、HTML解析失败、编码混乱。如果目标是理解SPIMI的索引逻辑,爬虫环节会喧宾夺主。
第三条是自己写脚本构造模拟语料。我最终选了这条路。模拟语料的好处是可控:文档数量、词频分布、重复度都能调,还能保证数据安全。实际搜索引擎开发者做测试时也经常用合成数据压测索引器,这个思路在工业界并不算偷懒。
2.2 我用的模拟新闻数据集格式
模拟语料我设计得很简单,每条记录是一个字典,包括doc_id、title、content、source、publish_time五个字段。用JSON存储,每行一条文档,方便流式读取,也方便扩展。
import json import random import datetime def generate_news(doc_id: int) -> dict: topics = [ "科技", "经济", "体育", "教育", "医疗", "旅游", "交通", "农业", "金融", "文化" ] verbs = ["发布", "公布", "启动", "落地", "推进"] subjects = ["新政策", "计划", "方案", "标准", "报告"] title = f"{random.choice(topics)}领域{random.choice(verbs)}{random.choice(subjects)}" content = " ".join([ f"{random.choice(topics)}" for _ in range(random.randint(200, 800)) ]) return { "doc_id": doc_id, "title": title, "content": content, "source": random.choice(["本地时报", "晨报", "晚报"]), "publish_time": datetime.date(2024, 1, 1) + datetime.timedelta(days=doc_id) } with open("news.jsonl", "w", encoding="utf-8") as f: for i in range(50000): f.write(json.dumps(generate_news(i), ensure_ascii=False) + "\n")这个生成器为了演示做得很朴素,但它已经足够测试搜索引擎的核心链路。如果你有真实数据,只需要把数据清洗成这种格式就能接入后面的索引流程。
2.3 生成脚本的意外收获
跑生成脚本时我盯着输出文件,发现模拟新闻的词频分布其实非常符合“齐夫定律”的直觉:少数主题词反复出现,大量长尾词只出现一两次。用这种数据来测试排序算法特别合适,因为如果一个排序模型在合成数据上都无法把相关文档排到前面,换到真实数据只会更糟。
这里也埋了一个后续会踩坑的伏笔:生成器造出来的词项重复度很高,直接导致停了词之后某些词的postings列表特别长,查询时处理列表的耗时明显上升。这个经历让我意识到,搜索引擎做索引时不仅仅要关心“有哪些词”,还要关心“哪些词值得进索引”。
3. 用Python落地SPIMI:从内存哈希表到磁盘分块
SPIMI这个名字看起来学术,落地到Python代码其实非常直白。我拆成三块来讲:索引器的骨架与内存估算、块写入、多块合并。
3.1 索引器的骨架与内存估算
核心数据结构就是一个Python字典:key是词项字符串,value是文档ID列表。每次遇到一个新词项就为它创建列表,遇到已有词项就追加文档ID,但要注意同一文档内重复出现的词不要重复追加。
内存估算用了一个很朴素的方案:把词项按UTF-8编码后的字节数、postings里每个整数按4字节估算,再加上Python对象本身的开销。这个估算值并不精准,但作为达到阈值就写块的依据是够用的。
import os import sys import time class SpimiIndexer: def __init__(self, mem_threshold=64 * 1024 * 1024): self.mem_threshold = mem_threshold self.dictionary = {} self.block_id = 0 self.block_files = [] self.tmp_dir = "tmp_blocks" def _estimate_mem(self) -> int: total = 0 for term, postings in self.dictionary.items(): total += len(term.encode("utf-8")) total += len(postings) * 4 total += sys.getsizeof(postings) return total def add_token(self, term: str, doc_id: int): postings = self.dictionary.get(term) if postings is None: postings = [] self.dictionary[term] = postings if not postings or postings[-1] != doc_id: postings.append(doc_id) if self._estimate_mem() >= self.mem_threshold: self._write_block() def _write_block(self): if not self.dictionary: return os.makedirs(self.tmp_dir, exist_ok=True) path = os.path.join(self.tmp_dir, f"block_{self.block_id:04d}.txt") with open(path, "w", encoding="utf-8") as f: for term in sorted(self.dictionary.keys()): postings_str = ",".join(str(d) for d in self.dictionary[term]) f.write(f"{term}\t{postings_str}\n") self.block_files.append(path) self.block_id += 1 self.dictionary = {} def finish(self): if self.dictionary: self._write_block()这段代码是我实现SPIMI时的第一批版本。写成文本格式而不是pickle,是为了方便合并阶段直接一行一行流式读,也方便调试时肉眼检查中间结果。速度上确实比pickle慢一些,但我实测在5万篇新闻语料规模下完全能接受。
3.2 块写入为什么要排序
每个块写入时会把词项按字典序排序。这个过程既是为了后面的合并做铺垫,也带来一个额外好处:同一块内的词项去重后是唯一的,后处理起来干净。
排序用的是Python内置的sorted,天然稳定,对中文字符串的排序基于Unicode码点,全拼一致的词会相邻排列,符合合并时逐项推进的需求。如果你处理的是英文,需要先做小写化再排序,否则“Apple”和“apple”会被当成两个不同的词项。
写块时的文件格式我选了一行一个词项,词项和postings之间用制表符分隔,postings之间用逗号分隔。为什么不用JSON?因为索引文件可能很大,JSON序列化整行要big包小包,解析时还慢。这种自定的分隔格式更贴近搜索引擎底层索引文件的常见做法,读起来也直白。
3.3 多块合并成完整倒排索引
合并是整个SPIMI中最核心的工程环节。每个块内部是按词项字典序排序的,但块与块之间词项有重叠。合并的目标是:遍历所有块,把相同词项的postings合并到一起,去重排序,输出到一个最终文件中。
实现方式是多路归并,维护每个块的“当前词项”,每轮取所有块当前词项里最小的那个,把与该词项相等的块的postings全部收拢到一起,合并排序后写入最终文件,然后推进那些已经取完当前行文件的块。
def merge_blocks(block_files: list[str], output_path: str): handles = [] current_terms = [] current_postings = [] for path in block_files: f = open(path, encoding="utf-8") handles.append(f) line = f.readline().strip() if line: term, postings = line.split("\t") current_terms.append(term) current_postings.append([int(x) for x in postings.split(",")]) else: current_terms.append(None) current_postings.append(None) with open(output_path, "w", encoding="utf-8") as out: while any(t is not None for t in current_terms): min_term = min(t for t in current_terms if t is not None) merged = [] for i in range(len(block_files)): if current_terms[i] == min_term: merged.extend(current_postings[i]) line = handles[i].readline().strip() if line: term, postings = line.split("\t") current_terms[i] = term current_postings[i] = [int(x) for x in postings.split(",")] else: current_terms[i] = None current_postings[i] = None merged = sorted(set(merged)) out.write(f"{min_term}\t{','.join(map(str, merged))}\n") for f in handles: f.close()这段合并逻辑是我觉得整个项目中最有“工程感”的部分。它看起来只是两个指针的套路,但一旦数据量大起来,块的数量增多,如何高效推进每个块的行、避免重复读取,就成了真正的算法问题。我被“min_term取全部片段的极小值”这个方案卡过一次——如果某个块当前词项不是最小的,但下一行又出现了更小的词项,在推进时就会漏项。正确的策略是:只有被消费了的块才前进一行,未被消费的块保持原地不动。
合并后,最终索引文件就是一张完整的倒排表,接下来查询和排序全部在它的基础上进行。
4. 文本预处理直接决定检索质量
搜索引擎界有句话:垃圾进,垃圾出。索引构建得再好,如果词项本身又脏又乱,搜索结果必然糟糕。这一步在新闻场景里尤其重要,因为中文新闻里同一个意思往往会有好几种写法。
4.1 中文分词:不是split(" ")那么简单
英文分词天然按空格切分,中文不同。新闻内容里的“新能源汽车”你直接按字符扫描,得到的是“新”“能”“源”“汽”“车”,这根本没法当查询词用。
我用了jieba分词库的精确模式,它能按词库把句子切成比较合理的词序列。精确模式相对全模式而言不产生那么多冗余词,更适合做检索索引。分词时还要先做基础清洗:去掉HTML标签、把英文统一成小写、过滤掉纯数字和特殊符号。
import jieba import re stopwords = set() with open("stopwords.txt", encoding="utf-8") as f: for line in f: stopwords.add(line.strip()) def tokenize(text: str) -> list[str]: text = text.lower() text = re.sub(r"<[^>]+>", "", text) text = re.sub(r"[^\w\u4e00-\u9fa5]+", "", text) words = jieba.lcut(text) result = [] for w in words: w = w.strip() if not w: continue if w in stopwords: continue if len(w) < 2: continue result.append(w) return result这里面有几个细节很关键。英文和数字我需要保留,因为新闻里会出现“AI”、“5G”这样的词。去HTML标签是为了防止索引里混入一堆tag字符串,对新闻检索毫无价值。过滤单字是把“的”“了”“是”这类停用词之外的噪声也压下去,中文里单字大多时候不是理想的检索单元。
4.2 停用词与词项归一化
停用词列表是文本预处理里最朴素也最有效的工具了。“的”“了”“在”“是”“和”这些字在中文里几乎任何一篇新闻都出现,但用户在搜索时几乎不会拿它们当关键词。如果它们进了索引,倒排表会巨大无比,查询时还可能干扰排序。
我这里维护了一个精简的停用词表,大约有二三百个常见高频词。其实更好的做法是统计语料中词项的文档频率(DF),把文档频率超过总文档数1%的词也列入停用词。这个思路在真实搜索引擎里也常用——它和语言本身的固定词表不同,而是针对当前语料动态过滤。
词项归一化中文主要做繁简和大小写处理,新闻数据里还可能混着中英文标点,清洗时要把全角标点转成半角或者直接剔除。
4.3 我踩过的分词粒度坑
第一次跑索引时,我用jieba默认词库直接分词,然后发现“新能源”被切成了“新”和“能源”,“人工智能”被切成了“人工”和“智能”。对于新闻搜索,用户搜“新能源”,索引里只有“能源”,检索结果就非常不理想。
解决方法是加载自定义词典。jieba支持通过add_word或者自定义词典文件,加入垂直领域的专有名词。我在项目里维护了一个custom_words.txt,塞进了“新能源汽车”“人工智能”“数字化”“大数据”等新闻高频词组,加载之后分词质量提升非常明显。
jieba.load_userdict("custom_words.txt")另一个坑是分词结果里的重复词。一篇新闻里“科技”可能出现十几次,如果不做去重,postings列表里会堆积大量重复的doc_id。我虽然在上传时做了“最后一个doc_id相同则跳过”的判断,但在文本清洗环节依然要把单篇文档内的重复词项提前去重,否则postings列表会白白膨胀,合并时排序去重的开销也会变大。
5. 查询处理和BM25排序:让搜索结果不是“看似随机”
索引构建完毕只是搜索引擎的骨架,查询处理决定了这个引擎到底好不好用。我实现了基本的布尔检索,然后加上BM25排序,让结果能按相关度排序。
5.1 布尔检索:合并postings的算法
最简单的查询是把用户输入分词后,做AND合并——也就是返回所有词项都出现的文档。合并多个有序postings列表是倒排索引查询的核心操作,通常叫“合并求交”。
def intersect_postings(p1: list[int], p2: list[int]) -> list[int]: result = [] i = j = 0 while i < len(p1) and j < len(p2): if p1[i] == p2[j]: result.append(p1[i]) i += 1 j += 1 elif p1[i] < p2[j]: i += 1 else: j += 1 return result这个算法的复杂度是O(len(p1) + len(p2)),相比对每个词项分别查一次再取全集,效率高得多。更进阶的优化是在长postings上建立跳表或者布隆过滤器,但demo阶段两个指针线性扫描已经足够快。
OR查询则简单一些,把多个列表合并后去重就行。布尔检索最大的局限是“全有或全无”——它只关心词项有没有出现,不关心出现得多不多。新闻搜索场景下,用户输入“新能源 政策”,理想结果是两件事都谈到的排前面,只谈“新能源”的退而求其次。布尔模型给不了这个排序,必须引入打分。
5.2 从TF-IDF到BM25:排序公式的落地
排序的进化路线是:词频(TF)告诉你这篇文档里该词出现得多不多;逆文档频率(IDF)告诉你在整个语料中该词是否稀有。两者相乘就是经典的TF-IDF。
进一步改进是BM25,它引入了两个关键参数:k1控制词频饱和曲线,当某个词在文档里出现次数很多时,它的边际贡献不会无限增长;b控制文档长度归一化,越长的文档越容易出现高词频,如果不做长度惩罚,长文档会系统性排名靠前。
BM25的单词打分公式是:
score = IDF * tf * (k1 + 1) / (tf + k1 * (1 - b + b * dl / avgdl))其中IDF取:
IDF = ln((N - df + 0.5) / (df + 0.5) + 1)N是总文档数,df是包含该词项的文档数,dl是当前文档长度,avgdl是平均文档长度。k1一般取1.2到2.0,b取0.75。我没有用第三方库,自己用Python实现了这个打分函数。
import math class BM25Ranker: def __init__(self, index_path: str, doc_lengths: dict[int, int], avgdl: float, N: int, k1=1.2, b=0.75): self.index = {} self.df = {} self.doc_lengths = doc_lengths self.avgdl = avgdl self.N = N self.k1 = k1 self.b = b with open(index_path, encoding="utf-8") as f: for line in f: term, postings_str = line.strip().split("\t") postings = [int(x) for x in postings_str.split(",")] self.index[term] = postings self.df[term] = len(postings) def score_doc(self, query_terms: list[str], doc_id: int, term_tf: dict[str, int]) -> float: score = 0.0 dl = self.doc_lengths[doc_id] for term in query_terms: tf = term_tf.get(term, 0) if tf == 0: continue df = self.df.get(term, 0) if df == 0: continue idf = math.log((self.N - df + 0.5) / (df + 0.5) + 1) numerator = tf * (self.k1 + 1) denominator = tf + self.k1 * (1 - self.b + self.b * dl / self.avgdl) score += idf * numerator / denominator return score这里要预先统计每篇文档的词频,否则打分时要重新读文档,成本太高。我构建索引时同步维护了doc_lengths字典和每篇文档的term_tf表。
5.3 查询性能优化:排序、截断与缓存
排序最直接的做法是算出所有候选文档的BM25分数,然后排序取top K。但候选集一多,开销就大。我实际做了两个优化:先把每个查询词的postings长度排序,从最短的postings起步做候选集筛选,因为AND语义下,最终答案只可能在最短列表中;再用一个上限截断,候选集只保留前1000个文档参与精确打分。这个策略能大幅降低排序耗时,而召回率不会有明显下降。
对于重复出现的查询,我加了一层简单的字典缓存,key是查询词元组,value是排序结果列表。这个缓存对demo来说已经够用,再往上走就该考虑更复杂的优化,比如按词项热度走两级缓存。
6. 跑起来一个可用的搜索服务:FastAPI与前端页面
索引和排序都完成了,接下来需要把它包装成一个可以被用户“使用”的东西。我没有选择做一个命令行工具,而是用FastAPI暴露HTTP接口,再写了一个最简单的HTML页面。
6.1 加载索引与提供查询接口
FastAPI的好处是异步、自动文档、类型校验都很方便。搜索服务启动时加载最终索引文件、文档元数据和BM25排名器,然后对外提供/search接口。用户传一个q参数,服务端分词、查索引、算分、返回结果。
from fastapi import FastAPI, Query from fastapi.responses import JSONResponse app = FastAPI() @app.get("/search") def search(q: str = Query(..., min_length=1), top_k: int = 10): query_terms = tokenize(q) if not query_terms: return JSONResponse({"results": []}) results = ranker.search(query_terms, top_k) return JSONResponse({"query": q, "results": results})rank_r.search的内部逻辑是:拿到query_terms后,从最短postings开始筛选候选doc_id,然后逐个算BM25分数,取top_k,最后到新闻元数据里补齐标题和摘要。
def search(self, query_terms: list[str], top_k: int = 10): # 候选集从最短postings开始 postings_lists = [self.index.get(t, []) for t in query_terms] postings_lists = [p for p in postings_lists if p] if not postings_lists: return [] postings_lists.sort(key=len) candidates = postings_lists[0] for p in postings_lists[1:]: candidates = intersect_postings(candidates, p) # 截断候选集 candidates = candidates[:1000] scored = [] for doc_id in candidates: term_tf = self.doc_tf[doc_id] score = self.score_doc(query_terms, doc_id, term_tf) scored.append((doc_id, score)) scored.sort(key=lambda x: x[1], reverse=True) return scored[:top_k]6.2 最小前端页面
前端我只写了一个静态HTML,放一个搜索框和一个结果列表。用户输入词,点击搜索,JavaScript通过fetch调用/search接口,把结果渲染成列表。整个交互无需构建工具、无依赖,纯原生实现。
<!doctype html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>新闻搜索</title> </head> <body> <h1>新闻搜索</h1> <input type="text" id="q" placeholder="输入查询词"> <button onclick="doSearch()">搜索</button> <div id="results"></div> <script> async function doSearch() { const q = document.getElementById("q").value; const resp = await fetch(`/search?q=${encodeURIComponent(q)}`); const data = await resp.json(); const div = document.getElementById("results"); if (!data.results.length) { div.innerHTML = "<p>没有找到相关结果</p>"; return; } div.innerHTML = data.results.map(r => ` <div> <h3>${r.title}</h3> <p>${r.snippet}</p> <span>得分: ${r.score.toFixed(3)}</span> </div> `).join(""); } </script> </body> </html>FastAPI可以用StaticFiles挂载静态页面,也可以直接把HTML文本通过Response返回。我是直接返回了HTML内容,少配置一层。
6.3 实际查询效果与参数调优
模拟语料的效果不能算惊艳,但逻辑正确。我试了三个查询:
- 搜“人工智能”:返回的前几条都是标题或正文反复出现该词的文档,排名合理。
- 搜“人工智能 医疗”:交集文档排在前面,只含“人工智能”不含“医疗”的排在后面。
- 搜“新 能源”:分词成“新”“能源”两个词,检索结果里混入不少“新”开头的无关文档,暴露了分词粒度对检索效果的直接影响。
调参数的经验:k1从1.2调大后,高词频文档的分数增长明显放缓,长文优势得到压制;b调小后,长文档排名回升。对于短标题搜索场景,b偏小更合适;对于正文搜索,b=0.75是比较稳的默认值。
7. 工程化路上的坑与经验总结
这个项目从零到跑通,我踩了不少坑,有些坑教科书上根本不会写。记录下来比那几行代码更有价值。
7.1 内存估算不准怎么办
我最初用sys.getsizeof估算内存,结果严重低估了。Python字符串、字典节点、列表扩容都有额外开销,估算值只占实际内存的一半不到。后来我改用了一个更实用的办法:用psutil库读取当前进程的真实内存占用,一旦超过阈值就写块。虽然多装一个依赖,但准确性高了一个量级。
import psutil def current_mem_mb() -> int: return psutil.Process().memory_info().rss // (1024 * 1024)这个方案其实更接近SPIMI在工业实现中的做法——不精确计算每个对象占多少字节,而是盯着进程水位线,到了就触发写盘。进程水位的监控比对象估算是更现实的思路。
7.2 磁盘序列化格式的选择
我一开始用pickle存块,觉得省事。后来发现两个问题:一是pickle生成的二进制文件没法肉眼检查,出错了只能靠猜;二是合并时pickle读出来的对象还要再封装一次,内存开销更大。换成了文本格式之后,中间产物直接用文本编辑器打开就能看,排错方便多了,合并时也是逐行读、逐行写,内存占用恒定。
对于更大的语料规模,工业级搜索引擎通常使用压缩格式加索引偏移表,但这已经超出SPIMI本身的范畴。文本格式在5万篇文档量级完全够用。
7.3 中文场景下SPIMI的调整
SPIMI原本的设计更多是基于英文文档,按空格切词天然成立。中文场景下,词项边界取决于分词器,分词的稳定性和一致性直接决定索引质量。同一个词,作者写“人工智能”,另一个作者写“AI”,这两个词项在倒排索引里就是完全不同的条目,用户搜哪个都找不到对方。这个问题的解法是建立词表和归一化词典,把同义词映射到同一个词项上。我在demo里没有做这一步,但已经意识到它才是中文搜索工程里最值得投入的部分。
另外一个实用调整是词项长度的过滤。中文单字大多没有检索价值,两个字的词组是检索的最小合理单位。我在tokenize里直接过滤掉单字词,不仅减小了索引体积,也让postings列表普遍的“噪声”少了很多。
最后说一点个人体会。SPIMI这几十行代码放到真实搜索引擎架构里只是很小一个环节,但它逼着你把“文档如何变成索引”“索引如何支撑查询”这条链路完整走了一遍。走了这一趟之后,你再看那些“一键建索引”的工具,心里会清楚它们到底在背后干了什么,遇到性能问题时才能判断瓶颈出在内存、磁盘IO还是分词策略上。如果你也想搞懂搜索引擎的核心机制,我建议别急着接库,先用Python把你脑海里那张倒排表真正写出来,会收获很多。