8.1 倒排索引的建立#
概念說明#
正排索引是「文件 → 詞」;倒排索引(inverted index)則反轉成「詞 → 文件串列」,這是所有全文搜尋引擎(Lucene/Elasticsearch、Google、SQLite FTS)的核心資料結構。
一個完整條目包含: - 詞典(dictionary / term dictionary):所有索引詞,常以排序陣列、B-tree 或 FST(有限狀態轉換器)儲存,支援前綴查找。 - 倒排串列(posting list):每個詞對應的文件清單,可含位置資訊(支援片語查詢 "演算法 分析")與詞頻 tf(支援排名)。 - 跳躍指標(skip list):長串列上隔段存指標,加速多詞求交集。
建立流程(經典三段式,MapReduce 的前身思想):
- 收集文件:爬蟲或匯入,每文件配 docID。
- 分析(analyze):分詞(7.1/7.2 節)+規格化(小寫、詞幹化、去停用詞)——索引時與查詢時必須用同一套分析器,否則查不到,這是搜尋系統最常見的 bug。
- 建索引:掃每份文件,把 (詞, docID, 位置, tf) 記入暫存;串列太大就排序後落盤成一個 segment;最後把多個 segment 合併(merge),相同詞的串列接起來。
查詢處理:把查詢詞分別取倒排串列,交集(AND,用雙指標或跳躍合併)得候選文件集,再交給 8.2 節排名。OR 查詢則用併集。片語查詢需檢查位置是否相鄰。
工程量級:10 億文件 × 平均 500 詞 → 詞典可能數千萬條、posting 總量數千億筆。所以真正的引擎把詞典分塊壓縮(front coding)、posting 用 delta encoding + 變長整數(VByte)或 Roaring Bitmap 壓縮——posting 串列本身就是個「嚴格遞增整數串列」的壓縮問題,與第6章的位元流技巧相通。
演算法步驟#
- 初始化
index: dict[term, list[(doc_id, [pos,...])]]與doc_len: dict[doc_id, int]。 - 對每份文件:分詞→規格化;逐詞記錄位置;更新 doc_len。
- 索引完成後,對每個詞的串列按 docID 排序(本實作自然有序)。
- 查詢(AND):把各詞的 docID 串列做 k 路交集(合併式:各串列各有指標,全指向同一 docID 時輸出並前進)。
- 片語查詢:取交集後,對每個命中文件比對各詞位置是否連續。
Python 程式碼範例#
import re
from collections import defaultdict
DOCS = {
1: "演算法是處理資料的方法,資料結構支撐演算法。",
2: "深度學習是演算法的一種,需要大量資料。",
3: "資料結構與演算法是電腦科學的核心課程。",
4: "搜尋引擎使用倒排索引與排名演算法。",
}
def analyze(text):
"""中文用「單字+相鄰二字詞(bigram)」索引,英文用完整詞——無需分詞器"""
out = []
for run in re.findall(r"[\u4e00-\u9fff]+|[A-Za-z0-9]+", text.lower()):
if not "\u4e00" <= run[0] <= "\u9fff":
out.append(run)
else:
for i in range(len(run)):
out.append(run[i])
if i + 1 < len(run):
out.append(run[i:i + 2])
return out
class InvertedIndex:
def __init__(self):
self.postings = defaultdict(lambda: defaultdict(list)) # 詞 -> doc -> [pos]
self.doc_len = {}
def add(self, doc_id, text):
toks = [] # (詞, 原文字元位置)
for m in re.finditer(r"[\u4e00-\u9fff]+|[A-Za-z0-9]+", text.lower()):
run, base = m.group(), m.start()
if not "\u4e00" <= run[0] <= "\u9fff":
toks.append((run, base))
else:
for i in range(len(run)):
toks.append((run[i], base + i))
if i + 1 < len(run):
toks.append((run[i:i + 2], base + i))
self.doc_len[doc_id] = len(toks)
for t, cp in toks:
self.postings[t][doc_id].append(cp)
def docs(self, term):
return sorted(self.postings.get(term, {}))
def intersect(self, terms):
lists = [self.docs(t) for t in terms]
if not lists: return []
ptrs, out = [0] * len(lists), []
while all(p < len(l) for p, l in zip(ptrs, lists)):
ids = [l[p] for p, l in zip(ptrs, lists)]
if len(set(ids)) == 1:
out.append(ids[0])
ptrs = [p + 1 for p in ptrs]
else:
m = min(ids)
ptrs = [p + 1 if l[p] == m else p for p, l in zip(ptrs, lists)]
return out
def phrase(self, words):
cand = self.intersect(words)
out = []
for d in cand:
pos0 = self.postings[words[0]][d]
if all(any(p + i in self.postings[w][d]
for p in pos0) for i, w in enumerate(words[1:], 1)):
out.append(d)
return out
ix = InvertedIndex()
for d, text in DOCS.items():
ix.add(d, text)
print("『演』出現於:", ix.docs("演"))
print("『演算法』(二字詞交集):", ix.intersect(["演算", "算法"]))
print("『演算法』AND『資料』:", ix.intersect(["演算", "算法", "資料"]))
print("片語『倒排索引』:", ix.phrase(["倒排", "排索", "索引"]))
print("片語『資料結構』:", ix.phrase(["資料", "料結", "結構"]))
複雜度分析#
- 建索引:O(總詞數)(每詞常數工作);空間 O(總詞數)(含位置時同級)。
- 交集(AND):k 路合併 O(Σ|串列長|);稀疏詞先交(先交最短的兩串)可大幅剪枝,最優可到 O(最小串 × log) 級。
- 單詞查詢:詞典查詢 O(log|V|)(排序陣列二分)+串列讀取 O(命中數)。
- 壓縮後的量級:posting 以 delta+VByte 編碼,大小常壓到原 10–20%;Lucene 的段合併讓查詢時段數維持在 O(log 文件數)。
練習#
- 為
InvertedIndex加上or_query(k 路併集)與not_query(差集),實作演算法 AND 資料 NOT 深度學習這種布林查詢。 - 實作 VByte 編碼(每 7 bits 一段、高位元為續傳旗標):把一個 posting 串列的 docID 序列先做 delta 再 VByte 編碼,計算壓縮比;對照直接存 4-byte int 的空間。
- 在 1 萬篇文件上分別測「先交集最短串列」與「照詞序交集」的查詢時間差,並解釋為何優化器總是「先處理稀有詞」。