最近,谷歌开源网站上线了代码搜索功能,这个功能多年前就曾上线,因为一些原因中途下线,当时参与该项目的实习生记录了部分开发工程。实际的代码搜索建立在谷歌世界级的文档索引和检索工具之上。本文还附带了一个实现,它可以很好地在一台计算机上索引和搜索大型代码库。

本文最初发布于 swtch.com,经原作者授权由 InfoQ 中文站翻译并分享。

2006 年夏天,我有幸成为谷歌的实习生。当时,谷歌有一个名为 gsearch 的内部工具,它的作用就好像是在谷歌源码树中的所有文件上运行 grep并打印结果。当然,那个实现相当慢,gsearch 实际上做的是与一堆服务器通信,这些服务器将源码树的不同部分保存在内存中:每台机器对内存执行一个 grep,然后 gsearch 合并结果并打印它们。Jeff Dean 是我的实习负责人,也是 gsearch 的作者之一,他建议构建一个 Web 界面,在全世界的公开源码上运行 gsearch,这将是一件很酷的事情。我觉得这听起来很有趣,所以那年夏天我就这么做了。由于我们最初的计划过于乐观,发布被推迟到 10 月,但在 2006 年 10 月 5 日,我们确实发布了(那时我已经回到学校,但仍然是兼职实习生)。

我使用 Ken Thompson 的 Plan 9 grep 构建了最早的演示程序,因为碰巧我的库中有它。本来的计划是切换到一个“真正的”正则表达式库,即PCRE,可能是在一个新编写的、经过代码审查的解析器之后。众所周知,PCRE 的解析器是安全漏洞的一个来源。唯一的问题是我当时发现没有一个流行的 regexp 实现使用真正的自动机——Perl 不是,Python 不是,PCRE 也不是。这让我很吃惊,甚至是 Plan 9 正则表达式库的作者 Rob Pike 也很吃惊。(Ken 还没有到谷歌,没法咨询他)。我从一些书、大学的理论课、Rob 和 Ken 的代码中学到了正则表达式等内容。我从来没想过不使用保证线性时间的算法。但事实证明,Rob 的代码使用了一种只有少数人知道的算法,而其他人几年前就已经忘记了,我们发布时使用了 Plan 9 grep 的代码。几年后,我用 RE2 取代了它。

代码搜索是谷歌第一个也是唯一接受正则表达式查询的搜索引擎。遗憾的是,许多程序员都不会编写正则表达式,更不用说编写正确的正则表达式了。当我们开始开发代码搜索时,谷歌搜索“正则表达式搜索引擎”获得的网站,你输入“电话号码”,它们会返回“(\d{3}) \d{3}-\d{4}”

2010 年 3 月,谷歌开源了我为代码搜索编写的正则表达式引擎 RE2。代码搜索和 RE2 是教人们如何安全地进行正则表达式搜索的好工具。事实上,Tom Christiansen 最近告诉我,即使是 Perl 社区的人也在使用它(perl -Mre::engine::RE2)在 Web 上运行 regexp 搜索引擎(真正的那种),而不会受到平常的拒绝服务攻击。

2011 年 10 月,谷歌宣布将关闭代码搜索,这是其努力重新专注于高影响力产品的一部分,现在代码搜索已经不在线了。为了纪念这一刻,我认为应该写一些关于代码搜索如何工作的内容。实际的代码搜索建立在谷歌世界级的文档索引和检索工具之上,本文还附带了一个实现,它可以很好地在一台计算机上索引和搜索大型代码库。

索引词搜索

在我们开始介绍正则表达式搜索之前,先了解一下如何实现基于单词的全文搜索。其关键数据结构称为倒排表或反向索引,对于每个可能的搜索词,它会列出包含该词的文档。

例如,考虑下面这三个非常简短的文档:

(1) Google Code Search

(2) Google Code Project Hosting

(3) Google Web Search

这三个文档的反向索引如下:

Code: {1, 2}
Google: {1, 2, 3}
Hosting: {2}
Project: {2}
Search: {1, 3}
Web: {3}

