关键词检索这件事,看起来简单,真动手写起来坑不少。我最早接触这类需求是在一个内部文档管理模块里,当时想的就是"用户输入几个词,把最相关的文档排前面",听起来十分钟能搞定,结果从分词、打分到排序策略调了一整周才勉强能用。这篇就把我从零搭一个基于关键词的文本排序检索系统的完整思路拆开讲,包括倒排索引怎么建、TF-IDF和BM25怎么选、中文分词怎么处理、排序分数怎么归一化、性能怎么优化。代码用Java写,但思路跟语言无关,换成任何后端栈都能照搬。适合有一定Java基础、想自己动手做一个轻量级检索系统的朋友,也适合想搞清楚搜索引擎排序背后到底在算什么的人。
1. 先想清楚:为什么不用数据库的LIKE就完事了
很多人第一反应是"我直接用SQL的LIKE查询不就行了"。小数据量确实可以,但一旦文档上了几万条,LIKE的全表扫描会让你等到怀疑人生。更关键的是,LIKE只能告诉你"包含或不包含",它没法告诉你"哪条更相关"。而检索系统的核心价值恰恰在于排序——把最相关的结果排在最前面。
1.1 关键词检索要解决的三件事
我把这个系统的核心需求拆成三块:
- 找到:给定关键词,快速定位到包含这些词的文档集合,不能全表扫描。
- 打分:对每个候选文档计算一个相关性分数,分数越高越相关。
- 排序:按分数从高到低返回,同时处理分数相同、关键词命中位置不同等边界情况。
这三件事对应三个技术点:倒排索引、相关性打分模型、排序与归一化。下面逐个拆。
1.2 一个生活化的类比
把检索系统想象成一个图书馆。倒排索引就是图书馆的索引卡片柜——每张卡片写着一个词,后面跟着所有包含这个词的书架编号。你查"机器学习",直接翻到那张卡片,就知道哪几本书涉及这个主题,不用一本本翻。打分模型则是图书管理员帮你判断"哪本书讲机器学习讲得最深入"——是整本书都在讲,还是只在某一页提了一嘴。排序就是把管理员认为最值得看的书放在最上面。
这个类比贯穿全文,后面讲每个模块时我都会回到它。
2. 倒排索引:整个系统的地基
倒排索引(Inverted Index)是检索系统的核心数据结构。正排索引是"文档→包含的词",倒排索引反过来,是"词→包含该词的文档列表"。这个反转是性能的关键。
2.1 倒排索引的基本结构
最朴素的倒排索引就是一个Map:
// 词 -> 文档ID列表 Map<String, List<Integer>> invertedIndex = new HashMap<>();但实际用起来,光有文档ID不够。你还需要知道这个词在每个文档里出现了几次(词频,TF),以及出现在哪些位置(用于短语匹配和高亮)。所以更实用的结构是:
// 词 -> (文档ID -> 词频) Map<String, Map<Integer, Integer>> invertedIndex = new HashMap<>();如果要支持位置信息,再嵌套一层:
// 词 -> (文档ID -> 位置列表) Map<String, Map<Integer, List<Integer>>> invertedIndexWithPositions = new HashMap<>();我建议一开始就把词频带上,因为打分模型几乎一定会用到TF。位置信息可以后面按需加,它占内存比较大。
2.2 构建索引的完整流程
构建索引分两步:文档预处理和索引写入。
第一步,文档预处理。对每个文档做分词,得到词列表。中文需要分词器,英文按空格和标点切分即可。分词后通常还要做归一化:转小写、去停用词、词干提取(英文)。中文一般不做词干提取,但去停用词是必要的。
第二步,写入索引。遍历分词结果,对每个词更新倒排索引:
public void addDocument(int docId, String content) { List<String> tokens = tokenizer.tokenize(content); Map<String, Integer> termFreq = new HashMap<>(); for (String token : tokens) { termFreq.merge(token, 1, Integer::sum); } for (Map.Entry<String, Integer> entry : termFreq.entrySet()) { String term = entry.getKey(); int freq = entry.getValue(); invertedIndex .computeIfAbsent(term, k -> new HashMap<>()) .put(docId, freq); // 同时记录文档长度,BM25打分需要 docLengths.put(docId, tokens.size()); } }注意这里我先用termFreq统计了每个词在文档内的词频,再一次性写入索引。这样做的好处是避免对同一个词反复操作Map,性能更好。
2.3 文档长度为什么要单独存
你可能注意到上面存了docLengths。这是因为BM25打分模型需要知道每个文档的长度,用来做长度归一化——长文档天然更容易命中更多词,如果不做归一化,长文档会不公平地占优。这个细节后面讲BM25时会展开。
2.4 索引的内存与持久化权衡
纯内存的倒排索引查询极快,但重启就没了。生产环境一般会把索引持久化到磁盘。简单做法是序列化整个Map到文件,启动时反序列化。但文档量大时,序列化/反序列化会很慢。
我的经验是:如果文档在十万级以内,直接Java序列化到磁盘完全够用,启动加载几秒钟的事。如果上了百万级,就得考虑分片存储或者用现成的检索引擎了。自己手写的话,可以按词的首字母分文件存储,加载时按需读取,减少启动开销。
提示:倒排索引的value用
Map<Integer, Integer>而不是List<Integer>,是因为查询时经常需要"某个文档是否包含某个词"以及"词频是多少",用Map的查找是O(1),比遍历List快得多。代价是内存占用略高,但值得。
3. 中文分词:绕不过去的第一道坎
英文检索相对简单,按空格切词就行。中文没有天然分隔符,必须分词。这一步做不好,后面打分再精确也白搭。
3.1 分词器的选择
Java生态里常用的中文分词器有几个方向:
| 方案 | 特点 | 适用场景 |
|---|---|---|
| 基于词典的最大匹配 | 实现简单,速度快,依赖词典质量 | 领域词汇固定的场景 |
| 基于统计的分词 | 准确率高,需要训练语料 | 通用文本 |
| 开源分词库 | 开箱即用,维护成本低 | 绝大多数项目 |
我的建议是:除非有特殊需求,直接用成熟的开源分词库。自己写分词器是个无底洞,尤其是处理歧义切分("南京市长江大桥"这种经典例子)和新词识别。
3.2 分词粒度对检索的影响
分词粒度是个容易被忽略的坑。比如"自然语言处理",粗粒度切成一个词,细粒度切成"自然/语言/处理"。如果用户搜"语言处理",粗粒度索引里没有这个词,就搜不到;细粒度索引里能匹配上。
反过来,如果用户搜"自然语言处理"这个完整短语,细粒度索引需要做短语匹配才能保证准确性,否则会把只包含"语言"的文档也排上来。
我的做法是:索引时用细粒度,查询时也做细粒度切分,然后通过短语匹配来提升精度。这样兼顾召回率和准确率。具体来说,查询"自然语言处理"时切成"自然/语言/处理"三个词,先召回包含任一词的文档,再对同时包含这三个词且位置连续的文档加分。
3.3 停用词处理的实际取舍
停用词(如"的""了""在")通常要去掉,因为它们几乎出现在每个文档里,没有区分度。但有个例外:如果用户搜的就是包含停用词的短语,去掉后可能匹配错。
我的处理方式是:索引时去掉停用词,查询时也去掉,但对原始查询做一次短语匹配校验。如果去掉停用词后召回的结果里,有文档的原文包含完整查询短语,给它额外加分。这样既保证了索引效率,又不会因为去停用词而丢失精度。
3.4 一个分词踩坑实例
早期我用某个分词库时,发现它对英文和数字的处理有问题——"Java8"被切成了"Java"和"8",导致搜"Java8"搜不到。后来我在分词前先做了一层预处理,把"字母+数字"的组合识别为整体,再交给分词器。这个坑很隐蔽,因为大部分测试用例都是纯中文,不会暴露这个问题。
注意:分词器的词典要定期更新,尤其是做垂直领域检索时。新词、网络用语、专业术语如果不在词典里,会被切得乱七八糟。我一般会维护一个自定义词典,把业务相关的词加进去。
4. 打分模型:TF-IDF还是BM25
找到候选文档后,下一步是给每个文档打分。分数决定了排序,所以打分模型的选择直接决定了检索质量。
4.1 TF-IDF的核心思想
TF-IDF由两部分组成:
- TF(词频):一个词在文档里出现越多,说明文档越可能跟这个词相关。
- IDF(逆文档频率):一个词在越少的文档里出现,说明它越有区分度,权重越高。
公式:
TF-IDF = TF × IDF IDF = log(总文档数 / 包含该词的文档数)直觉上很好理解:如果"的"出现在所有文档里,IDF接近0,权重极低;如果"倒排索引"只出现在3个文档里,IDF很高,命中它的文档应该排前面。
4.2 TF-IDF的局限
TF-IDF有个问题:TF是线性增长的。一个词出现10次和出现20次,TF翻倍,分数也翻倍。但实际上,出现10次已经说明文档很相关了,出现20次并不会让相关性翻倍。这种线性增长会让某些堆砌关键词的文档获得不合理的高分。
另外,TF-IDF没有考虑文档长度。长文档的TF天然更高,会不公平地占优。
4.3 BM25的改进
BM25在TF-IDF基础上做了两个关键改进:
第一,TF饱和。BM25的TF部分用了饱和函数,词频增加到一定程度后,分数增长趋缓:
TF_component = (tf × (k1 + 1)) / (tf + k1 × (1 - b + b × docLen / avgDocLen))其中k1控制饱和速度(通常取1.2到2.0),b控制长度归一化强度(通常取0.75)。
第二,文档长度归一化。公式里的docLen / avgDocLen就是长度归一化因子。文档越长,分母越大,TF贡献越小,抵消了长文档的优势。
4.4 两种模型的实测对比
我在一个约5万篇文档的测试集上对比过两者:
| 指标 | TF-IDF | BM25 |
|---|---|---|
| 短查询准确率 | 一般 | 较好 |
| 长文档公平性 | 差 | 好 |
| 关键词堆砌抗性 | 差 | 好 |
| 实现复杂度 | 低 | 中 |
| 计算速度 | 快 | 略慢 |
结论很明确:只要不是极端追求性能,直接用BM25。它的实现复杂度只比TF-IDF高一点点,但效果提升明显。我现在的项目里默认都用BM25。
4.5 BM25的Java实现要点
public double bm25Score(int tf, int docLen, double avgDocLen, int totalDocs, int docFreq, double k1, double b) { double idf = Math.log(1 + (totalDocs - docFreq + 0.5) / (docFreq + 0.5)); double tfComponent = (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * docLen / avgDocLen)); return idf * tfComponent; }注意IDF的写法,我用的是带平滑的版本log(1 + (N - df + 0.5)/(df + 0.5)),这样即使某个词出现在所有文档里,IDF也不会是负数。标准BM25的IDF在某些情况下会算出负值,导致分数异常,平滑版本能避免这个问题。
4.6 多关键词的分数合并
用户查询通常有多个词,每个词算出一个分数,怎么合并?最简单的是相加。但要注意:不同词的IDF差异很大,直接相加会让高IDF的词主导结果。
我的做法是:每个词的分数先做归一化,再相加。归一化可以用该词在所有文档中的最大分数做分母。这样每个词的贡献被拉到同一量级,合并更合理。
另外,如果查询里有短语(连续多个词),对短语匹配的文档额外加分。这个加分系数我一般设成1.5到2.0,具体值靠测试调。
5. 排序与结果处理:分数算完了还没结束
打分只是中间步骤,最终要输出一个排好序的结果列表。这一步有不少细节。
5.1 分数归一化到0-1区间
原始BM25分数没有固定范围,可能很大也可能很小。如果要在前端展示"相关度90%",就需要归一化。常用方法是用当前结果集里的最高分做分母:
double maxScore = results.stream() .mapToDouble(SearchResult::getScore) .max().orElse(1.0); for (SearchResult r : results) { r.setNormalizedScore(r.getScore() / maxScore); }但这样有个问题:如果结果集里只有一个文档,它的归一化分数永远是1.0,看起来"完美匹配",实际上可能只是矮子里拔将军。更稳妥的做法是维护一个全局的历史最高分,或者用一个理论上的分数上限做分母。我一般用当前结果集最高分,但在前端展示时加一句"相对相关度",避免误导。
5.2 排序的稳定性处理
当两个文档分数相同时,排序结果应该稳定。Java的Collections.sort是稳定排序,但如果你用parallelSort就不保证了。我的做法是:在比较器里加一个次级排序键,比如文档ID或者发布时间,确保分数相同时顺序确定。
results.sort(Comparator .comparingDouble(SearchResult::getScore).reversed() .thenComparingInt(SearchResult::getDocId));5.3 分页与截断
检索系统通常不需要返回所有匹配文档,用户只看前几页。所以可以在排序后直接截断:
int from = (page - 1) * pageSize; int to = Math.min(from + pageSize, results.size()); List<SearchResult> pageResults = results.subList(from, to);但要注意:截断前必须完成全部打分和排序,不能边打分边截断,否则可能漏掉高分文档。如果性能有压力,可以用堆(优先队列)维护Top-K,避免全排序。对于K远小于N的情况,堆排序能把复杂度从O(N log N)降到O(N log K)。
5.4 高亮关键词位置
用户搜完,通常希望看到关键词在文档里出现的位置被高亮。这需要位置信息。如果索引里存了位置列表,直接取出来即可;如果没存,可以在返回结果前对原文做一次匹配定位。
我的建议是:如果文档不大(几千字以内),不存位置信息,返回时现算。这样省内存,而且现算的逻辑简单,不容易出错。只有文档很大或者高亮要求很高时,才值得在索引里存位置。
6. 性能优化:从能用变成好用
功能跑通只是第一步,真正上线还得过性能这关。
6.1 查询词的处理顺序
多关键词查询时,先处理IDF高的词(也就是区分度高的词)。因为高IDF词命中的文档少,能快速缩小候选集。比如查询"的 倒排索引",先查"倒排索引",可能只有几十个文档命中,再在这几十个里过滤"的",比反过来快得多。
// 按文档频率升序排列查询词,先处理稀有的 List<String> sortedTerms = queryTerms.stream() .sorted(Comparator.comparingInt(t -> invertedIndex.getOrDefault(t, Collections.emptyMap()).size())) .collect(Collectors.toList());6.2 候选集的交集运算
如果查询有多个词,理想情况是取所有词命中文档的交集(AND语义)。但如果交集为空,就得退化成并集(OR语义)。我的做法是:先尝试交集,为空则退化为并集。这样既保证了精度,又不会让用户搜不到东西。
交集运算时,从最小的集合开始,逐个和其他集合求交,能减少比较次数。
6.3 缓存策略
热门查询的重复率很高。可以加一层LRU缓存,把查询词组合和对应的结果列表缓存起来。缓存key用排序后的查询词拼接,避免"AB"和"BA"被当成两个查询。
String cacheKey = queryTerms.stream() .sorted().collect(Collectors.joining("|"));缓存要注意失效:文档增删改后,相关缓存要清掉。简单做法是文档一变就清空整个缓存,粗暴但有效。精细做法是维护词到缓存的映射,只清受影响的。我一般先用粗暴做法,等性能真的成瓶颈了再优化。
6.4 索引构建的并行化
如果文档量大,构建索引可以并行。把文档分片,每个线程处理一片,最后合并倒排索引。合并时要注意线程安全,用ConcurrentHashMap或者分段锁。
但并行构建有个坑:合并阶段可能成为瓶颈。如果每个线程都产出一个完整的倒排索引,合并时要把所有词的所有文档列表合并,开销很大。更好的做法是让每个线程处理不同的词范围,或者用MapReduce的思路,先局部聚合再全局合并。
6.5 内存占用的控制
倒排索引很吃内存。一个优化技巧是:用int数组代替List存文档ID。List 每个元素都有对象开销,int数组紧凑得多。代价是插入时不方便,适合索引构建完成后不再变动的场景。
另一个技巧是对文档ID做差值编码。如果文档ID是递增的,存差值比存原值占空间小。查询时再还原。这个优化在文档量极大时才明显,小规模没必要。
7. 几个我踩过的坑和对应的解法
理论讲完了,说几个实际开发中遇到的坑,都是文档里不会写的。
7.1 空查询和单字查询的处理
用户可能输入空字符串,或者只输入一个停用词。这时候如果直接走检索流程,要么报错,要么返回一堆无意义结果。我的处理是:空查询直接返回空列表,单停用词查询返回提示"请输入更有意义的词"。别小看这个,线上真的有人这么搜。
7.2 特殊字符导致的查询异常
用户输入里可能包含正则特殊字符、引号、括号等。如果查询解析没做好,轻则匹配错误,重则抛异常。我的做法是:查询词先做转义和过滤,只保留字母、数字和常见标点。中文标点也要处理,全角和半角要统一。
7.3 同义词和近义词的召回
用户搜"电脑",文档里写的是"计算机",就搜不到。解决办法是维护一个同义词表,查询时把同义词也加进去一起检索。同义词表可以手工维护,也可以从业务数据里挖掘。我一般先手工维护核心词,后面再考虑自动化。
7.4 索引更新时的并发问题
如果系统支持实时添加文档,索引更新和查询会并发。用ConcurrentHashMap能保证单个操作的线程安全,但"读-改-写"这种复合操作还是可能出问题。我的做法是:索引用读写锁保护,写的时候加写锁,读的时候加读锁。虽然会降低一点并发度,但保证了正确性。如果并发要求极高,可以考虑双缓冲——维护两份索引,写的时候写新的,写完切换指针。
7.5 打分结果的解释性
有时候用户会问"为什么这个文档排第一"。如果打分过程是个黑盒,很难解释。我的做法是:在返回结果里附带每个查询词的命中次数和贡献分数。这样既能解释排序,也方便调试。调试阶段这个信息特别有用,能快速定位是哪个词的分数异常导致排序不对。
8. 从单机到可扩展:后续可以怎么演进
这套系统在单机、十万级文档的场景下完全够用。如果数据量继续涨,有几个演进方向。
8.1 索引分片
把文档按某种规则(比如ID范围或哈希)分成多个分片,每个分片独立建索引。查询时并行查所有分片,合并结果。这样单机内存不够的问题就解决了,而且查询可以并行加速。
8.2 引入向量检索做语义召回
关键词检索的硬伤是"字面匹配",搜"苹果手机"搜不到只写"iPhone"的文档。解决办法是加一路向量检索,把文档和查询都转成向量,用余弦相似度做语义召回,再和关键词检索的结果融合。这就是现在常说的混合检索。实现上可以用现成的向量库,也可以自己算,但自己算的话性能和效果都要打折扣。
8.3 学习排序
如果有点击日志,可以用学习排序(Learning to Rank)的方法,把BM25分数、文档质量、用户行为等特征喂给模型,让模型学出最优排序。这比手工调BM25参数效果好得多,但需要数据和模型训练的基础设施。小项目一般用不上,知道有这条路就行。
8.4 参数调优的经验值
最后分享几个BM25参数的调优经验。k1控制词频饱和,值越大,词频的影响越持久。对于短文本(标题、摘要),k1取1.2左右;对于长文本(正文),k1可以取1.5到2.0。b控制长度归一化,0表示不归一化,1表示完全归一化。一般取0.75,但如果你的文档长度差异很大,可以调高到0.8以上;如果文档长度都差不多,可以调低到0.5。
这些值没有绝对标准,最好的办法是准备一批标注数据,用网格搜索找最优。没有标注数据的话,就靠人工看Top-10结果,凭感觉调。我一般会准备20到30个典型查询,每次调参后看这些查询的结果有没有变好,避免过拟合到个别查询上。
整套系统写下来,核心代码量其实不大,倒排索引加BM25打分,几百行就能跑起来。难的是各种边界情况的处理和参数调优,这些只能靠实际跑数据、看结果、反复迭代。我建议你先用一个小数据集把流程跑通,再逐步加文档量,观察性能瓶颈出现在哪里,有针对性地优化。别一上来就想着做分布式,单机优化到位,大部分场景都够用了。