从关键词到语义搜索:TF-IDF与嵌入向量对比

2 阅读8分钟

TF-IDF vs. 嵌入向量:从关键词到语义搜索

系列前言:从文本到RAG

本系列共3部分,将指导从原始文本文档到构建一个完整的检索增强生成(RAG)流程。

系列构建内容

每一课都建立在前一课的基础上,使用相同的共享代码仓库。

项目结构

所有3课共享同一个结构,可以在各部分之间复用嵌入向量、索引和提示词。

vector-rag-series/
├── 01_intro_to_embeddings.py          # 第1部分 – 生成并可视化嵌入向量
├── 02_vector_search_ann.py            # 第2部分 – 构建FAISS索引并运行ANN搜索
├── 03_rag_pipeline.py                 # 第3部分 – 将向量搜索连接到LLM
│
├── pyimagesearch/
│   ├── __init__.py
│   ├── config.py                      # 路径、常量、模型名称、提示模板
│   ├── embeddings_utils.py            # 加载语料库、生成并保存嵌入向量
│   ├── vector_search_utils.py         # ANN工具
│   └── rag_utils.py                   # 提示构建器与检索逻辑
│
├── data/
│   ├── input/                         # 语料库文本 + 元数据
│   ├── output/                        # 缓存的嵌入向量和PCA投影
│   ├── indexes/                       # FAISS索引
│   └── figures/                       # 生成的图表
│
├── scripts/
│   └── list_indexes.py                # 索引检查辅助工具
│
├── environment.yml                    # Conda环境配置
├── requirements.txt                   # 依赖项

  • config.py:集中化配置和文件路径
  • embeddings_utils.py:加载、嵌入和保存数据的核心逻辑
  • 01_intro_to_embeddings.py:协调一切的驱动脚本

为什么从嵌入向量开始

一切从语义开始。计算机在检索或推理文本之前,必须首先表示该文本的含义。嵌入向量使这成为可能——它们将人类语言转换为数值形式,捕捉关键词匹配无法实现的微妙语义关系。

关键词搜索的问题

大多数经典系统(如TF-IDF或BM25)将文本视为词袋,通过词频和稀有度调整来判断相关性,假设词汇重叠等于相关性。

当“不同词语”表达相同含义时

示例:

  • Q1:“明天有多暖和?”
  • Q2:“明天的天气预报”

这两句话表达了相同的意图——询问天气——但它们几乎没有重叠的词语。关键词搜索引擎根据共享词汇对文档进行排序。如果没有共享词汇,可能会完全错过匹配。这称为意图不匹配:词汇相似性无法捕捉语义相似性。

TF-IDF和BM25的不足

TF-IDF对在一篇文档中出现频繁但在其他文档中很少出现的词语给出高分。它擅长区分主题,但对语义却很脆弱。BM25通过词项饱和度和文档长度归一化改进了排序,但仍然根本上依赖于词汇重叠而非语义含义。

词汇思维的代价

关键词搜索在以下情况中表现不佳:

  • 同义词:“AI” vs “人工智能”
  • 释义:“修复bug” vs “解决问题”
  • 一词多义:“苹果”(水果)vs “某机构”(公司)
  • 语言灵活性:“电影” vs “影片”

为什么语义需要几何

语言是连续的;含义存在于一个谱系上,而不是离散的词桶中。与其匹配字符串,不如将它们的含义绘制在高维空间中——即使使用不同的词语,相似的想法也会靠近在一起。这就是从关键词搜索到语义搜索的飞跃。

什么是向量数据库及其重要性

向量数据库存储和检索语义含义,而不是字面文本。它们通过称为嵌入向量的连续几何数据表示,按概念进行搜索。

核心思想

每一条非结构化数据(如段落、图像或音频片段)都通过一个模型(例如 SentenceTransformer 或 CLIP)转换为一个向量(即一串数字)。这些数字捕捉语义关系:概念上相似的项在这个多维空间中彼此更接近。

形式上,每个向量是N维空间中的一个点(N = 模型的嵌入维度,例如384或768)。点之间的距离表示它们的相关程度——余弦相似度、内积或欧氏距离是最常见的度量。

重要性

向量数据库使含义变得可搜索。这使得它们成为以下应用的基础:

  • 语义搜索
  • 推荐系统
  • RAG流程
  • 聚类和发现