要查找包含 Code 和 Search 的所有文档,需要加载 Code {1, 2}的索引项,并将其与 Search {1, 3}的索引项列表取交集,生成列表{1}。要查找包含 Code 或 Search(或两者都包含)的文档,需要将列表合并而不是取交集。由于列表是有序的,所以这些操作是在线性时间内完成的。为了支持短语,全文搜索实现通常会记录一个单词在倒排表中的每次出现,以及它的位置:

Code: {(1, 2), (2, 2)}
Google: {(1, 1), (2, 1), (3, 1)}
Hosting: {(2, 4)}
Project: {(2, 3)}
Search: {(1, 3), (3, 4)}
Web: {(3, 2)}

要查找短语“Code Search”,一个实现会首先加载 Code 的列表,然后扫描 Search 的列表,查找那些比 Code 列表中的条目右移一个单词的条目。Code 列表中的 (1, 2) 项Search 列表中的 (1, 3) 项来自相同的文档 (1) ,并且具有连续的单词编号(2 和 3),因此文档 1 包含短语“Code Search”。支持短语的另一种方法是将它们视为 AND 查询,识别出一组候选文档,然后在从磁盘加载文档主体后过滤掉不匹配的文档。实际上,一些由普通词汇组成的短语(如“to be or not to be”)会让这种方式失去吸引力。在索引项中存储位置信息会使索引变大,但可以避免从磁盘加载文档,除非能确保匹配。

索引正则表达式搜索

世界上有太多的源代码,代码搜索无法将它们全部保存在内存中,并对每个查询都运行正则表达式搜索,无论正则表达式引擎有多快。相反,代码搜索使用反向索引来标识要搜索、评分、排序并最终显示为结果的候选文档。

正则表达式匹配并不总是能很好地对齐单词边界,因此,反向索引不能基于像前面的例子中那样的单词。相反,我们可以使用一个旧的信息检索技巧,建立一个 n-grams 索引(长度为 n 的子字符串)。在实践中,不同的 2-grams 太少,不同的4-grams 又太多了,所以 3-grams(trigrams)不错。

还是上一节的例子,文档集如下:

(1) Google Code Search

(2) Google Code Project Hosting

(3) Google Web Search

其 trigram 索引如下:

_Co: {1, 2}     Sea: {1, 3}     e_W: {3}        ogl: {1, 2, 3}
_Ho: {2}        Web: {3}        ear: {1, 3}     oje: {2}
_Pr: {2}        arc: {1, 3}     eb_: {3}        oog: {1, 2, 3}
_Se: {1, 3}     b_S: {3}        ect: {2}        ost: {2}
_We: {3}        ct_: {2}        gle: {1, 2, 3}  rch: {1, 3}
Cod: {1, 2}     de_: {1, 2}     ing: {2}        roj: {2}
Goo: {1, 2, 3}  e_C: {1, 2}     jec: {2}        sti: {2}
Hos: {2}        e_P: {2}        le_: {1, 2, 3}  t_H: {2}
Pro: {2}        e_S: {1}        ode: {1, 1}     tin: {2}

_ 字符在这里用作一个空格的可见表示,给定一个正则表达式,比如 /Google.*Search/,我们可以构建一个由 AND 和 OR 组成的查询,任何与正则表达式匹配的文本中必须出现该查询给出的trigrams。在本例中,这个查询是:

Goo AND oog AND ogl AND gle AND Sea AND ear AND arc AND rch

我们可以对 trigrams 索引运行此查询,识别出一组候选文档,然后仅对这些文档运行完整的正则表达式搜索。

查询转换并不仅仅是提取文本字符串并将其转换为 AND 表达式,尽管这是其一部分工作。使用|操作符的正则表达式将产生 OR 条件的查询,而括号括起来的子表达式将使把字符串转换为 AND 表达式的过程变得更复杂。

完整的规则是对于每个正则表达式 r 计算五个结果:空字符串是否是匹配的字符串,精确匹配的字符串集或表明精确匹配未知,所有匹配字符串的前缀集,所有匹配字符串的后缀集,和上面一样的匹配查询,经常描述字符串的中间。由正则表达式的含义得出的规则如下:

