關於Lucene的詞典FST深入剖析


搜索引擎為什么能查詢速度那么快?

核心是在於如何快速的依據查詢詞快速的查找到所有的相關文檔,這也是倒排索引(Inverted Index)的核心思想。那么如何設計一個快速的(常量,或者1)定位詞典的數據結構就顯得尤其重要。簡單來說,我們可以采用HashMap, TRIE, Binary Search Tree, Tenary Search Tree等各種數據結構來實現。

那么開源的搜索引擎包Lucene是怎么來設計的呢?Lucene采用了一種稱為FST(Finite State Transducer)的結構來構建詞典,這個結構保證了時間和空間復雜度的均衡,是Lucene的核心功能之一。

關於FST(Finite State Transducer)

FST類似一種TRIE樹。

使用FSM(Finite State Machines)作為數據結構

FSM(Finite State Machines)有限狀態機: 表示有限個狀態(State)集合以及這些狀態之間轉移和動作的數學模型。其中一個狀態被標記為開始狀態,0個或更多的狀態被標記為final狀態
一個FSM同一時間只處於1個狀態。FSM很通用,可以用來表示多種處理過程,下面的FSM描述了《小貓咪的一天》。

fsm 小貓咪的一天

其中“睡覺”或者“吃飯”代表的是狀態,而“提供食物”或者“東西移動”則代表了轉移。圖中這個FSM是對小貓活動的一個抽象(這里並沒有刻意寫開始狀態或者final狀態),小貓咪不能同時的即處於“玩耍”又處於“睡覺”狀態,並且從一個狀態到下一個狀態的轉換只有一個輸入。“睡覺”狀態並不知道是從什么狀態轉換過來的,可能是“玩耍”,也可能是”貓砂窩”。

如果《小貓咪的一天》這個FSM接收以下的輸入:

  • 提供食物
  • 有大聲音
  • 安靜
  • 消化食物

那么我們會明確的知道,小貓咪會這樣依次變化狀態: 睡覺->吃飯->躲藏->吃飯->貓砂窩.

以上只是一個現實中的例子,下面我們來看如何實現一個Ordered Sets,和Map結構。

Ordered Sets

Ordered Sets是一個有序集合。通常一個有序集合可以用二叉樹、B樹實現。無序的集合使用hash table來實現. 這里,我們用一個確定無環有限狀態接收機(Deterministric acyclic finite state acceptor, FSA)來實現。

FSA是一個FSM(有限狀態機)的一種,特性如下:

  • 確定:意味着指定任何一個狀態,只可能最多有一個轉移可以訪問到。
  • 無環: 不可能重復遍歷同一個狀態
  • 接收機:有限狀態機只“接受”特定的輸入序列,並終止於final狀態。

下面來看,我們如何來表示只有一個key:”jul“ 的集合。FSA是這樣的:

fsm

當查詢這個FSA是否包含“jul”的時候,按字符依序輸入。

  • 輸入j,FSA從0->1
  • 輸入u, FSA從1->2
  • 輸入l,FSA從2->3

這個時候,FSA處於final狀態3,所以“jul”是在這個集合的。

設想一下如果輸入“jun”,在狀態2的時候無法移動了,就知道不在這個集合里了。
設想如何輸入“ju”, 在狀態2的時候,已經沒有輸入了。而狀態2並不是final狀態,所以也不在這個集合里。
值得指出的是,查找這個key是否在集合內的時間復雜度,取決於key的長度,而不是集合的大小。

現在往FSA里再加一個key. FSA此時包含keys:”jul”和“mar”。

fsm

start狀態0此時有了2個轉移:jm。因此,輸入key:”mar”,首先會跟隨m來轉移。 final狀態是“jul”和“mar”共享的。這使得我們能用更少的空間來表示更多的信息

當我們在這個FSA里加入“jun”,那么它和“jul”有共同的前綴“ju”:

fsm

這里變化很小,沒有增加新的狀態,只是多了一個轉移而已。

下面來看一下由“october”,“november”,”december”構成的FSA.

fsm

它們有共同的后綴“ber”,所以在FSA只出現了1次。 其中2個有共同的后綴”ember”,也只出現了1次。

