Calendar 第一天

演示:语义电影搜索

让我们将今天所学的一切综合应用到一个实际项目中:一个科幻电影的语义搜索引擎。


在 Colab 中跟随操作: Open In Colab

项目概述:当搜索理解了含义

想象一下,你问搜索引擎:“给我推荐关于质疑现实和存在本质的电影”,然后它返回了《黑客帝国》、《盗梦空间》和《机械姬》,但这并不是因为这些电影名称中包含这些确切的词语,而是因为系统理解了这些电影实际讲的是什么。

这就是语义搜索。而你即将构建一个。

我们将获取详细的电影描述,应用你之前学到的分块策略,使用句子转换器嵌入这些分块,并将它们与丰富的元数据一起存储在 Qdrant 中。最终结果是一个能够理解主题、情绪和概念的搜索引擎。

这个项目综合了今天所学的一切:点和向量、距离度量、载荷、分块策略和嵌入模型。到最后,你将拥有一个可以按情节、主题或情感共鸣来查找电影的系统。

您将构建什么

一个可以实现以下功能的语义搜索引擎:

  • 理解含义:搜索“时间旅行和家庭关系”并找到《星际穿越》
  • 比较分块策略:查看固定大小、基于句子和语义分块如何影响搜索质量
  • 智能过滤:将语义搜索与元数据过滤器(年份、类型、评级)结合
  • 处理约束:处理超出嵌入模型令牌限制的长电影描述
  • 结果分组:当多个分块与你的查询匹配时,避免重复电影

步骤 1:理解挑战

我们的数据集包含 13 部科幻电影,带有详细的文学描述。挑战在于:每个描述包含 240-460 个令牌,但我们的嵌入模型(all-MiniLM-L6-v2)只能嵌入 256 个或更少的令牌。

这就是分块变得至关重要的地方。

# Example: A movie description that's too long for our embedding model
movie_example = {
    "name": "Ex Machina",
    "year": 2014,
    "description": """Alex Garland's Ex Machina is a cerebral, slow-burning psychological 
    thriller that delves into the ethics and consequences of artificial intelligence. 
    The story begins with Caleb, a young programmer at a tech conglomerate, who wins 
    a contest to spend a week at the secluded estate of Nathan, the reclusive CEO..."""
    # This continues for 386 tokens - too long for optimal embedding!
}

完整数据集(包括《黑客帝国》、《星际穿越》、《降临》、《湮灭》等)可在完整笔记本中获取。

步骤 2:三向量实验

这个演示的独特之处在于:我们将在一个集合中创建三个不同的向量空间,每个空间代表一种不同的分块策略。这使我们能够直接比较分块如何影响搜索质量。

旁注:在一个集合中创建三个不同的向量空间几乎与每个向量空间拥有一个集合的成本相同。我们这样做纯粹是为了方便比较。

from sentence_transformers import SentenceTransformer
from qdrant_client import QdrantClient, models

# Initialize components
encoder = SentenceTransformer("all-MiniLM-L6-v2")

# In-memory for demo: NO HNSW built -> queries are a full scan.
client = QdrantClient(":memory:")

# For ANN/HNSW:
# client = QdrantClient(url="https://:6333")

# Create collection with three named vectors
client.create_collection(
    collection_name='movie_search',
    vectors_config={
        'fixed': models.VectorParams(size=384, distance=models.Distance.COSINE),
        'sentence': models.VectorParams(size=384, distance=models.Distance.COSINE),
        'semantic': models.VectorParams(size=384, distance=models.Distance.COSINE),
    },
)

每个向量空间将存储相同但以不同方式分块的电影内容

  • 固定:原始的 40 令牌分块(可能在句中截断)
  • 句子:句子感知分块,带有重叠
  • 语义:使用嵌入相似度的含义感知分块

步骤 3:实现分块策略

这里是前面课程中的分块概念变得生动的地方。我们将实现三种不同的方法,并查看它们的表现

from transformers import AutoTokenizer
from llama_index.core.node_parser import SentenceSplitter, SemanticSplitterNodeParser
from llama_index.embeddings.huggingface import HuggingFaceEmbedding

tokenizer = AutoTokenizer.from_pretrained("sentence-transformers/all-MiniLM-L6-v2")
MAX_TOKENS = 40

def fixed_size_chunks(text, size=MAX_TOKENS):
    """Fixed-size chunking: splits at exact token boundaries"""
    tokens = tokenizer.encode(text, add_special_tokens=False)
    return [
        tokenizer.decode(tokens[i:i+size], skip_special_tokens=True)
        for i in range(0, len(tokens), size)
    ]

def sentence_chunks(text):
    """Sentence-aware chunking: respects sentence boundaries"""
    splitter = SentenceSplitter(chunk_size=MAX_TOKENS, chunk_overlap=10)
    return splitter.split_text(text)

def semantic_chunks(text):
    """Semantic chunking: uses embedding similarity to find natural breaks.
    Note: still constrained by the embed model's context window (same as retrievers)."""
    from llama_index.core import Document
    
    semantic_splitter = SemanticSplitterNodeParser(
        buffer_size=1,
        breakpoint_percentile_threshold=95,
        embed_model=HuggingFaceEmbedding(model_name="sentence-transformers/all-MiniLM-L6-v2")
    )
    nodes = semantic_splitter.get_nodes_from_documents([Document(text=text)])
    return [node.text for node in nodes]

关键区别:固定分块可能在句中截断。句子分块尊重语法。语义分块尊重含义。

