跳轉到

8.2 TF-IDF與排名演算法#

概念說明#

倒排索引回答「哪些文件含有這些詞」,但使用者要的是「最相關的排在最前」。排名(ranking)就是相關性的量化:給定查詢 q 與文件 d,算一個分數 score(q, d),由大到小排序。

TF-IDF 是最經典的權重方案,兩個直覺各據一半:

  • TF(詞頻):詞 w 在文件 d 中出現越多次,d 與 w 越相關。但相關性不是線性的——出現 10 次不代表比 1 次相關 10 倍,常用對數壓縮:tf = 1 + log(count)。
  • IDF(逆文件頻率):出現在越多文件中的詞越沒有區別力。「演算法」在我們的小語料中每篇都有,IDF≈0;「倒排索引」只有一篇有,IDF 高。idf(w) = log(N / df(w)) + 1(N 為文件總數、df 為含 w 的文件數)。

向量模型:把文件與查詢都表示為「各詞的 TF-IDF 權重」構成的向量,相關性 = 餘弦相似度 cos(θ) = (q·d)/(‖q‖‖d‖)。除以長度是關鍵:否則長文件天然占便宜。查詢向量也做同樣加權,讓稀有詞在查詢中更重要。

BM25(Robertson & Spärck Jones 系列工作)是現代標準(Elasticsearch、Lucene 預設),對 TF-IDF 的兩個修正是:

  1. 詞頻飽和:分數隨 tf 增長但有上界——tf 出現 3 次後,第 4 次的邊際貢獻趨近 0。tf 貢獻 = tf·(k₁+1) / (tf + k₁),k₁≈1.2 控制飽和速度。
  2. 文件長度正規化:除以 (1 − b + b·|d|/avgdl),b≈0.75——太長的文件不能無限占優,但也不該一刀切懲罰(b=0 就是不管長度)。

評估:排名好壞用標準 IR 指標——Precision@k(前 k 名中相關的比例)、MAP、NDCG。這讓排名演算法可以像分類器一樣被量化比較。

語義擴展:TF-IDF 只懂「字面」。現代搜尋加上向量檢索:查詢與文件都編碼成語義向量(7.3 節的延伸),用餘弦相似度召回「詞面不同但義近」的文件(查「筆電」也召回「膝上型電腦」),與 BM25 分數融合(hybrid search)。再疊上 8.3 節的重要性訊號與點擊回饋,就是完整引擎的輪廓。

演算法步驟#

  1. 對每份文件分詞,統計詞頻與文件長度;統計每詞的 df。
  2. 建索引(8.1 節),同時存 tf 於 posting。
  3. 對查詢分詞,取各詞 posting 交集得候選文件。
  4. 對每個候選文件 d:score = Σ_{w∈q} tfidf(w,d) × tfidf(w,q)(向量模型)或 BM25 分式和。
  5. 除以向量範數(餘弦模型需要),依分數排序輸出前 k 名。

Python 程式碼範例#

import math, re
from collections import Counter

DOCS = {  # 延續 8.1 節語料
    1: "演算法是處理資料的方法,資料結構支撐演算法。",
    2: "深度學習是演算法的一種,需要大量資料。",
    3: "資料結構與演算法是電腦科學的核心課程。",
    4: "搜尋引擎使用倒排索引與排名演算法。",
}
def analyze(text):
    """與 8.1 節一致:中文單字+二字詞,英文完整詞"""
    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

tf, doc_len = {}, {}
for d, text in DOCS.items():
    tf[d] = Counter(analyze(text))
    doc_len[d] = len(tf[d])
N = len(DOCS)
df = Counter(w for c in tf.values() for w in c)
def idf(w): return math.log(N / df[w]) + 1 if df[w] else 0.0

def tfidf_vector(counter):
    return {w: (1 + math.log(c)) * idf(w) for w, c in counter.items()}

def cosine(q_vec, d_vec):
    dot = sum(s * d_vec.get(w, 0) for w, s in q_vec.items())
    nq = math.sqrt(sum(s * s for s in q_vec.values()))
    nd = math.sqrt(sum(s * s for s in d_vec.values()))
    return dot / (nq * nd) if nq and nd else 0

def search_tfidf(query, top=3):
    q = tfidf_vector(Counter(analyze(query)))
    scores = {d: cosine(q, tfidf_vector(c)) for d, c in tf.items()}
    return sorted(scores.items(), key=lambda x: -x[1])[:top]

def bm25(query, k1=1.2, b=0.75, top=3):
    avgdl = sum(doc_len.values()) / N
    scores = {}
    for d in DOCS:
        s = 0
        for w in analyze(query):
            f = tf[d].get(w, 0)
            if f:
                s += idf(w) * f * (k1 + 1) / (f + k1 * (1 - b + b * doc_len[d] / avgdl))
        scores[d] = s
    return sorted(scores.items(), key=lambda x: -x[1])[:top]

for q in ["資料結構 演算法", "搜尋引擎 倒排索引", "深度學習 資料"]:
    print(q, "TF-IDF:", [(d, round(s, 3)) for d, s in search_tfidf(q)])
    print(q, "BM25  :", [(d, round(s, 3)) for d, s in bm25(q)])

複雜度分析#

  • 離線:df/tf 統計 O(總詞數);向量正規化常數預先算好存起來,查詢期免重算。
  • 查詢:取 posting 交集 O(Σ串列長);對候選計分 O(|q| × |候選|);總複雜度與命中集大小成正比——排名只在候選集上做,不必掃全庫,這就是索引化排名。
  • BM25 vs TF-IDF:同量級;BM25 有兩個可調參數 k₁、b,且對長文件更穩健,故成為預設。
  • 極大規模時用 WAND / Block-Max WAND 剪枝:維護分數上界,跳過不可能進前 k 名的文件,實務可省一個數量級。

練習#

  1. 構造兩份文件:短文件精確命中查詢一次、長文件出現查詢詞五次但夾雜大量無關內容;分別用「純 TF」(無 IDF、無長度正規)與 BM25 排名,展示後者為何較合理。
  2. 實作 NDCG@5:為 8.1 節的語料手工標註相關度(0–3),計算 TF-IDF 與 BM25 的 NDCG,比較誰贏。
  3. 把 7.3 節的詞向量接進來:對查詢中每個詞找最近的 2 個同義詞加入查詢(查詢擴展),觀察「筆電 vs 膝上型電腦」類查詢的召回變化;寫下擴展何時反而傷害精確率。