概念工作原理

  1. 编码:将原始内容(文本、图像等)转换为密集数值向量
  2. 存储:将这些向量及其元数据保存在向量数据库中
  3. 查询:将传入查询转换为向量并找到最近邻
  4. 返回:检索匹配的嵌入向量及其代表的原始数据

理解嵌入向量:将语言转化为几何

嵌入向量就是一串浮点数——但每个数字都编码了模型学习到的潜在特征。这些特征共同表示输入的语义:它谈论什么,出现什么概念,以及这些概念如何关联。

为什么需要嵌入向量

语言是灵活的——同一个想法可以用多种形式表达。嵌入向量通过将两个句子映射到附近的向量来解决这个问题——这是共享含义的几何信号。

嵌入向量的工作原理

当将文本输入嵌入模型时,它输出一个向量,如:[0.12, -0.45, 0.38, ..., 0.09]。每个维度编码潜在属性,如主题、语气或上下文关系。

从静态到上下文再到句子级嵌入

  • 静态嵌入(如Word2Vec):每个词一个向量 → 无法处理一词多义
  • 上下文嵌入(如BERT):通过自注意力机制,根据周围词语推断含义 → 真正理解上下文
  • 句子嵌入(如Sentence Transformers):为每个句子或段落生成一个嵌入向量

本项目使用的是 all-MiniLM-L6-v2,一个轻量级、高质量的模型,输出384维的句子嵌入向量。

代码中的实现

pyimagesearch/config.py 中定义:

EMBED_MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"

加载模型:

from sentence_transformers import SentenceTransformer
def get_model(model_name=config.EMBED_MODEL_NAME):
    return SentenceTransformer(model_name)

生成嵌入向量:

def generate_embeddings(texts, model=None, batch_size=16, normalize=True):
    embeddings = model.encode(
        texts, batch_size=batch_size, show_progress_bar=True,
        convert_to_numpy=True, normalize_embeddings=normalize
    )
    return embeddings

为什么嵌入向量会语义聚类

当使用PCA或t-SNE绘制时,来自相似主题的嵌入向量会形成聚类。这是因为嵌入向量通过对比目标进行训练——将语义上接近的示例推到一起,将不相关的示例推开。

配置开发环境

核心依赖:

pip install sentence-transformers==2.7.0
pip install numpy==1.26.4  
pip install rich==13.8.1

实现详解:配置和目录设置

config.py 文件定义了数据位置、模型加载方式以及不同流程组件之间的通信方式。

核心目录设置:

from pathlib import Path
import os

BASE_DIR = Path(__file__).resolve().parent.parent
DATA_DIR = BASE_DIR / "data"
INPUT_DIR = DATA_DIR / "input"
OUTPUT_DIR = DATA_DIR / "output"
INDEX_DIR = DATA_DIR / "indexes"
FIGURES_DIR = DATA_DIR / "figures"

嵌入和模型工件:

EMBEDDINGS_PATH = OUTPUT_DIR / "embeddings.npy"
METADATA_ALIGNED_PATH = OUTPUT_DIR / "metadata_aligned.json"
DIM_REDUCED_PATH = OUTPUT_DIR / "pca_2d.npy"
EMBED_MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"

嵌入工具 (embeddings_utils.py)

加载语料库:

def load_corpus(corpus_path=CORPUS_PATH, meta_path=CORPUS_META_PATH):
    with open(corpus_path, "r", encoding="utf-8") as f:
        texts = [line.strip() for line in f if line.strip()]
    if meta_path.exists():
        import json; metadata = json.load(open(meta_path, "r", encoding="utf-8"))
    else:
        metadata = []
    if len(metadata) != len(texts):
        metadata = [{"id": f"p{idx:02d}", "topic": "unknown", "tokens_est": len(t.split())} for idx, t in enumerate(texts)]
    return texts, metadata

保存和加载嵌入向量:

import json
def save_embeddings(embeddings, metadata, emb_path=EMBEDDINGS_PATH, meta_out_path=METADATA_ALIGNED_PATH):
    np.save(emb_path, embeddings)
    json.dump(metadata, open(meta_out_path, "w", encoding="utf-8"), indent=2)

