想象你是一家藏书百万的大图书馆的咨询台管理员。每天都有读者红着脸问:“我想找一本 'Mon' 开头的书,书名怎么都想不起来了!” 你手里那本厚厚的主题卡片目录(倒排索引),只能按完整主题词精确找到对应书架;面对 mon* 这种残缺线索,目录完全派不上用场。老馆长教了你两招祖传手艺:一种是给每个单词系上“结尾小旗子”,再转着圈排列——这就是旋转索引;另一种是把所有单词切成连续的小碎片,再按碎片反向归档——这就是 K-gram 索引。前者把后缀通配变成前缀查询,后者靠多个碎片的交集锁定候选词,最后再用原模式过筛。本文就用图书馆里的这些日常,把通配符查询与 K-gram 索引的原理、流程和优化讲得明明白白。

岗位关联

通配符查询与 K-gram 属于倒排索引之上的“词项召回层”。掌握它,你才能在搜索与推荐系统中低成本支持模糊匹配、拼写容错和查询联想,这是查询理解、召回优化等岗位的基本功。

目录
  1. 可视化演示
  2. 通配符查询基础
  3. 倒排索引的困境
  4. 旋转索引
  5. K-gram 索引原理
  6. 查询处理流程
  7. 性能优化策略
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

演示K-gram生成、通配符查询处理流程与K值选择权衡,理解模糊匹配的索引与查询机制。

本节配有交互动效(进入视口后自动加载,约 3KB)。

K-gram生成:滑动窗口切分 词项 子串 $monk$ 按长度K滑动窗口提取子串

通配符查询基础

读者小陈跑到咨询台前,脸涨得通红:“我想找一本讲 'Mon' 开头的书,可书名怎么都想不起来了!” 管理员王姐微微一笑,在便签上写下三个字符:mon*。这串看似简单的记号,就是通配符查询——用占位符代表记不清的部分,请搜索引擎帮忙猜。图书馆里每天都有这样的模糊需求:“以 land 结尾的书”、“中间是 a 和 n 的词”……通配符就是表达这种“残缺记忆”的通用语言。

在搜索引擎术语里,通配符有两类最常见的占位符:星号 * 表示匹配任意长度的字符序列(可以为空),问号 ? 表示恰好匹配一个字符。例如 mon* 可以命中 "monitor"、“monday“ 这类以 mon 打头的词;m?ney 则可以命中 ”money”,因为 ? 恰好占掉字母 o 的位置。按通配符的位置,还能分成前缀通配(mon*)、后缀通配(*land)和中缀通配(m*n)三类。

为什么要把这些记不清的查询方式单独拿出来研究?因为真实用户输入并不总是完整准确的。搜索日志里充斥着拼写残缺、记忆模糊的查询串,而搜索引擎必须在倒排索引之外,再设计一套“词项召回”机制来兜底。通配符查询正是这种机制的直接需求来源。它看似只是符号问题,实际却决定了整个检索系统能否“听懂”用户残缺的表达。

回到图书馆,小陈的 mon* 被王姐郑重记下。接下来王姐要先翻那本主题卡片目录——但真正的麻烦,才刚刚开始。

倒排索引的困境

王姐翻开那本厚厚的主题卡片目录:每一张卡片上都写着“主题词 → 一排书名”。比如卡片 “monitor“ 背面列着《显示器维修指南》;卡片 ”monday“ 背面列着《Monday 健身食谱》。这就是倒排索引——从词项指向文档列表的反向映射,检索时靠它一步定位文档。可当王姐想找 mon* 时,她犯了难:目录里根本没有一张叫 ”mon\*” 的卡片!

问题就出在倒排索引的“精确匹配”本性上。倒排索引的词典层通常用哈希表或 B 树实现,查找时拿查询词和词典里的词项做完全相等比较。mon* 作为一个带星号的字符串,既不是 “monitor“ 也不是 ”monday“,它在词典里没有任何对应的槽位。换句话说,倒排索引只回答”这个词在哪些文档里出现“,不回答”哪些词长得像这个模式”。

那能不能绕开索引,把整个词典从头到尾扫一遍?这就是最朴素的暴力解法:把词典中每个词都取出来,用通配符模式逐一做正则匹配。设词典有 N 个词,每个词平均长度为 L,则总耗时是两者的乘积。当 N 达到百万量级时,一次通配查询就要做几千万次字符比较,让搜索引擎的服务延迟彻底失控。