谷歌开源网站代码搜索的工作原理_java谷歌开源网站代码搜索的工作原理_java_02谷歌开源网站代码搜索的工作原理_java_03

上面描述的规则是正确的,但是不会产生非常有用的匹配查询,而且根据正则表达式,各种字符串集可能会呈指数增长。为了保持计算的信息易于管理,在每个步骤中,我们可以做一些简化。首先,我们需要一个函数来计算 trigrams

应用于单个字符串的 trigrams 函数可以是 ANY(如果字符串少于三个字符),也可以是字符串中所有 trigrams 的 AND。应用于一组字符串的 trigrams 函数是应用于每个字符串的 trigrams函数的 OR。

(单字符串)
trigrams(ab) = ANY
trigrams(abc) = abc
trigrams(abcd) = abc AND bcd
trigrams(wxyz) = wxy AND xyz
(字符串集)
trigrams({ab}) = trigrams(ab) = ANY
trigrams({abcd}) = trigrams(abcd) = abc AND bcd
trigrams({ab, abcd}) = trigrams(ab) OR trigrams(abcd) = ANY OR (abc AND bcd) = ANY
trigrams({abcd, wxyz}) = trigrams(abcd) OR trigrams(wxyz) = (abc AND bcd) OR (wxy AND xyz)

使用 trigrams 函数,我们可以定义在分析过程的任意步骤中应用于任何正则表达式 e 的转换。这些转换可以保持计算信息的有效性,并可在需要时应用,保证结果容易处理:

(信息保留)

At any time, set match(e) = match(e) AND trigrams(prefix(e)).

At any time, set match(e) = match(e) AND trigrams(suffix(e)).

At any time, set match(e) = match(e) AND trigrams(exact(e)).

(信息丢弃)

如果 prefix(e) 包含 s 和 t,其中 s 是 t 的前缀,则丢弃 t。如果 suffix(e) 包含 s 和 t,其中 s 是 t 的后缀,则丢弃 t。

如果 prefix(e) 太大,将 prefix(e) 中最长的字符串中的最后一个字符去掉。

如果 suffix(e) 太大,则将 suffix(e) 中最长的字符串中的第一个字符去掉。

如果 exact(e) 太大,则 set exact(e) = unknown。

应用这些转换的最佳方法是在使用信息丢弃转换之前使用信息保留转换。实际上,这些转换将即将从前缀、后缀和精确集中丢弃的信息移动到匹配查询本身。另外,可以从连接分析中获得更多的信息:如果 e1e2 不准确,那么 match(e1e2) 也可能需要 trigrams(suffix(e1) × prefix(e2))

除了这些转换之外,它还有助于在构造匹配查询时对其应用基本的布尔简化:abc OR (abc AND def) 比 abc 开销大,但并不比 abc 更准确。

实   现

为了演示这些想法,我在 code.google.com/p/codesearch 上发布了一个用 Go 编写的基本实现。如果你已安装 Go,则可以运行以下命令:

goinstall code.google.com/p/codesearch/cmd/{cindex,csearch}

安装名为 cindex 和 csearch 的二进制文件。如果还没有安装,可以下载用于 FreeBSD、Linux、OpenBSD、OS X 和 Windows 的 二进制程序包。第一步是运行 cindex,参数是包括在索引中的目录或文件列表,:

cindex /usr/include $HOME/src

默认情况下,cindex 会添加到现有的索引中,如果已经存在一个,那么上一个命令就相当于这两个命令:

cindex /usr/include
cindex $HOME/src

如果没有参数,cindex 会刷新现有的索引,之后,运行前面的命令将重新扫描 /usr/include$HOME/src 并重写索引。运行 cindex -help 可以获得更多细节。

cindex