那么我們如何來遍歷一個FSA表示的所有key呢,我們以前面的”jul”,“jun”,”mar”為例:

fsm

遍歷算法是這樣的:

  • 初始狀態0, key=””
  • ->1, key=”j”
  • ->2, key=”ju”
  • ->3, key=”jul”, 找到jul
  • 2<-, key=”ju”
  • ->3, key=”jun”, 找到jun
  • 2<-, key=”ju”
  • 1<-, key=”j”
  • 0<-, key=””
  • ->4, key=”m”
  • ->5, key=”ma”,
  • ->3, key=”mar”,找到mar

這個算法時間復雜度O(n),n是集合里所有的key的大小, 空間復雜度O(k),k是結合內最長的key字段length。

Ordered maps

Ordered maps就像一個普通的map,只不過它的key是有序的。我們來看一下如何使用確定無環狀態轉換器(Deterministic acyclic finite state transducer, FST)來實現它。

FST是也一個有限狀態機(FSM),具有這樣的特性:

  • 確定:意味着指定任何一個狀態,只可能最多有一個轉移可以遍歷到。
  • 無環: 不可能重復遍歷同一個狀態
  • transducer:接收特定的序列,終止於final狀態,同時會輸出一個值

FST和FSA很像,給定一個key除了能回答是否存在,還能輸出一個關聯的值

下面來看這樣的一個輸入:“jul:7”, 7是jul關聯的值,就像是一個map的entry.

fst

這和對應的有序集合基本一樣,除了第一個0->1的轉換j關聯了一個值7. 其他的轉換u和l,默認關聯的值是0,這里不予展現。

那么當我們查找key:”jul”的時候,大概流程如下:

  • 初始狀態0
  • 輸入j, FST從0->1, value=7
  • 輸入u, FST從1->2, value=7+0
  • 輸入l,FST從2->3, value=7+0+0

此時,FST處於final狀態3,所以存在jul,並且給出output是7.

我們再看一下,加入mar:3之后,FST變成什么樣:

fst

同樣的很簡單,需要注意的是mar自帶的值3放在了第1個轉移上。這只是為了算法更簡單而已,事實上,可以放在其他轉移上。

如果共享前綴,FST會發生什么呢?這里我們繼續加入jun:6。

fst

和sets一樣,jun和jul共享狀態3, 但是有一些變化。

  • 0->1轉移,輸出從7變成了6
  • 2->3轉移,輸入l,輸出值變成了1。

這個輸出變化是很重要的,因為他改變了查找jul輸出值的過程。

  • 初始狀態0
  • 輸入j, FST從0->1, value=6
  • 輸入u, FST從1->2, value=6+0
  • 輸入l,FST從2->3, value=6+0+1

最終的值仍舊是7,但是走的路徑卻是不一樣的。
那查找jun是不是也是正確的呢?

  • 初始狀態0
  • 輸入j, FST從0 -> 1, value=6
  • 輸入u,FST從1 -> 2, value=6+0
  • 輸入n,FST從2 -> 3, value=6+0+0

從上可知,jun的查詢也是正確的。FST保證了不同的轉移有唯一的值,但同時也復用了大部分的數據結構。

實現共享狀態的關鍵點是:每一個key,都在FST中對應一個唯一的路徑。因此,對於任何一個特定的key,總會有一些value的轉移組合使得路徑是唯一的。我們需要做的就是如何來在轉移中分配這些組合。

key輸出的共享機制同樣適用於共同前綴和共同后綴。比如我們有tuesday:3和thursday:5這樣的FST:

fst

2個key有共同的前綴t,共同后綴sday。關聯的2個value同樣有共同的前綴。3可以寫做3+0,而5可以寫作:3+2。 這樣很好的讓實現了關聯value的共享。

上面的這個例子,其實有點簡單化,並且局限。假如這些關聯的value並不是int呢? 實際上,FST對於關聯value(outputs)的類型是要求必須有以下操作(method)的。

  • 加(Addition)
  • 減 (Subtraction)
  • 取前綴 (對於整數來說,就是min)

