
先说个场景。你在做一个内容平台文章、帖子、商品描述都丢在 MongoDB 里。某天产品说要加一个全文搜索框你想着用regex一把梭结果数据量上了几十万之后一个带前导通配符的模糊查询慢到让用户怀疑服务器是不是断电了。这时候就该考虑 MongoDB 的文本索引了。文本索引Text Index本质上是 MongoDB 内置的一套倒排索引机制专门用来应对“在字符串内容里按关键词找文档”这种搜索场景。它能帮你做分词、词项匹配、相关性排序还能和普通查询条件自由组合。这个系列写到第 27 篇前面已经聊过了单字段索引、复合索引、唯一索引这些常规角色今天这篇我们正式进入文本检索的领域。这篇内容适合两类读者一是项目里已经出现模糊搜索性能瓶颈想看看 MongoDB 原生方案能不能顶上的后端开发二是刚接触 MongoDB、准备给业务加搜索功能但不确定该不该用它自带的文本索引还是在外面挂一个搜索引擎的初学者。我会先把原理讲透再带你把创建、查询、调优、避坑完整过一遍。1. 文本索引到底解决什么问题1.1 正则查询和文本索引的本质区别很多人第一反应是搜索不就是db.articles.find({ title: /关键词/ })嘛。数据量小的时候确实没问题但这里有两个隐藏的坑。第一正则查询本质是字符匹配它不关心这个词在语义上是什么。你搜“MongoDB 索引”它就是在字符串里找“MongoDB 索引”这串字符中间多个空格、换行、标点都可能导致匹配不上。第二如果正则不是前缀匹配比如/关键词/而不是/^关键词/B-tree 索引基本帮不上忙只能全表扫描数据一多就完蛋。文本索引走的是完全不同的路子。它会在写入时把字符串分词拆成一个个词项token然后记录每个词项出现在哪些文档里。这个数据结构就是倒排索引跟你在字典最后看到的“关键词-页码对照表”是一个道理。查询的时候用户输入“索引”系统直接去倒排表查“索引”这个词拿到文档列表压根不用扫全表。1.2 倒排索引——文本索引的底层工作原理我用一个生活化的例子解释倒排索引。想象你有一本几百页的菜谱想找所有包含“鸡翅”的菜。没有索引的时候你得从第一页翻到最后一页眼睛扫每一行字。但如果你在书最后维护了一个“食材表”写着“鸡翅第 32 页、第 87 页、第 210 页”那查起来就是翻到固定页码的事。MongoDB 的文本索引就是维护了这么一张“词项-文档列表”的大表。每个词项后面挂着一串文档 ID以及这个词在文档里出现的次数和位置。次数和位置不是白记录的后面算相关性分数textScore全靠它们。这里有个关键点值得注意文本索引不是 B-tree它不支持范围查询、前缀查询那套玩法。你没法用它做field: { $gt: 2024 }这种条件它是独立的索引类型专门服务$text查询。这也是很多新手最容易搞混的地方。1.3 为什么不用 Elasticsearch聊到全文搜索很多人会问那直接用 Elasticsearch 不就行了我的看法是这取决于你的业务量级和团队运维能力。MongoDB 文本索引的定位是“轻量级内置方案”。如果你的数据量在百万级以内、搜索需求就是简单关键词匹配、不想额外维护一套 Elasticsearch 集群那它完全够用。而且它和业务数据在同一个库里事务、聚合、权限体系都能复用省去了数据同步的麻烦。但如果你的场景是海量日志检索、中文分词要求高、需要复杂的查询语法比如多字段加权、模糊匹配、同义词扩展、QPS 很高那 MongoDB 文本索引就有点吃力了。它毕竟是一个数据库的附属功能不是一个专业的检索引擎。我见过不少项目一开始图省事用文本索引后来搜索需求复杂到一定程度还是老老实实上了 Elasticsearch。所以选型时要想清楚边界。2. 如何创建文本索引2.1 单字段文本索引创建文本索引的语法很简单把一个字段的值设为text就行。db.articles.createIndex({ title: text })这条命令会给articles集合的title字段创建文本索引。建立之后你就可以用$text运算符来搜索了。db.articles.find({ $text: { $search: 索引 } })这个查询会返回所有title字段中包含“索引”这个词的文档。注意这里的“包含”是分词后词项匹配不是简单的子串匹配。什么意思呢如果文档标题是“MongoDB 索引实战”分词后会产生“mongodb”、“索引”、“实战”这几个词项所以搜“索引”能命中但如果标题是“索引实战遇到的问题”分词后依然包含“索引”也能命中。可如果你搜“索引实”那就命中不了因为分词器不会把“索引实”当作一个词。2.2 多字段组合与权重实际业务里很少只搜一个字段通常是标题和正文一起搜而且标题的匹配应该比正文更“值钱”。MongoDB 允许你在一个文本索引里包含多个字段并给每个字段设置权重weight。权重越高该字段命中时贡献的相关性分数越大。db.articles.createIndex( { title: text, body: text, tags: text }, { weights: { title: 10, tags: 5, body: 1 }, name: idx_articles_fulltext } )这段代码创建了一个覆盖title、body、tags三个字段的文本索引权重分别是 10、5、1。默认权重是 1如果你不指定weights那所有字段一视同仁。为什么需要权重因为相关性排序时“标题命中”和“正文里不小心提到一次”显然是两种不同的相关程度。给标题更高的权重可以让标题匹配的文章排在最前面。这个设计非常符合搜索产品的直觉。这里还要提醒一点一个集合只能有一个文本索引。你不能给title建一个文本索引又给body建另一个。但没关系一个文本索引可以覆盖任意多个字段。如果真的需要不同字段用不同的分词规则比如一个字段要中文分词另一个要英文原样那就得考虑拆集合或者上外部搜索引擎了。2.3 通配符文本索引的便利与代价如果你不想费心枚举所有需要搜索的字段MongoDB 还提供了一种更暴力的方式——通配符文本索引。db.articles.createIndex({ $**: text })这个索引会对集合里所有字符串字段以及字符串数组元素建立文本索引。听起来很爽以后不管哪个字段加了字符串内容搜索都能直接覆盖到。但代价也很明显索引体积会成倍增长写入性能会下降而且某些你根本不想被搜索的字段比如作者备注、内部标识符也会被索引进去可能导致搜索结果出现不相关的内容。我个人的建议是通配符文本索引只适合原型验证或者字段结构极度不固定的场景。正式环境尽量明确指定字段把索引范围控制住性能和结果质量都可控。另外通配符文本索引要求textIndexVersion为 2这是当前版本的默认值正常情况下不需要额外处理。2.4 中文分词这个老大难前面说的都是机制接下来要聊一个 MongoDB 文本索引没法回避的痛点中文分词。MongoDB 内置的分词器是基于空格和标点来切词的这处理英文、数字非常顺手因为单词之间天然有空格分隔。可中文是连续字符串“索引实战”四个字之间没有空格默认分词器会把它当成一个完整词项。这时候你搜“索引”可能反而匹配不上“索引实战”这个文档因为它根本不认为“索引”是一个独立的词。官方文档里列出的默认支持语言有几十种但确实没有真正意义上的中文分词支持。那怎么办我实践下来比较靠谱的方案是写入时预先分词把分词结果放到一个数组字段里对这个数组字段建文本索引。举个例子你可以在文档里维护一个search_text字段{ title: MongoDB索引实战, search_text: [mongodb, 索引, 实战] }写入前用第三方分词库比如 IKAnalyzer、HanLP、jieba 的某种服务化形态把标题和正文分词塞进search_text。然后对search_text建文本索引db.articles.createIndex({ search_text: text })查询的时候也同样用分词器把用户的搜索词处理好再丢给$text。这套方案我实际跑过搜索效果和性能都比直接用默认分词器搜原始文本好很多。本质上是把分词这个智力活从数据库里挪到了应用层MongoDB 只负责它擅长的倒排和排序。3. 文本搜索的完整实操3.1 $text 查询语法详解创建好索引之后核心的查询语法就是$text运算符配合$search指定搜索词。这里面的语法细节比较多我逐个说。最基本的用法db.articles.find({ $text: { $search: 索引 优化 } })多个词用空格分隔默认是OR 逻辑。也就是说文档里只要包含“索引”或者“优化”任意一个词就会被查出来。这是很多新手容易踩的坑——以为空格是“和”的意思实际上 MongoDB 是“或”。如果想要求多个词全部命中可以用引号把短语包起来或者用$search的组合语法。精确短语匹配的写法是把短语放在双引号里db.articles.find({ $text: { $search: \索引优化\ } })这个查询要求文档中必须出现完整的“索引优化”这个词组分词后的相邻词项序列而不是分开的“索引”和“优化”。排除某个词在词前面加负号db.articles.find({ $text: { $search: 索引 -优化 } })这会匹配包含“索引”但不包含“优化”的文档。注意排除词不能单独使用比如$search: -优化是语法错误。还有一点特殊字符比如-、的处理MongoDB 的文本查询语法对转义比较敏感如果搜索词本身包含这些符号需要用\转义。我建议在实际业务里对用户输入做一层清洗避免用户乱输入特殊字符导致查询报错。3.2 相关性排序与分数计算文本索引比正则查询强的一个重要能力就是相关性排序。通过$meta操作符可以把textScore取出来再按它排序。db.articles.find( { $text: { $search: 索引 优化 } }, { score: { $meta: textScore } } ).sort({ score: { $meta: textScore } })textScore衡量的是文档与搜索词之间的匹配程度得分综合考虑了词项在文档中出现的频率、词项在索引字段中的权重、以及包含搜索词的文档数量等因素。简单理解一个词在越少的文档中出现而你的文档命中了它那这个文档的分数就越高。这就是经典的 TF-IDF 思想。实际观察下来textScore的数值范围跟数据分布关系很大没有固定的“多少分算相关”。所以产品上如果要展示匹配度百分比需要自己做个归一化处理直接用原始值给用户看会很莫名其妙。另外我要特意提醒一句textScore只能在有$text查询条件时使用。普通查询里你用$meta: textScore取字段分数永远是 null。3.3 与过滤条件、分页组合使用实际业务中的搜索99% 都伴随着过滤条件。比如只搜索某个分类下的文章或者只搜索浏览量大于某个值的文章。db.articles.find({ $text: { $search: 索引 }, category: database, views: { $gt: 100 } })MongoDB 的查询优化器会自己决定先走文本索引还是先走普通索引过滤但大多数情况下它会先用文本索引找出候选文档再根据其他条件做过滤。你可以用.explain()查看执行计划确认查询是否用对了索引。分页也很常规配合.skip()和.limit()即可。但如果你在排序中使用了textScore又要做分页这类查询在数据量大时会比较吃内存因为它需要先把匹配的所有文档按分数排好才能取某一页。还有一个常见的需求搜索关键词高亮。MongoDB 文本索引本身不提供高亮能力你得自己在应用层根据命中的词项对返回的文本做高亮标记。一般来说把搜索词拆开后各自在文本里定位替换就可以了。4. 文本索引的调优与性能排查4.1 用 explain 确认索引真的被用上了文本索引建了是不是查询就一定走它不一定。我见过有人建了索引但查询条件里还有正则表达式结果执行计划显示走了全表扫描。排查方法就是explaindb.articles.find({ $text: { $search: 索引 } }).explain(executionStats)关注输出里的winningPlan如果stage是TEXT或者TEXT_MATCH说明索引生效了如果是COLLSCAN那就有问题。另外注意executionStats里的totalDocsExamined理想情况下应该接近实际返回的文档数而不是整个集合的文档数。如果两者差距巨大说明很多文档是被过滤掉的需要分析查询条件是否有问题。文本索引有一个特性需要了解它不支持覆盖查询。普通索引如果字段足够可以直接从索引里返回结果不用回表读文档文本索引不行因为它需要读取文档内容来做评分、高亮等后续操作所以无论如何都会有一层 FETCH 阶段。这不是 bug是设计如此别在这个问题上纠结。4.2 索引膨胀与写入性能文本索引的存储开销比普通 B-tree 索引大不少。原因很简单普通索引一条文档记录对应一个索引条目文本索引一条文档得为每个词项各记一笔。一篇 1000 字的文章假设去重后有 200 个词那索引里就多了 200 个条目。我遇到过的一个真实案例一个集合的数据量大约 200GB文本索引建完占了快 80GB接近数据的 40%。而且这个索引对大字段中的每个词都做了记录膨胀非常明显。写入性能方面每插入或更新一篇文档都意味着要对该文档重新分词并更新倒排表这比更新普通索引要贵得多。所以文本索引不太适合“高频写入 实时搜索”的场景如果有这种需求建议考虑用延时同步到外部搜索引擎的架构。在大集合上创建文本索引我的经验是在业务低峰期执行。现在的 MongoDB 版本默认就支持在后台构建索引不用手动指定background: true但后台构建会持续占用 CPU 和内存资源对正在运行的业务会有影响。4.3 让文本索引跑得更好的几个参数习惯第一控制索引字段的数量和长度。能索引标题就不索引正文能索引摘要就不索引全文。字段越短词项越少索引越小查询越快。第二合理设置权重但别盲目拉大差距。权重差距过大会让某个字段完全主导排序结果反而忽略其他字段的相关性。我一般把标题设为 5-10正文设为 1-2差距在 5 倍以内比较稳妥。第三关注语言和停用词。默认情况下MongoDB 对英文会忽略掉 “the”、“a”、“and”这类停用词。如果你索引的字段是英文技术文档这是好事但如果你的产品里 “to” 这种词是有业务含义的比如某个编程名词就需要通过default_language参数或自定义分词方案来处理。不要把默认行为当成理所当然得确认是否符合业务预期。第四查询词数量要控制。$search里词项越多倒排取交集/并集的计算量越大。用户输入一长串模糊词时要么做词数裁剪要么把常见词过滤掉别让数据库去做无谓的计算。4.4 中文搜索的性能与准确率平衡回到中文这个老大难问题。如果用了“预先分词存数组”的方案还有几个细节值得注意。数组越长索引越大这是绕不过去的。所以我建议数组里只存有搜索价值的核心词不要把分词器吐出来的每个词都塞进去。比如“的”、“了”、“是”这种无意义词分词阶段就应该过滤掉。查询阶段的处理也很关键。用户输入“索引优化实战”如果你把整句话作为分词粒度的词组去搜可能什么都搜不到如果拆成“索引”、“优化”、“实战”三个词分别搜又可能召回太多不相关的文档。我的做法是对短查询词2 个字以内的做 AND 匹配对长查询词拆成多个词做 OR 匹配再依靠textScore排序把最相关的顶上来。这样在召回率和准确率之间能取得一个相对好的平衡。5. 常见问题与避坑记录5.1 问题速查表我把实操中高频踩坑的问题整理成一个速查表方便你遇到问题时直接对照。现象常见原因解决办法中文搜不到预期结果默认分词器不识别中文把整句当一个词写入前预先分词存数组字段并建索引查询报错找不到文本索引集合里没有文本索引或索引字段与查询字段不匹配检查索引定义确认createIndex包含了查询字段$text与普通条件组合查询变慢普通条件没有索引先做了全表过滤给过滤字段建普通索引配合文本索引使用相同文档重复出现在结果里多个字段命中导致评分重复计算属于正常现象依靠textScore排序展示相关度最高的结果查询包含特殊字符报错用户输入里的-、没有被正确转义在应用层清洗输入必要时用\转义索引创建超时或内存占用过高数据量大文本索引本身膨胀严重低峰期创建控制字段长度和数量必要时拆分集合搜索结果里出现了不想搜索的字段内容使用了通配符文本索引$**改用显式指定字段的文本索引5.2 我踩过的几个坑讲几个真实发生过的案例。第一次用文本索引时我没有指定$language默认语言是英语。当时索引的字段里有大量中文内容分词效果一塌糊涂不说最诡异的是有些短单词被当成停用词过滤掉了导致怎么搜都搜不到。后来我把中文场景的分词逻辑整体移到了应用层数据库这边彻底放弃默认分词问题才真正解决。还有一次我在给一个字段建文本索引时发现总是报错。后来排查半天才知道是集合里有部分文档包含一个language字段MongoDB 会默认用这个字段来覆盖索引的语言设置。如果我前言不搭后语地存了个language: french那索引对那篇文档的分词规则就变成法语了查中文当然搜不到。解决办法是创建索引时指定language_override为一个业务里不会用到的字段名比如lang_override从而避免和业务字段冲突。第三个坑是关于查询语义的。有次产品反馈“搜‘索引 优化’应该两个词都匹配才对为什么只有一个词也能搜出来”。这就是前面说的空格默认 OR 问题。我后来在查询前对用户输入做了处理如果用户没有明确要求短词之间默认用 AND 逻辑取交集。这里没有标准答案完全看产品预期但一定要让团队所有人知道这个默认行为不然就等着产品来找你“修 bug”吧。最后再分享一个小技巧。文本索引虽然好用但它不是万能药。如果哪天你发现搜索需求开始频繁涉及同义词替换、拼音纠错、多语言混合检索那就说明业务已经到了需要考虑专业搜索引擎的节点。在 MongoDB 上做各种 hack 不是不能撑但长期维护成本会越来越高。尽早评估别等技术债堆到不可收拾才动手。