跳轉到

8.1 倒排索引的建立#

概念說明#

正排索引是「文件 → 詞」;倒排索引(inverted index)則反轉成「詞 → 文件串列」,這是所有全文搜尋引擎(Lucene/Elasticsearch、Google、SQLite FTS)的核心資料結構。

一個完整條目包含: - 詞典(dictionary / term dictionary):所有索引詞,常以排序陣列、B-tree 或 FST(有限狀態轉換器)儲存,支援前綴查找。 - 倒排串列(posting list):每個詞對應的文件清單,可含位置資訊(支援片語查詢 "演算法 分析")與詞頻 tf(支援排名)。 - 跳躍指標(skip list):長串列上隔段存指標,加速多詞求交集。

建立流程(經典三段式,MapReduce 的前身思想):

  1. 收集文件:爬蟲或匯入,每文件配 docID。
  2. 分析(analyze):分詞(7.1/7.2 節)+規格化(小寫、詞幹化、去停用詞)——索引時與查詢時必須用同一套分析器,否則查不到,這是搜尋系統最常見的 bug。
  3. 建索引:掃每份文件,把 (詞, docID, 位置, tf) 記入暫存;串列太大就排序後落盤成一個 segment;最後把多個 segment 合併(merge),相同詞的串列接起來。

查詢處理:把查詢詞分別取倒排串列,交集(AND,用雙指標或跳躍合併)得候選文件集,再交給 8.2 節排名。OR 查詢則用併集。片語查詢需檢查位置是否相鄰。

工程量級:10 億文件 × 平均 500 詞 → 詞典可能數千萬條、posting 總量數千億筆。所以真正的引擎把詞典分塊壓縮(front coding)、posting 用 delta encoding + 變長整數(VByte)或 Roaring Bitmap 壓縮——posting 串列本身就是個「嚴格遞增整數串列」的壓縮問題,與第6章的位元流技巧相通。

演算法步驟#

  1. 初始化 index: dict[term, list[(doc_id, [pos,...])]] 與 doc_len: dict[doc_id, int]。
  2. 對每份文件:分詞→規格化;逐詞記錄位置;更新 doc_len。
  3. 索引完成後,對每個詞的串列按 docID 排序(本實作自然有序)。
  4. 查詢(AND):把各詞的 docID 串列做 k 路交集(合併式:各串列各有指標,全指向同一 docID 時輸出並前進)。
  5. 片語查詢:取交集後,對每個命中文件比對各詞位置是否連續。

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 文件數)。

練習#

  1. 為 InvertedIndex 加上 or_query(k 路併集)與 not_query(差集),實作 演算法 AND 資料 NOT 深度學習 這種布林查詢。
  2. 實作 VByte 編碼(每 7 bits 一段、高位元為續傳旗標):把一個 posting 串列的 docID 序列先做 delta 再 VByte 編碼,計算壓縮比;對照直接存 4-byte int 的空間。
  3. 在 1 萬篇文件上分別測「先交集最短串列」與「照詞序交集」的查詢時間差,並解釋為何優化器總是「先處理稀有詞」。