FST的構建

前面,一直沒有提到如何構建FST。構建相對於遍歷來說,還是有些復雜的。
為了簡單化,我們假設set或者map里的數據是按字典序加入的。這個假設是很沉重的限制,不過我們會講如何來緩解它。

為了構建FSM,我們先來看看TRIE樹是如何構建的。

TRIE樹的構建

TRIE可以看做是一個FSA,唯一的一個不同是TRIE只共享前綴,而FSA不僅共享前綴還共享后綴。

假設我們有一個這樣的Set: mon,tues,thurs。FSA是這樣的:

fst

相應的TRIE則是這樣的,只共享了前綴。

fst trie

TRIE有重復的3個final狀態3,8,11. 而8,11都是s轉移,是可以合並的。

構建一個TRIE樹是相當簡單的。插入1個key,只需要做簡單的查找就可以了。如果輸入先結束,那么當前狀態設置為final;如果無法轉移了,那么就直接創建新的轉移和狀態。不要忘了最后一個創建的狀態設置為final就可以了。

FST的構建

構建FST在很大程度上和構建FSA是一樣的,主要的不同點是,怎么樣在轉移上放置和共享outputs

仍舊使用前面提到的例子,mon,tues和thurs,並給他們關聯相應的星期數值2,3和5.

從第1個key, mon:2開始:

fst mon

這里虛線代表,在后續的insert過程中,FST可能有變化。

需要關注的是,這里只是把2放在了第1個轉移上。技術上說,下面這樣分配也是正確的。

fst mon alt

只不過,把output放在靠近start狀態的算法更容易寫而已。

下面繼續把thurs:5插入:

fst mon thurs

就像FSA的insert一樣,插入thurs之后,我們可以知道FST的mon部分(藍色)就不會再變了。

由於mon和thurs沒有共同的前綴,只是簡單的2個map中的key. 所以他們的output value可以直接放置在start狀態的第1個轉移上。

下面,繼續插入tues:3,

fst

這引起了新的變化。有一部分被凍住了,並且知道以后不會再修改了。output value也出現了重新的分配。因為tues的output是3,並且tues和thurs有共同的前綴t, 所以5和3的prefix操作得出的結果就是3. 狀態0->狀態4的value被分配為3,狀態4->狀態5設置為2。

我們再插入更多的key, 這次插入tye:99看發生什么情況:

fst

插入tye,導致”es”部分被凍住,同時由於共享前綴t, 狀態4->狀態9的輸出是99-3=96。

最后一步,結束了,再執行一次凍住操作。

最終的FST長這樣:

fst

Lucene FST

上一部分,對於FST的概念以及構建進行了詳細的介紹。本部分將對Lucene FST的實現以及具體進行詳細的分析。
Lucene關於FST相關的代碼在package:org.apache.lucene.util.fst

org.apache.lucene.util.fst.Builder看起,這個是構建FST的Builder:

fst builder lucene

Builder通過泛型T,從而可以構建包含不同類型的FST。我們重點關注屬性。