步骤 4:处理和上传数据

对于每个电影描述,我们应用所有三种分块策略,嵌入生成的分块,并将它们与各自的向量名称一起存储

points = []
idx = 0

for movie in movies_data:  # Process each movie
    # Fixed-size chunks
    for chunk in fixed_size_chunks(movie["description"]):
        points.append(models.PointStruct(
            id=idx,
            vector={"fixed": encoder.encode(chunk).tolist()},
            payload={**movie, "chunk": chunk, "chunking": "fixed"}
        ))
        idx += 1

    # Sentence-aware chunks  
    for chunk in sentence_chunks(movie["description"]):
        points.append(models.PointStruct(
            id=idx,
            vector={"sentence": encoder.encode(chunk).tolist()},
            payload={**movie, "chunk": chunk, "chunking": "sentence"}
        ))
        idx += 1

    # Semantic chunks
    for chunk in semantic_chunks(movie["description"]):
        points.append(models.PointStruct(
            id=idx,
            vector={"semantic": encoder.encode(chunk).tolist()},
            payload={**movie, "chunk": chunk, "chunking": "semantic"}
        ))
        idx += 1

client.upload_points(collection_name='movie_search', points=points)
print(f"Uploaded {idx} vectors across three chunking strategies")

步骤 5:比较搜索结果

现在是激动人心的部分:测试不同的分块策略如何影响搜索质量。让我们创建一个辅助函数来比较结果

def search_and_compare(query, k=3):
    """Compare search results across all three chunking strategies"""
    print(f"Query: '{query}'\n")
    
    for strategy in ['fixed', 'sentence', 'semantic']:
        results = client.query_points(
            collection_name='movie_search',
            query=encoder.encode(query).tolist(),
            using=strategy,
            limit=k,
        )
        
        print(f"--- {strategy.upper()} CHUNKING ---")
        for i, point in enumerate(results.points, 1):
            payload = point.payload
            print(f"{i}. {payload['name']} ({payload['year']}) | Score: {point.score:.3f}")
            print(f"   Chunk: {payload['chunk'][:100]}...")
        print()

# Test with different queries
search_and_compare("alien invasion")
search_and_compare("questioning reality and existence")

预期输出

Query: 'alien invasion'

--- FIXED CHUNKING ---
1. E.T. the Extra-Terrestrial (1982) | Score: 0.554
   Chunk: the film opens with a group of botanist aliens visiting earth, only to be interrupted...

--- SENTENCE CHUNKING ---  
1. E.T. the Extra-Terrestrial (1982) | Score: 0.568
   Chunk: The film opens with a group of botanist aliens visiting Earth, only to be interrupted...

--- SEMANTIC CHUNKING ---
1. Annihilation (2018) | Score: 0.440
   Chunk: Annihilation is not a traditional alien invasion story - it is a meditation on the fragility...

步骤 6:高级功能

注意:如果您已经熟悉 Qdrant 的可过滤 HNSW,您会知道有效的过滤和分组通常依赖于在构建 HNSW 索引之前创建载荷索引。为了在本教程中保持简单,我们将进行带有过滤器的基本搜索,而不使用载荷索引,并将在本课程的第二天讨论载荷索引的正确用法。

按元数据过滤

将语义搜索与传统过滤器结合

# Find movies about AI made after 2000
results = client.query_points(
    collection_name='movie_search',
    query=encoder.encode("artificial intelligence").tolist(),
    using="semantic",
    query_filter=models.Filter(
        must=[models.FieldCondition(key="year", range=models.Range(gte=2000))]
    ),
    limit=3
)

for point in results.points:
    print(f"{point.payload['name']} ({point.payload['year']}) | Score: {point.score:.3f}")

分组结果以避免重复

当同一电影的多个分块匹配时,按电影标题对结果进行分组

# Group by movie name to get unique recommendations
response = client.query_points_groups(
    collection_name='movie_search',
    query=encoder.encode("time travel and family relationships").tolist(),
    using="semantic",
    group_by="name",       # Group by movie title
    limit=3,               # Number of unique movies
    group_size=1,          # Best chunk per movie
)

for group in response.groups:
    print(f"{group.id} | Best match score: {group.hits[0].score:.3f}")

你学到了什么

这个演示汇集了第一天的所有概念

分块实践: 你已经了解了不同的分块策略如何影响搜索质量。固定分块速度快但粗糙,句子分块保留了可读性,而语义分块捕捉了含义——但代价是计算成本。

嵌入和距离: all-MiniLM-L6-v2 模型将电影描述转换为 384 维向量。余弦相似度可以找到主题相似的电影,即使它们使用了完全不同的词语。

载荷和过滤: 丰富的元数据支持混合查询:“查找 2000 年后制作的关于 AI 的电影。” 这结合了语义理解和传统数据库过滤。

关键见解

分块很重要:相同的查询可能会根据你如何分块描述而返回不同的电影。语义分块为“外星人入侵”找到了《湮灭》,因为它理解了主题关联,而固定分块则侧重于字面提及。

上下文长度是一个实际约束:电影描述超出了嵌入模型的限制,使得分块在实际应用中至关重要。

分组确保捕获了底层文档逻辑:如果没有分组,结果将充斥着同一热门电影的多个分块。然而,有了分组,我们可以确保根据其单个前 k 个分块的排名返回热门电影(而不是分块)。

继续探索: 完整笔记本包含其他功能,如相似性搜索、基于主题的推荐和高级过滤示例。