def load_embeddings(emb_path=EMBEDDINGS_PATH, meta_out_path=METADATA_ALIGNED_PATH):
    emb = np.load(emb_path)
    meta = json.load(open(meta_out_path, "r", encoding="utf-8"))
    return emb, meta

计算相似度和排序:

def compute_cosine_similarity(vec, matrix):
    return matrix @ vec

def top_k_similar(query_emb, emb_matrix, k=DEFAULT_TOP_K):
    sims = compute_cosine_similarity(query_emb, emb_matrix)
    idx = np.argpartition(-sims, k)[:k]
    idx = idx[np.argsort(-sims[idx])]
    return idx, sims[idx]

降维可视化:

from sklearn.decomposition import PCA
def reduce_dimensions(embeddings, n_components=2, seed=42):
    pca = PCA(n_components=n_components, random_state=seed)
    return pca.fit_transform(embeddings)

驱动脚本详解 (01_intro_to_embeddings.py)

确保嵌入向量存在或重建:

def ensure_embeddings(force: bool = False):
    if config.EMBEDDINGS_PATH.exists() and not force:
        emb, meta = load_embeddings()
        texts, _ = load_corpus()
        return emb, meta, texts
    texts, meta = load_corpus()
    model = get_model()
    emb = generate_embeddings(texts, model=model, batch_size=16, normalize=True)
    save_embeddings(emb, meta)
    return emb, meta, texts

展示最近邻(语义搜索演示):

def show_neighbors(embeddings: np.ndarray, texts, model, queries):
    print("[bold cyan]\nSemantic Similarity Examples[/bold cyan]")
    for q in queries:
        q_emb = model.encode([q], convert_to_numpy=True, normalize_embeddings=True)[0]
        idx, scores = top_k_similar(q_emb, embeddings, k=5)
        table = Table(title=f"Query: {q}")
        table.add_column("Rank")
        table.add_column("Score", justify="right")
        table.add_column("Text (truncated)")
        for rank, (i, s) in enumerate(zip(idx, scores), start=1):
            snippet = texts[i][:100] + ("..." if len(texts[i]) > 100 else "")
            table.add_row(str(rank), f"{s:.3f}", snippet)
        print(table)

可视化嵌入空间:

def visualize(embeddings: np.ndarray):
    coords = reduce_dimensions(embeddings, n_components=2)
    np.save(config.DIM_REDUCED_PATH, coords)
    try:
        import matplotlib.pyplot as plt
        fig_path = config.FIGURES_DIR / "semantic_space.png"
        plt.figure(figsize=(6, 5))
        plt.scatter(coords[:, 0], coords[:, 1], s=20, alpha=0.75)
        plt.title("PCA Projection of Corpus Embeddings")
        plt.tight_layout()
        plt.savefig(fig_path, dpi=150)
        print(f"Saved 2D projection to {fig_path}")
    except Exception as e:
        print(f"[yellow]Could not generate plot: {e}[/yellow]")

主协调逻辑:

def main():
    print("[bold magenta]Loading / Generating Embeddings...[/bold magenta]")
    embeddings, metadata, texts = ensure_embeddings()
    print(f"Loaded {len(texts)} paragraphs. Embedding shape: {embeddings.shape}")

    model = get_model()

    sample_queries = [
        "Why do we normalize embeddings?",
        "What is HNSW?",
        "Explain vector databases",
    ]
    show_neighbors(embeddings, texts, model, sample_queries)

    print("[bold magenta]\nCreating 2D visualization (PCA)...[/bold magenta]")
    visualize(embeddings)

    print("[green]\nDone. Proceed to Post 2 for ANN indexes.\n[/green]")

if __name__ == "__main__":
    main()

总结

在本课中,构建了理解机器如何表示含义的基础:

  1. 回顾了基于关键词的搜索的局限性
  2. 探索了嵌入向量如何通过将语言映射到连续向量空间来解决这些问题
  3. 学习了现代嵌入模型如何生成密集的数值向量
  4. 使用 all-MiniLM-L6-v2 模型将语料库中的每个段落转换为384维向量
  5. 执行了首次语义相似度搜索,比较句子之间的含义方向
  6. 使用PCA可视化语义空间,揭示了语义聚类

这些工件现在作为下一阶段旅程的输入,届时将通过使用FAISS构建高效的近似最近邻索引,使搜索真正可扩展。FINISHED