索引器假设文件是用 UTF-8 编码的。它会拒绝那些不太可能有用的文件,比如那些包含无效 UTF-8 的文件,或者那些有很长的行,或者那些有大量不同 trigrams 的文件。索引文件包含一个路径列表(如上所述,用于重建索引)、一个索引文件列表,以及与本文开头相同的倒排表,每个 trigram一个。在实践中,这个索引往往是被索引文件大小的 20% 左右。例如,索引 Linux 3.1.3 内核源代码(总共 420 MB)将创建一个 77 MB 的索引。当然,索引是组织好的,因此,对于任何特定的搜索,只需要读取一小部分。

一旦索引写入完成,就可以运行 csearch 进行搜索:

csearch [-c] [-f fileregexp] [-h] [-i] [-l] [-n] regexp

Regexp 语法是 RE2 的,也就是说基本上是 Perl 的,但是没有反向引用。(RE2 支持捕获括号;请参见 code.google.com/p/re2 底部的脚注,以了解区别。)布尔命令行标识与 grep 的类似,不同之处在于,选项不能组合在一起:csearch -i -n不能写成 csearch -in。新的 -f 标识使 csearch只考虑路径匹配 fileregexp 的文件。

$ csearch -f /usr/include DATAKIT
/usr/include/bsm/audit_domain.h:#define    BSM_PF_DATAKIT        9
/usr/include/gssapi/gssapi.h:#define GSS_C_AF_DATAKIT    9
/usr/include/sys/socket.h:#define    AF_DATAKIT    9        /* datakit protocols */
/usr/include/sys/socket.h:#define    PF_DATAKIT    AF_DATAKIT
$

-verbose 标识使 csearch 报告关于搜索的统计信息。-brute 标识将绕过 trigram 索引,搜索索引中列出的每个文件,而不是使用精确的 trigram 查询。在 Datakit 查询中,trigram 查询将搜索范围缩小到三个文件,这些文件都是匹配的。

$ time csearch -verbose -f /usr/include DATAKIT
2011/12/10 00:23:24 query: "AKI" "ATA" "DAT" "KIT" "TAK"
2011/12/10 00:23:24 post query identified 3 possible files
/usr/include/bsm/audit_domain.h:#define    BSM_PF_DATAKIT        9
/usr/include/gssapi/gssapi.h:#define GSS_C_AF_DATAKIT    9
/usr/include/sys/socket.h:#define    AF_DATAKIT    9        /* datakit protocols */
/usr/include/sys/socket.h:#define    PF_DATAKIT    AF_DATAKIT
0.00u 0.00s 0.00r
$

相反,如果没有索引,我们就得搜索 2739 个文件:

$ time csearch -brute -verbose -f /usr/include DATAKIT
2011/12/10 00:25:02 post query identified 2739 possible files
/usr/include/bsm/audit_domain.h:#define    BSM_PF_DATAKIT        9
/usr/include/gssapi/gssapi.h:#define GSS_C_AF_DATAKIT    9
/usr/include/sys/socket.h:#define    AF_DATAKIT    9        /* datakit protocols */
/usr/include/sys/socket.h:#define    PF_DATAKIT    AF_DATAKIT
0.08u 0.03s 0.11r  # brute force
$

(我正在 OS X Lion 笔记本电脑上使用/usr/include。你在自己的系统上得到的结果可能会稍有不同。)作为一个更大的示例,我们可以在 Linux 3.1.3 内核中搜索 hello worldtrigram索引将搜索范围从 36972 个文件缩小到 25 个文件,并将搜索所需的时间减少了约 100 倍。