这个复杂度让王姐直摇头:图书馆词典有上百万张卡片,每张卡片上的单词都拿去比一遍“mon\*“,读者怕是要等到闭馆。看来,非得有一本”专门为残缺线索准备”的辅助索引不可。

T=O(N×L)T = O(N \times L)
想靠顺序扫描支持通配符查询,总工作量大约等于“词典词数 × 平均词长”,代价高到无法接受。
  1. 扫描每个词项词典里有 N 个词,暴力做法要把它们一个一个地拿出来做匹配。
    N=∣Dict∣N = |\text{Dict}|
  2. 每个词做模式比对每个词平均长度 L,用通配符模式去匹配它,成本正比于词长。
    L=avg_len(word)L = \text{avg\_len(word)}
  3. 总成本相乘两层乘起来,就是全词典暴力扫描的时间复杂度。
    T=O(N×L)T = O(N \times L)

旋转索引

老馆长笑呵呵地拿出一沓“旋转便签”:“光靠目录当然不行,你得换个角度看单词。” 所谓旋转,就是把每个单词想象成一个圆环,在任意位置切开,再在切口处插一面 $ 小旗子表示单词结尾。比如 "mon",旋转后可以得到 mon$、on$m、n$mo、$mon 等几种形式。把这些旋转形式全部按字典序排好,就构成旋转索引(Permuterm Index)。

旋转索引的精妙之处在于:它把“后缀通配”偷换成了“前缀查询”。假设读者想找以 land 结尾的书,即查询 *land。管理员只需在旋转索引里查前缀 land$:所有旋转形式以 land$ 开头的条目,对应的原词自然是以 land 结尾的。同理,查询 mon* 可以转成查前缀 $mon。这样原本倒排索引束手无策的通配符问题,就被“转一圈”化解成了普通的前缀查找。

但旋转索引并非免费午餐。每个长度为 L 的词会产生 L+1 种旋转形式,索引总规模膨胀到原词典的 L 倍左右。更头疼的是,它只擅长处理前缀或后缀通配;遇到 m*n 这种中间带星号的查询,需要拆成前后两段分别旋转再交叉求交,复杂度直线上升。王姐记下心得:旋转索引像是“一个方向上的魔法”,灵活但昂贵。

旋转便签虽然好用,但面对“中间缺失”的情况依然捉襟见肘。馆长又拿出一沓更细碎的小卡片:“别急,还有更通用的切片法。”

Perm(w)={cuti(w)+$+lefti(w)∣0≤i≤∣w∣}\text{Perm}(w) = \{ \text{cut}_i(w) + \$ + \text{left}_i(w) \mid 0 \le i \le |w| \}
把一个单词从任意位置“切开”再换位拼接,并插入结尾符 $,就得到它的所有旋转形式。
  1. 词尾补结束符给单词末尾补一个 $,“结尾”从此有了独特标记,避免旋转后头尾混淆。
    w+$w + \$
  2. 在任意位置切开i 从 0 到 |w|,把单词切出左右两段。
    left(w)=w[0..i],right(w)=w[i+1..∣w∣]\text{left}(w) = w[0..i], \quad \text{right}(w) = w[i+1..|w|]
  3. 旋转拼接右段放前面、左段放后面,中间补 $,形成一种旋转形式。
    cuti(w)=right(w)+$+left(w)\text{cut}_i(w) = \text{right}(w) + \$ + \text{left}(w)
  4. 排序建索引所有旋转形式按字典序排列后,查询 mon* 就变成查前缀 $mon,命中后映射回原词。

K-gram 索引原理

老馆长拿出的新工具,是一盒“字母切片卡”。做法很朴素:把每个单词前后各补一个 $,然后切成所有长度为 k 的连续小片,这些小片就叫 k-gram(克)。例如单词 "monday" 加上边界符变成 $monday$,切成 2-gram 后得到:$m、mo、on、nd、da、ay、y$ 共 7 个小片。每一张小卡片正面写一个克,背面写上所有包含这个小片的单词——这就是 K-gram 索引。