從其中插入數據add()方法看起:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
/** Add the next input/output pair. The provided input
* must be sorted after the previous one according to
* {@link IntsRef#compareTo}. It's also OK to add the same
* input twice in a row with different outputs, as long
* as {@link Outputs} implements the {@link Outputs#merge}
* method. Note that input is fully consumed after this
* method is returned (so caller is free to reuse), but
* output is not. So if your outputs are changeable (eg
* {@link ByteSequenceOutputs} or {@link
* IntSequenceOutputs}) then you cannot reuse across
* calls. */
public void add(IntsRef input, T output) throws IOException {

...
// prefixLenPlus1是計算出input和lastInput具有公共前綴的位置
final int prefixLenPlus1 = pos1+1;

// 1.新插入的節點放到frontier數組,UnCompileNode表明是新插入的,以后還可能會變化,還未放入FST內。
if (frontier.length < input.length+1) {
final UnCompiledNode<T>[] next = ArrayUtil.grow(frontier, input.length+1);
for(int idx=frontier.length;idx<next.length;idx++) {
next[idx] = new UnCompiledNode<>(this, idx);
}
frontier = next;
}

// minimize/compile states from previous input's
// orphan'd suffix

// 2.從prefixLenPlus1, 進行freeze冰凍操作, 添加並構建最小FST
freezeTail(prefixLenPlus1);

// init tail states for current input
// 3.將當前input剩下的部分插入,構建arc轉移(前綴是共用的,不用添加新的狀態)。
for(int idx=prefixLenPlus1;idx<=input.length;idx++) {
frontier[idx-1].addArc(input.ints[input.offset + idx - 1],
frontier[idx]);
frontier[idx].inputCount++;
}

final UnCompiledNode<T> lastNode = frontier[input.length];
if (lastInput.length() != input.length || prefixLenPlus1 != input.length + 1) {
lastNode.isFinal = true;
lastNode.output = NO_OUTPUT;
}

// push conflicting outputs forward, only as far as
// needed
// 4.如果有沖突的話,重新分配output值
for(int idx=1;idx<prefixLenPlus1;idx++) {
final UnCompiledNode<T> node = frontier[idx];
final UnCompiledNode<T> parentNode = frontier[idx-1];

final T lastOutput = parentNode.getLastOutput(input.ints[input.offset + idx - 1]);
assert validOutput(lastOutput);

final T commonOutputPrefix;
final T wordSuffix;

if (lastOutput != NO_OUTPUT) {
// 使用common方法,計算output的共同前綴
commonOutputPrefix = fst.outputs.common(output, lastOutput);
assert validOutput(commonOutputPrefix);
// 使用subtract方法,計算重新分配的output
wordSuffix = fst.outputs.subtract(lastOutput, commonOutputPrefix);
assert validOutput(wordSuffix);
parentNode.setLastOutput(input.ints[input.offset + idx - 1], commonOutputPrefix);
node.prependOutput(wordSuffix);
} else {
commonOutputPrefix = wordSuffix = NO_OUTPUT;
}
output = fst.outputs.subtract(output, commonOutputPrefix);
assert validOutput(output);
}

...
}

通過注釋,我們看到input是經過排序的,也就是ordered。否則生成的就不是最小的FST。另外如果NO_OUTPUT就退化為FSA了,不用執行第4步重新分配output了。

其中freezeTail 方法就是將不再變化的部分進行冰凍,又叫compile,把UnCompileNode,給構建進FST里。進入到FST是先進行compileNode, 然后addNode進去的。

總結以下,加入節點過程:

  • 1)新插入input放入frontier,這里還沒有加入FST
  • 2)依據當前input, 對上次插入數據進行freezeTail操作, 放入FST內
  • 3)構建input的轉移(Arc)關系
  • 4)解決Output沖突,重新分配output,保證路徑統一(NO_OUTPUT,不執行)

最后在finish方法里,執行freezeTail(0), 把所有的input構建進FST內。

另外,值得注意的是Lucene里定義的Outputs類型:

fst

其中3個method是Outputs接口定義的,有11個不同類型的實現:

  • T add(T prefix, T output); 加
  • T subtract(T output, T inc); 減
  • T common(T output1, T output2) 前綴

完全滿足我們上個部分的限制,可見就是基於之前算法的一個完整的實現。

除了在Term詞典這塊有應用,FST在整個lucene內部使用的也是很廣泛的,基本把hashmap進行了替換。
場景大概有以下:

  • 自動聯想:suggester
  • charFilter: mappingcharFilter
  • 同義詞過濾器
  • hunspell拼寫檢查詞典

總結

FST,不但能共享前綴還能共享后綴。不但能判斷查找的key是否存在,還能給出響應的輸入output。 它在時間復雜度和空間復雜度上都做了最大程度的優化,使得Lucene能夠將Term Dictionary完全加載到內存,快速的定位Term找到響應的output(posting倒排列表)。


參考文檔:

Burst Tries
Direct Construction of Minimal Acyclic Subsequential Transducers
Index 1,600,000,000 Keys with Automata and Rust
DFA minimization WikiPedia
Smaller Representation of Finite State Automata
Using Finite State Transducers in Lucene

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM