上一篇文章 :【信息检索】Java简易搜索引擎原理及实现(二)新增停用词表 + 查询处理,我们在建立好的倒排索引的结构中剔除了停用词,同时引入了AND、OR、ANDNOT操作符,支持三种查询方式。
这篇文章中,我们将在倒排索引的基础上,改进我们的字典结构,使用B+树索引来加快检索速度;同时引入轮排的索引方法,以支持通配符的模糊查询方式。
目标:支持通配符查询
在原有字典的基础上,扩展索引结构,实现支持通配符查询。
本文采用的方式是建立轮排索引(Permuterm Index)和 B+树索引结构
使用B+树的原因
首先我们说一下为什么要使用B+树来作为基础索引结构呢?
在这篇文章之前,我们已经建立好了倒排索引的结构,但各个item项之间,我们是用LinkedList来把它们串连在一起的,对于一个搜索的词汇,直接遍历搜索List明显效率感人。因此,我们考虑使用数据库索引这样的结构来存储item项,即B+树,能有效减少搜索次数。
那么为什么选用B+树而不是B树呢?
关于B+树和B树的对比,我们先来看一张图:
可以看到,B+树的非叶节点没有指针指向实际的数据,非叶节点只做导航作用,而B树在非叶节点上也有指向实际数据的指针。
同时,B+树的叶节点之间相邻节点使用链表相连,便于实现区间查找和遍历。而B树则需要进行每一层的递归遍历,相邻的元素可能在内存中不相邻,所以缓存命中性没有B+树好。
通配符查询原理
比如查询语句 mon*:找出所有以mon开头的单词。如果采用B+树结构的词典,我们可以很容易的解决,只需要查询范围在mon ≤ w < moo的所有单词就ok了。
但是查询语句 *mon:找出所有以mon结尾的单词就比较困难了。其中一种办法就是我们增加一个额外的B+树来存储所有单词,以从后向前的顺序,然后在这个树上查询范围在nom ≤ w < non的所有单词。
可是如何处理通配符在单词中间的查询呢?
比如query是co*tion的话。我们当然可以分别在B+树查询到co*和*tion的所有单词然后合并这些单词,但是这样开销太大了。
解决办法就是:轮排索引(Permuterm Index),我们把query的通配符转换到结尾处。
设置一个标志$表示单词的结尾。
以hello举例,hello可以被转换成hello$, ello$h, llo$he, lo$hel, o$hell。$代表中hello的结束。现在,查询X等于查询X$,查询X等于查询X$,查询X等于查询X$,查询XY等于查询Y$X。对于hel*o来说,X等于hel,Y等于o。
既然我们已经把通配符都弄到了单词尾部,现在我们又可以通过B+树像以前那样查询了。
轮排索引的缺点在于,据统计,会使得词典的大小约变为原始的四倍。
因此,针对这个问题,又有k-gram索引结构,能解决词典扩大的问题。
k-gram:由k个字符组成的序列
2-gram:由2个字符组成的序列,又称二元组(bigram)
例如,对于文本 “April is the cruelest month”
它的2-gram(bigram)就是:
$a, ap, pr, ri, il, l$, $i, is, s$, $t, th, he, e$, $c, cr, ru, ue, el, le, es, st, t$, $m, mo, on, nt, h$
标志$表示单词的结尾
由bigram到词典项的倒排索引关系如图所示:
因此,查询mon*现在被转换为:$m AND mo AND on
具体实现思路
对于每个item项,将它的term值取出,用上面提到的轮排的方式,把该item项对应的所有字符串存为B+树的key,item项存为value,即原本的一个item项此时会对应有轮排出的多个key值。
举个例子来说,一个item项的term值是【互联网】,根据上述轮排的定义,它将对应于
互联网$、联网$互、网$互联
这三个字符串,将每个字符串和item当做一组key、value存入B+树结构中。
构建好B+树和轮排索引结构后,就是对搜索词的查询了。
根据上述在轮排原理处提到的多种不同的含通配符的搜索词格式,我们用不同的映射方式,将用户输入的搜索词映射为实际在B+树中查询的搜索词,然后直接到B+树中进行范围搜索即可,具体范围搜索的方法看下面的代码实现。
代码实现
每部分思路在代码中都有详细的注释,具体可参考代码理解。
B+树提供的功能主要就是数据的插入、精确查询、通配符查询。
用枚举类VagueMode来表示不同的通配符模式。
1.枚举类VagueMode
public enum VagueMode {
X, //X -> X$
X_, //X* -> X*$
_X, //*X -> X$*
_X_, //*X* -> X*
X_Y //X*Y -> Y$X*
}
2.B+树构建
import java.util.List;
public class BplusTree {
/** 根节点 */
protected Node root;
/** 阶数,M值 */
protected int order;
/** 叶子节点的链表头*/
protected Node head;
public BplusTree(int order){
if (order < 3) {
System.out.print("order must be greater than 2");
System.exit(0);
}
this.order = order;
root = new Node(true, true);
head = root;
}
public Node getHead() {
return head;
}
public void setHead(Node head) {
this.head = head;
}
public Node getRoot() {
return root;
}
public void setRoot(Node root) {
this.root = root;
}
public int getOrder() {
return order;
}
public void setOrder(int order) {
this.order = order;
}
public Item get(String key) {
return root.get(key);
}
public List<Item> rangeFind(String key, VagueMode mode) {
return root.rangeFind(key, mode);
}
public void insertOrUpdate(String key, Item obj) {
root.insertOrUpdate(key, obj, this);
}
}
3.Node节点
import java.text.Collator;
import java.util.*;
public class Node {
/** 是否为叶子节点 */
protected boolean isLeaf;
/** 是否为根节点*/
protected boolean isRoot;
/** 父节点 */
protected Node parent;
/** 叶节点的前节点*/
protected Node previous;
/** 叶节点的后节点*/
protected Node next;
/** 节点的关键字 */
protected List<Map.Entry<String, Item>> entries;
/** 子节点 */
protected List<Node> children;
static Comparator<Object> comparator = Collator.getInstance(Locale.CHINA);
public Node(boolean isLeaf) {
this.isLeaf = isLeaf;
entries = new ArrayList<>();
if (!isLeaf) {
children = new ArrayList<>();
}
}
public Node(boolean isLeaf, boolean isRoot) {
this(isLeaf);
this.isRoot = isRoot;
}
//X*格式的通配符查询 -> X*$
//key为已去除末尾的*的X
private List<Item> modeX_(String key) {
//如果是叶子节点
if (isLeaf) {
Node node = this;
int firstLargerPos = 0;
int flag = 0; //用于控制循环的标志位,确保循环最多执行2次
while (flag < 2) {
firstLargerPos = 0;
//找到第一个大于等于key的元素的下标
for (; firstLargerPos < node.entries.size(); firstLargerPos++) {
if (comparator.compare(node.entries.get(firstLargerPos).getKey(), key) >= 0) {
flag = 2;
break;
}
}
node = node.next;
flag++;
}
node = node.previous;
if (firstLargerPos < node.entries.size()) {
List<Item> list = new ArrayList<>();
while (node.entries.get(firstLargerPos).getKey().startsWith(key)
&& node.entries.get(firstLargerPos).getKey().endsWith("$")) {
list.add(node.entries.get(firstLargerPos).getValue());
firstLargerPos++;
if (firstLargerPos >= node.entries.size()) {
node = node.next;
firstLargerPos = 0;
}
}
return list;
}
//未找到所要查询的对象
return null;
//如果不是叶子节点
} else {
//如果key小于等于节点最左边的key,沿第一个子节点继续搜索
if (comparator.compare(key, entries.get(0).getKey()) <= 0) {
return children.get(0).modeX_(key);
//如果key大于节点最右边的key,沿最后一个子节点继续搜索
} else if (comparator.compare(key, entries.get(entries.size()-1).getKey()) >= 0) {
return children.get(children.size()-1).modeX_(key);
//否则沿比key大的前一个子节点继续搜索
} else {
for (int i = 0; i < entries.size(); i++) {
if (comparator.compare(entries.get(i).getKey(), key) <= 0
&& comparator.compare(entries.get(i+1).getKey(), key) > 0) {
return children.get(i).modeX_(key);
}
}
}
}
return null;
}
//*X格式的通配符查询 -> X$*
//key = X$
private List<Item> mode_X(String key) {
//如果是叶子节点
if (isLeaf) {
Node node = this;
int firstLargerPos = 0;
int flag = 0; //用于控制循环的标志位,确保循环最多执行2次
while (flag < 2) {
firstLargerPos = 0;
//找到第一个大于等于key的元素的下标
for (; firstLargerPos < node.entries.size(); firstLargerPos++) {
if (comparator.compare(node.entries.get(firstLargerPos).getKey(), key) >= 0) {
flag = 2;
break;
}
}
node = node.next;
flag++;
}
node = node.previous;
if (firstLargerPos < node.entries.size())<