为什么这套“切片卡”比旋转便签更通用?因为它把“单词长得像什么”彻底拆解成了“由哪些小零件组成”。无论通配符出现在前缀、后缀还是中间,只要把查询的字面部分也切成同样的克,就能通过“零件交集”找到候选词。旋转索引只是把词转了个圈,而 K-gram 则把词碎成了可以自由组合的积木,通用性大幅提升。

当然,积木不是白来的。对于长度为 L 的词,共产生约 L+k 个克,索引空间近似正比于全部词的总字符数;k 越小,每个克越短,匹配越宽泛、候选越多,但索引更小;k 越大,克越长、选择性越强,但索引更大,且对查询字面长度有要求。实际系统中用得最多的是 k=2 或 k=3,在召回率和空间开销之间取一个平衡点。

王姐看着满抽屉的切片卡,突然意识到:卡片背后那串词项清单,不就是一本微型的“零件倒排索引”吗?现在关键的问题是——读者报出的残缺书名,该怎么利用这些切片卡找到真正的候选书?

Gramk(w)={ w[i:i+k]∣1≤i≤∣w∣−k+1 }\text{Gram}_k(w) = \{ \, w[i : i+k] \mid 1 \le i \le |w|-k+1 \,\}
一个词的全部 k-gram,就是用它所有长度恰好为 k 的连续子串组成的集合。
  1. 前后补边界符在 w 首尾各加一个 $,让开头的 $m、结尾的 y$ 也能成为克。
    w′=$+w+$w' = \$ + w + \$
  2. 滑动窗口从第 1 个字符开始,窗口长度固定为 k,每次向右移动 1 位。
    w′[i:i+k],i=1,2,…,∣w′∣−k+1w'[i : i+k], \quad i = 1, 2, \ldots, |w'|-k+1
  3. 去重收集把所有窗口得到的子串放入集合,就得到这个词的克集合。
    Gramk(w)={g1,g2,…,gm}\text{Gram}_k(w) = \{ g_1, g_2, \ldots, g_m \}

查询处理流程

小陈的查询 mon* 又被拿了出来。王姐的处理流程分三步走。第一步,把模式中的通配符剥掉,留下连续的字面部分 "mon",再用 2-gram 切法得到克集合 {mo, on}。第二步,翻开切片卡,分别查 mo 卡和 on 卡背后的词项清单,然后取两串清单的交集。交集里出现的词,就是同时包含这两个克的候选词项:如 monitor、monday、money,可居然还混进了 demon——它明明不以 mon 开头!

这暴露了克交集的一个本质特点:它只是“候选超集”。demon 虽然同时包含 mo 和 on,却完全不符合 mon* 的前缀约束。因此必须有第三步——用原始通配符模式对候选集再做一次正则匹配过滤,把 demon 这类“零件凑巧相似”的误报剔除。正是因为有这一步,K-gram 索引才能安心负责“广撒网”,最后的“精准收网”交给模式验证完成。

把这个流程写成公式:设查询 q 的字面部分是 ℓ,先对 ℓ 生成克集合,再对每个克查它的倒排候选表 Post(g),最后把所有这些候选表做交集,得到候选集合 C(q)。最终结果还需满足 q 本身的正则。倒排索引的职责是“文档定位”,而 K-gram 的职责是“词项召回”,两层配合才构成完整答案。

王姐在实践中发现,流程虽然清晰,但当切片卡背后的清单长得吓人时,求交仍然很慢。有没有办法让管理员少翻几张卡、少对几行名?

C(q)=⋂g∈Gramk(ℓ)Post(g)C(q) = \bigcap_{g \in \text{Gram}_k(\ell)} \text{Post}(g)
通配查询的候选集,等于“字面部分所有克的倒排候选列表”的交集——交集能缩小范围,但不能保证精确。
  1. 剥离通配符从模式 q 中取出连续字母部分 ℓ,它是后续切克的原料。
    ℓ=literal(q)\ell = \text{literal}(q)
  2. 对 ℓ 生成克用 Gram_k 把字面部分切成若干克。
    G=Gramk(ℓ)G = \text{Gram}_k(\ell)
  3. 查每个克的倒排表在 K-gram 索引中,为每个克取回候选词项集合。
    Post(g)={w∣g∈Gramk(w)}\text{Post}(g) = \{ w \mid g \in \text{Gram}_k(w) \}
  4. 取交集缩小候选只有出现在所有克列表中的词,才有资格进入候选集。
    C(q)=⋂g∈GPost(g)C(q) = \bigcap_{g \in G} \text{Post}(g)