$ time csearch -verbose -c 'hello world'
2011/12/10 00:31:16 query: " wo" "ell" "hel" "llo" "lo " "o w" "orl" "rld" "wor"
2011/12/10 00:31:16 post query identified 25 possible files
/Users/rsc/pub/linux-3.1.3/Documentation/filesystems/ramfs-rootfs-initramfs.txt: 2
/Users/rsc/pub/linux-3.1.3/Documentation/s390/Debugging390.txt: 3
/Users/rsc/pub/linux-3.1.3/arch/blackfin/kernel/kgdb_test.c: 1
/Users/rsc/pub/linux-3.1.3/arch/frv/kernel/gdb-stub.c: 1
/Users/rsc/pub/linux-3.1.3/arch/mn10300/kernel/gdb-stub.c: 1
/Users/rsc/pub/linux-3.1.3/drivers/media/video/msp3400-driver.c: 1
0.01u 0.00s 0.01r
$
$ time csearch -brute -verbose -h 'hello world'
2011/12/10 00:31:38 query: " wo" "ell" "hel" "llo" "lo " "o w" "orl" "rld" "wor"
2011/12/10 00:31:38 post query identified 36972 possible files
/Users/rsc/pub/linux-3.1.3/Documentation/filesystems/ramfs-rootfs-initramfs.txt: 2
/Users/rsc/pub/linux-3.1.3/Documentation/s390/Debugging390.txt: 3
/Users/rsc/pub/linux-3.1.3/arch/blackfin/kernel/kgdb_test.c: 1
/Users/rsc/pub/linux-3.1.3/arch/frv/kernel/gdb-stub.c: 1
/Users/rsc/pub/linux-3.1.3/arch/mn10300/kernel/gdb-stub.c: 1
/Users/rsc/pub/linux-3.1.3/drivers/media/video/msp3400-driver.c: 1
1.26u 0.42s 1.96r  # brute force
$

对于不区分大小写的搜索,不太精确的查询意味着速度提升没那么大,但它仍然比使用蛮力好一个数量级:

$ time csearch -verbose -i -c 'hello world'
2011/12/10 00:42:22 query: ("HEL"|"HEl"|"HeL"|"Hel"|"hEL"|"hEl"|"heL"|"hel")
 ("ELL"|"ELl"|"ElL"|"Ell"|"eLL"|"eLl"|"elL"|"ell")
 ("LLO"|"LLo"|"LlO"|"Llo"|"lLO"|"lLo"|"llO"|"llo")
 ("LO "|"Lo "|"lO "|"lo ") ("O W"|"O w"|"o W"|"o w") (" WO"|" Wo"|" wO"|" wo")
 ("WOR"|"WOr"|"WoR"|"Wor"|"wOR"|"wOr"|"woR"|"wor")
 ("ORL"|"ORl"|"OrL"|"Orl"|"oRL"|"oRl"|"orL"|"orl")
 ("RLD"|"RLd"|"RlD"|"Rld"|"rLD"|"rLd"|"rlD"|"rld")
2011/12/10 00:42:22 post query identified 599 possible files
/Users/rsc/pub/linux-3.1.3/Documentation/filesystems/ramfs-rootfs-initramfs.txt: 3
/Users/rsc/pub/linux-3.1.3/Documentation/java.txt: 1
/Users/rsc/pub/linux-3.1.3/Documentation/s390/Debugging390.txt: 3
/Users/rsc/pub/linux-3.1.3/arch/blackfin/kernel/kgdb_test.c: 1
/Users/rsc/pub/linux-3.1.3/arch/frv/kernel/gdb-stub.c: 1
/Users/rsc/pub/linux-3.1.3/arch/mn10300/kernel/gdb-stub.c: 1
/Users/rsc/pub/linux-3.1.3/arch/powerpc/platforms/powermac/udbg_scc.c: 1
/Users/rsc/pub/linux-3.1.3/drivers/media/video/msp3400-driver.c: 1
/Users/rsc/pub/linux-3.1.3/drivers/net/sfc/selftest.c: 1
/Users/rsc/pub/linux-3.1.3/samples/kdb/kdb_hello.c: 2
0.07u 0.01s 0.08r
$
$ time csearch -brute -verbose -i -c 'hello world'
2011/12/10 00:42:33 post query identified 36972 possible files
/Users/rsc/pub/linux-3.1.3/Documentation/filesystems/ramfs-rootfs-initramfs.txt: 3
/Users/rsc/pub/linux-3.1.3/Documentation/java.txt: 1
/Users/rsc/pub/linux-3.1.3/Documentation/s390/Debugging390.txt: 3
/Users/rsc/pub/linux-3.1.3/arch/blackfin/kernel/kgdb_test.c: 1
/Users/rsc/pub/linux-3.1.3/arch/frv/kernel/gdb-stub.c: 1
/Users/rsc/pub/linux-3.1.3/arch/mn10300/kernel/gdb-stub.c: 1
/Users/rsc/pub/linux-3.1.3/arch/powerpc/platforms/powermac/udbg_scc.c: 1
/Users/rsc/pub/linux-3.1.3/drivers/media/video/msp3400-driver.c: 1
/Users/rsc/pub/linux-3.1.3/drivers/net/sfc/selftest.c: 1
/Users/rsc/pub/linux-3.1.3/samples/kdb/kdb_hello.c: 2
1.24u 0.34s 1.59r  # brute force
$