性能优化策略

王姐的切片卡越攒越多,问题也来了:热门克背后的词项清单长得像流水账,比如 on 卡背面密密麻麻写了几百个词。每一次求交都要把这些清单逐一比对。馆长给她支了三招。第一招叫“稀有优先”:查询时先算每个克的稀有度,从最稀有的克开始求交。因为稀有克的候选少,先把它和其他克交一轮,候选集合会迅速收缩,后续比对成本骤降。

第二招叫“阈值截断”:如果一个克的候选清单已经超过预设阈值(比如 1000 个),说明它区分度太低,直接放弃用它做交集,或者限制只取前若干项。这相当于告诉管理员:“这张卡太没营养,翻都不要翻。” 第三招叫“结果缓存”:高频克(如 on、er)的候选清单经常被反复查询,系统可以把计算结果缓存下来,下次直接取现成的,省去重复读索引的时间。此外,多个克的查询互不依赖,还能并行处理,让多核服务器同时翻好几张卡。

其中“稀有优先”策略背后有一个简洁的排序依据——用类似 IDF 的思想度量克的稀有度:N 是词典总词数,df(g) 是包含克 g 的词项数量,稀有度得分越高的克越值得优先处理。

经过这轮优化,王姐的检索台效率翻倍。她终于理解了馆长的智慧:面对残缺信息,不要硬着头皮全馆扫描,而是先把世界拆成小零件、按零件先过滤、再用原始需求收尾。这就是通配符查询与 K-gram 索引的全部精髓。

priority(g)=log⁡Ndf(g)\text{priority}(g) = \log \frac{N}{\text{df}(g)}
一个克在词典中出现得越少,它的稀有度得分越高,优先用它做交集就能更快缩小候选范围。
  1. N 是词典词项总数它是稀有度计算的基准规模。
    N=∣Dict∣N = |\text{Dict}|
  2. df(g) 是克 g 的文档频率统计有多少个词包含这个克。
    df(g)=∣{w∣g∈Gramk(w)}∣\text{df}(g) = |\{ w \mid g \in \text{Gram}_k(w) \}|
  3. 取对数算稀有度df 越小,比值越大,得分越高,表示这个克越“挑人”。
    priority(g)=log⁡Ndf(g)\text{priority}(g) = \log \frac{N}{\text{df}(g)}
  4. 按得分降序处理先算最稀有的克,候选集迅速缩小,后续求交成本直线下降。

AI 应用场景

搜索引擎的“边输入边联想”

阶段:查询理解 / 召回

解决的问题:用户在搜索框输入 mac 还没输完,系统就要立刻联想出 macbook、machine learning 等候选。传统的倒排索引只能处理完整词项,无法理解“半截词”,更无法处理 mon* 这类残缺表达。K-gram 索引在词项层快速召回候选,再结合热度排序,就能让联想既快又准。

具体案例:在电商搜索中,用户输入 连衣,系统立刻提示 连衣裙、连衣短裙;在 Google 搜索中,输入 什么是倒排,下拉框即时补全 倒排索引。这些联想本质上都是先通过前缀克或旋转索引召回一批候选词项,再依据统计热度筛选出来的。

代码库与日志的模糊检索

阶段:索引构建 / 查询处理

解决的问题:开发者只记得变量名或函数名的一个片段,比如 user_info 只记得 user_i,或日志检索时只想查包含 time_ou 的报错标识。传统精确匹配索引完全失效,而 K-gram 索引能把符号名切成小片,用片段交集快速召回候选,再配合模式匹配做精确定位。

具体案例:GitHub 代码搜索支持在仓库内用 user_* 查找符号;IDE 的 "Quick Open" 功能用驼峰片段 UIF 匹配 UserInfoFormatter。这些工具背后都有类似 K-gram 的 n-gram 索引支撑词项级别的模糊召回。

Python 演示

python34 行
import re class KGramIndex:    """用 2-gram 索引支持通配符候选生成的小型演示。"""     def __init__(self, k=2):        self.k = k        self.words = set()        self.posting = {}     def grams(self, text):        padded = '$' + text + '$'        return [padded[i:i + self.k] for i in range(len(padded) - self.k + 1)]     def add(self, word):        self.words.add(word)        for g in set(self.grams(word)):            self.posting.setdefault(g, set()).add(word)     def query(self, pattern):        literal = pattern.replace('*', '').replace('?', '')        gram_sets = [self.posting.get(g, set())                     for g in set(self.grams(literal))]        candidates = set.intersection(*gram_sets) if gram_sets else set(self.words)        regex = re.compile('^' + pattern.replace('*', '.*').replace('?', '.') + '$')        return [w for w in self.words if w in candidates and regex.match(w)] if __name__ == '__main__':    vocab = ['monitor', 'monday', 'money', 'noon', 'night', 'onion', 'demon']    index = KGramIndex(2)    for w in vocab:        index.add(w)    print(index.query('mon*'))    print(index.query('*day'))
  1. padded = '$' + text + '$'给词首尾补边界符,保证开头的 $m 和结尾的 y$ 也能参与匹配。
  2. self.posting.setdefault(g, set()).add(word)每个克对应一个词项集合,构成克级倒排表。
  3. literal = pattern.replace('*', '').replace('?', '')先剥离通配符,提取连续字面部分作为切克原料。
  4. gram_sets = [self.posting.get(g, set()) for g in set(self.grams(literal))]为每个克取出候选词项集合。
  5. set.intersection(*gram_sets)对所有克的候选集合求交集,得到候选超集。
  6. regex = re.compile('^' + pattern.replace('*', '.*').replace('?', '.') + '$')把通配模式转成正则,用于最终过滤误报。
  7. return [w for w in self.words if w in candidates and regex.match(w)]候选与正则双重校验后才输出,保证结果精确。

常见误区

误区倒排索引本质就是查字典,给它加一层子串匹配逻辑就能直接响应通配符查询,不需要额外数据结构。

正解倒排索引的词典层要求精确匹配词项,才能返回文档列表。通配符查询首先要解决“哪些词项长得像这个模式”——这是词项召回问题,需要 K-gram 或旋转索引这样的辅助索引在词典上工作。否则就只能退化成全词典扫描,倒排索引的高速优势完全丧失。

为何常见:把“文档定位”和“词项查找”两个层级混为一谈,误以为一个倒排结构能同时解决两个不同的问题。

误区既然候选词是通过多个克的交集筛出来的,每个词都包含了所有克,那直接返回给用户就好,不用再验证。

正解克交集只保证候选词包含这些连续小片段,不保证整体匹配通配模式。比如 demon 包含 mo 和 on,却完全不符合 mon*。必须用原始模式再做一次正则过滤,候选集只是超集,不是答案。

为何常见:把“候选生成”和“精确匹配”两个阶段合并成一步,忽略了 K-gram 本质是召回工具而非精排工具。

误区K 越大,克越长,匹配越精确,候选越少,所以 K 应该尽量取大。

正解K 增大确实提升了选择性,但索引体积也随之膨胀,每个克更“长”导致匹配约束更强,字面部分太短时甚至生成不出足够的克。K 过小则候选爆炸。实际系统常用 k=2 或 k=3,并根据查询长度动态调整,还要配合阈值截断控制候选规模。

为何常见:只看到精确度收益,忽略了空间开销、查询长度限制和召回率之间需要平衡,犯了“参数越大越好”的直觉错误。

误区旋转索引把词尾旋转到前面,前后缀通配都能转换成前缀查询,所以中缀通配符也能一并搞定。

正解旋转索引主要解决纯前缀或纯后缀通配。对于 m*n 这类中缀通配,需要把模式拆成前后两段,分别做旋转查询再交叉合并,实现复杂、开销巨大。实践中中缀场景更常交由 K-gram 处理,因为克可以自由组合,天然适配任意位置的通配。

为何常见:看到“后缀能转前缀”就觉得“所有通配都能转前缀”,忽略了转换后还需要多段拼接与求交的额外成本。

面试题

Q1. 倒排索引的基本结构是下列哪一项?

单选题 · easy

  • A.词项到文档列表的映射← 正确答案
  • B.文档到词项的映射
  • C.字符到文档的映射
  • D.句子到词项的映射