为了最小化 I/O 并利用操作系统缓存,csearch 使用 mmap 将索引映射到内存中,并直接从操作系统的文件缓存中读取索引。这使得 csearch 可以在不使用服务器进程的情况下在重复运行时的运行速度加快。这些工具的源代码在 code.google.com/p/codesearch。本文中用于说明这些技术的文件包括 index/regexp.go(从正则表达式到查询)、index/read.go(读索引)、 index/write.go(写索引)。

还有些代码在 regexp/match.go 中。这是一个定制的基于 DFA 的匹配引擎,专门针对 csearch 工具进行了调优。这个引擎唯一的好处就是实现了 grep,但是它的速度非常快。因为它可以调用标准的 Go 程序包 来解析正则表达式并将其简化为基本操作,所以新的匹配器的代码不到 500 行。

历史

n-gram 及其在模式匹配中的应用都不是新东西。香农在他 1948 年的开创性论文《通信的数学理论》中使用了 n-gram 来分析英语文本的信息内容。即使在那时,n-gram 也已经过时了。香农引用了普拉特 1939 年出版的 Secret and Urgent 一书,有人认为,香农在战时的密码工作中使用了 n-gram 分析。

Zobel、Moffat 和 Sacks-Davis 在 1993 年发表的论文“Searching Large Lexicons for Partially Specified Terms using Compressed Inverted Files”中描述了如何使用一组单词(一个词典)中的 n-gram 反向索引来将 fro*n 这样的模式映射到 frozen 或 frogspawn 这样的匹配项上。Witten、Moffat 和 Bell 1994 年的经典著作 Managing Gigabytes 总结了同样的方法。Zobel 等人在论文中提到,该技术可以应用于比*通配符更丰富的模式语言,但他们只演示了一个简单的字符类:

注意,n-gram 可用于支持其他类型的模式匹配。例如,如果 n = 3 且模式包含诸如 ab[cd]e 这样的序列,其中方括号表示 b 和 e 之间的字符必须是 c 或 d,那么可以通过查找包含 abc 和 bce 或 abd 和 bde 的字符串来找出匹配项。

Zobel 的论文和我们的实现之间的主要区别是,GB 不再是大量的数据,因此,我们不是将n-gram索引和模式匹配应用到单词列表,我们有足够的计算资源将其应用于整个文档集合。上面给出的 trigram 查询生成规则生成的查询:

(abc AND bce) OR (abd AND bde)

对于正则表达式 ab[cd]e。如果在实现时更积极地应用简化转换,它使用的内存会更小,但最终会得出:

(abc OR abd) AND (bce OR bde)

第二个查询同样有效,但是不那么精确,这说明了内存使用和精度之间的折中。

小结

使用每个文档的 trigrams 索引,可以快速地对大量小文档进行正则表达式匹配。将 trigrams 用于这个目的并不新鲜,但也不是众所周知。

尽管它们的 语法非常复杂,但从数学意义上讲,正则表达式总是可以简化为上面提到的几种情况(空字符串、单个字符、重复、和、或)。这种根本的简单性使得实现高效的搜索算法成为可能,就像本系列 前三篇文章 中的算法一样。上面的分析将正则表达式转换为 trigram 查询,它是索引匹配器的核心,其实现同样得益于这种简单性。

如果你错过了谷歌代码搜索,并希望在本地代码上运行快速索引正则表达式搜索,请试一下这个 独立程序。