答案:A。倒排索引是“反向”的,以词项为键、文档列表为值,方便从词快速定位文档。选项 B、D 方向反了,C 的粒度错了,K-gram 才是字符级的映射。

Q2. 查询 *land(后缀通配)最经典的辅助索引是?

单选题 · easy

  • A.旋转索引生成候选← 正确答案
  • B.顺序扫描全部文档
  • C.正则匹配全部词项
  • D.分词后取其并集

答案:A。旋转索引把 land 转成 land$ 前缀,从而把后缀通配变成前缀查找,是教科书级解法。B、C 是暴力法,D 与通配问题无关。

Q3. 单词 mon 经 2-gram(含边界符 $)切分后的克集合是?

单选题 · mid

  • A.$m、mo、on、n$← 正确答案
  • B.$m、mo、on
  • C.mo、on、n$
  • D.$、mo、on、n

答案:A。mon 补边界符为 $mon$,长度 5,滑动窗口 2 得到 4 个克:$m、mo、on、n$。B 少算了 n$,C 少了 $m,D 混入了孤立 $。

Q4. K-gram 索引的 Posting 列表中存放的是什么?

单选题 · mid

  • A.包含该克的文档编号
  • B.包含该克的词项集合← 正确答案
  • C.该克出现的文档频率
  • D.旋转后的词尾标记

答案:B。K-gram 索引工作在词典层,记录的是“哪个克的背后有哪些词项”,而不是文档编号。文档编号是倒排索引的职责,二者分层协作。

Q5. 处理查询 mon* 时,K-gram 候选集的正确生成方式是?

单选题 · mid

  • A.对两克定位求交集← 正确答案
  • B.对两克定位求并集
  • C.仅取第一个克定位
  • D.直接在 B 树中查找

答案:A。mon 切出 mo、on 两个克,取两个克候选列表的交集,保证候选词同时包含两个片段。并集太宽,单克太窄,B 树查不到模糊形式。

Q6. 下列关于旋转索引的说法正确的有?

多选题 · mid

  • A.词尾需要加结束符← 正确答案
  • B.通配后缀转前缀查← 正确答案
  • C.可支持中间通配
  • D.存储是词典的数倍← 正确答案

答案:ABD。旋转索引在词尾补 $ 标记结尾,能把后缀通配转成前缀查询,且每种旋转形式都要存储,空间约为原词典的 L 倍。它对中缀通配支持并不完美,需要分段拼接,不能选 C。

Q7. K-gram 得到候选集合之后,还必须做什么?

单选题 · hard

  • A.用原模式做最终匹配← 正确答案
  • B.重新构建一次索引
  • C.直接返回文档列表
  • D.无需任何附加处理

答案:A。克交集只是候选超集,可能混入 demon 这类片段相似但整体不匹配的词,必须用原始通配符模式再做一次正则验证,才能输出最终结果。

Q8. K-gram 索引相对暴力全词典扫描的优势有哪些?

多选题 · mid

  • A.避免逐词比较全部词典← 正确答案
  • B.常数级定位候选集合← 正确答案
  • C.能无损压缩全部词典
  • D.支持通配候选的生成← 正确答案

答案:ABD。K-gram 通过哈希直接定位克的候选集合,成本接近常数级,无需扫描整个词典,也能支持通配候选生成。但它不会压缩词典,空间反而更大,C 错。

Q9. 其他条件不变,K 值增大时会发生什么?

单选题 · hard

  • A.索引更大候选更精准← 正确答案
  • B.索引更小候选更精准
  • C.索引更大候选更粗糙
  • D.索引大小基本不变

答案:A。K 越大,每个克包含的字符越多,候选数量更少、更精准,但每个词产生的克数量增加,索引体积也随之变大。B、C、D 方向描述都出错。

Q10. 实际系统中优化 K-gram 查询的常用策略有哪些?

多选题 · hard

  • A.低文档频率优先求交← 正确答案
  • B.高频克结果做缓存← 正确答案
  • C.取消所有剪枝限制
  • D.阈值截断过多候选← 正确答案

答案:ABD。稀有克优先能快速缩小候选集,高频克缓存能省去重复查找,阈值截断可防止低区分度的克拖垮性能。取消剪枝只会让查询更慢,C 错。