Building Context-Aware Search in Python with LLM Embeddings + Metadata

TL;DR · AI 摘要
本文介绍如何结合LLM嵌入和元数据过滤,在Python中构建上下文感知的语义搜索引擎。
核心要点
- 使用本地预训练模型生成384维向量,无需API密钥即可实现语义搜索。
- 通过团队、状态、优先级和日期等元数据进行前置过滤,提升检索准确性。
- 利用余弦相似度计算候选文档得分,并支持索引持久化以提高效率。
结构提纲
按章节快速跳转。
思维导图
用一张图看清主题之间的关系。
查看大纲文本(无障碍 / 无 JS 友好)
- Context-Aware Semantic Search
- Core Components
- LLM Embeddings
- Metadata Filters
- Implementation Steps
- Data Preparation
- Index Construction
- Similarity Scoring
金句 / Highlights
值得收藏与分享的关键句。
句子嵌入模型将字符串转化为固定长度向量,其中意义决定距离而非字面重叠。
余弦相似度范围从-1(相反)到1(相同),强相关通常高于0.6。
嵌入模型编码的是语义内容,而不是诸如作者或创建日期之类的元数据。
在本文中,您将学习如何在 Python 中构建一个上下文感知的语义搜索引擎,该引擎结合了基于嵌入的相似性与结构化元数据过滤。
我们将涵盖的主题包括:
- 句子嵌入和余弦相似度如何协同工作以查找语义相关的文档。
- 如何构建一个支持元数据的搜索索引,在评分候选结果之前按团队、状态、优先级和日期进行过滤。
- 如何将索引持久化到磁盘,以便仅计算一次嵌入并在后续运行时高效重新加载。

使用LLM嵌入+元数据在Python中构建上下文感知搜索
引言
关键词搜索在用户输入文档中没有明确表述的内容时就会失效。支持工程师搜索“登录持续失败”时,不会找到标题为“OAuth2令牌刷新竞争条件”的工单,即使那正是他们需要的。这就是上下文感知语义搜索旨在解决的核心问题。
语义搜索通过将文本转换为称为嵌入的密集向量表示来解决这个问题,在这种表示中,接近度由含义而非确切的词汇重叠决定。在此基础上叠加结构化元数据过滤器——按日期、状态、团队、优先级——你就得到了一个既能理解某人询问的内容,同时又能尊重上下文约束的系统。
本文将端到端地介绍构建该系统的过程:来自本地预训练模型的嵌入、支持元数据的索引、余弦相似度排序,以及一个可在重启时持久化且无需重新编码的索引。
你将构建什么
一个在工程支持工单语料库上的简单上下文感知搜索引擎。最终你将拥有:
- 从预训练模型本地生成的384维嵌入,无需API密钥
- 在评分之前按团队、状态、优先级和日期过滤的搜索索引
- 对过滤后的候选池进行余弦相似度排序
- 无需重新编码即可重新加载的持久化索引
先决条件:Python 3.8+,基本熟悉NumPy和处理字典列表。
安装依赖项:
pip install sentence-transformers numpy理解语义搜索的工作原理
句子嵌入模型接收一个字符串并返回一个固定长度的浮点数向量。该模型经过训练,使具有相似含义的句子在高维空间中产生指向相似方向的向量。
余弦相似度测量两个向量之间的角度:
cosine_similarity(A,B) = (A·B)/(||A||×||B||)
当向量单位归一化——即其长度等于1.0时——这简化为点积:A · B。得分范围从-1(相反)到1(相同)。实际上,无关文档得分为0.1–0.25左右,强匹配得分超过0.6。
那么为什么元数据过滤很重要呢?嵌入模型编码语义内容。它们不编码谁写了文档、哪个团队拥有它或何时创建的。这些属性存在于文本之外,必须单独处理。结合两种信号——语义得分和元数据约束——才是使搜索在实际系统中有用的关键。
设置数据集
我们将处理20个跨三个团队——基础设施、后端和前端——的工程支持工单,包含四个优先级级别、两种状态和两个月的时间窗口。
每个工单都是一个普通字典。text字段是要嵌入的内容;其他所有内容都是用于过滤的元数据。
为了简洁起见,这里显示的是截断列表而不是完整代码块。完整的工单集可在这个GitHub gist中获得。
from datetime import date
tickets=[
{"id":"T-101","team":"infrastructure","status":"open","priority":"high",
"created":date(2025,11,3),
"text":"Kubernetes pod keeps crashing with OOMKilled — memory limits on the ML inference container are set too low for the model it loads at runtime."},
{"id":"T-102","team":"infrastructure","status":"open","priority":"high",
"created":date(2025,11,8),
"text":"Nginx ingress returning 502 after rotating TLS certificate. Chain is valid per openssl verify but the backend handshake fails immediately."},
{"id":"T-103","team":"infrastructure","status":"resolved","priority":"medium",
"created":date(2025,10,14),
"text":"Terraform state file locked in S3 — a team member force-applied a plan without releasing the DynamoDB lock first."},
...
{"id":"T-401","team":"infrastructure","status":"open","priority":"medium",
"created":date(2025,11,11),
"text":"CI pipeline fails on ARM64 runners — base Docker image has no ARM variant, exec format error at build stage."},
{"id":"T-402","team":"infrastructure","status":"resolved","priority":"high","created": date(2025, 10, 9),
"text": "高峰时段 VPN 网关延迟激增 —— 两个对等点之间的 BGP 路由抖动导致私有子网中出现间歇性丢包。"继续之前快速检查语料库的结构:
open_ct = sum(1 for t in tickets if t["status"] == "open")
resolved_ct = sum(1 for t in tickets if t["status"] == "resolved")
print(f"{len(tickets)} tickets | {open_ct} open | {resolved_ct} resolved")输出:
20 tickets | 14 open | 6 resolved运行这段代码确认了分布情况:总共 20 张工单,其中 14 张未解决,6 张已解决,并分布在三个团队中。
步骤 1:生成嵌入向量
all-MiniLM-L6-v2 可以将任意句子映射到一个 384 维的向量空间。它完全在 CPU 上运行,首次从 Hugging Face 下载模型(约 22MB),之后会缓存在本地,无需 API 密钥即可使用。
from sentence_transformers import SentenceTransformer
import numpy as np
model = SentenceTransformer("all-MiniLM-L6-v2")
texts = [t["text"] for t in tickets]
embeddings = model.encode(texts, normalize_embeddings=True, show_progress_bar=True)
print(f"Shape: {embeddings.shape} | norm[0]: {np.linalg.norm(embeddings[0]):.4f}")我们传入参数 normalize_embeddings=True,使得每个输出向量都具有精确为 1.0 的 L2 范数。一旦这些向量位于 单位超球面 上,在查询时计算余弦相似度就简化为其点积运算,因此不需要做除法操作。这意味着 对整个候选池评分只需一次矩阵乘法 即可完成。
输出:

20 张工单的句向量表示
我们得到了一个形状为 (20, 384) 的 float32 类型矩阵——每行对应一张工单。范数为 1.0 表明归一化成功生效。
步骤 2:构建索引
该 索引存储了嵌入矩阵以及相关的元数据,并提供了一个搜索方法,允许通过关键字参数指定每一个元数据字段作为筛选条件。
class ContextAwareIndex:
def __init__(self, embeddings: np.ndarray, documents: list):
self.embeddings = embeddings # (N, D), L2-normalized
self.documents = documents
def search(
self,
query: str,
top_k: int = 5,
team: str = None,
status: str = None,
priority: str = None,
after: "date" = None,
before: "date" = None,
min_score: float = 0.0,
) -> list[dict]:
# 将查询转换成与文档相同的向量空间中的向量
q_vec = model.encode([query], normalize_embeddings=True)[0]
# 构建布尔掩码 —— 不满足过滤条件的文档标记为 False
mask = np.ones(len(self.documents), dtype=bool)
for i, doc in enumerate(self.documents):
if team and doc["team"] != team:
mask[i] = False
if status and doc["status"] != status:
mask[i] = False
if priority and doc["priority"] != priority:
mask[i] = False
if after and doc["created"] < after:
mask[i] = False
if before and doc["created"] > before:
mask[i] = False
candidate_idx = np.where(mask)[0]
if len(candidate_idx) == 0:
return []
# 对通过筛选的候选文档进行打分
scores = self.embeddings[candidate_idx] @ q_vec
# 去掉低于最小分数阈值的结果,排序后返回前 k 个
valid = np.where(scores >= min_score)[0]
if len(valid) == 0:
return []
top_local = np.argsort(scores[valid])[::-1][:top_k]
top_global = candidate_idx[valid[top_local]]
return [
{**self.documents[i], "score": float(scores[valid[top_local[j]]])}
for j, i in enumerate(top_global)
]
index = ContextAwareIndex(embeddings, tickets)这里的关键设计决策是在打分之前执行过滤,而不是之后。事后过滤会在那些最终会被舍弃的文档上浪费点积计算资源。先过滤还能确保 min_score 参数可以有效剔除无关结果,避免返回低置信度的噪声匹配项。
步骤 3:执行查询
我们将运行三次查询来展示系统的不同方面:仅语义搜索、带元数据过滤器的相同查询,以及按优先级限定范围的跨团队查询。
首先定义一个小助手函数,用于统一格式化所有三个示例的输出结果。
查询 1:无过滤搜索
为了建立基准线,我们在没有任何元数据限制的情况下进行搜索,让嵌入模型仅基于语义相似度在整个语料库中进行排名。
results = index.search("authentication token expiry and session management", top_k=4)
show("'authentication token expiry and session management' (no filters)", results)针对完整的 20 张工单语料库运行此查询,返回如下四张后端相关的工单:
Query: 'authentication token expiry and session management' (no filters)
[0.6133] T-207 backend open high 2025-11-03
登出后 Session Cookie 仍然存在 —— 中间件缺少令牌黑名单校验机制...
[0.4958] T-201 backend open high 2025-11-05
OAuth2 刷新令牌间歇性失败 —— 令牌缓存中存在竞态条件问题...
[0.3459] T-203 backend open medium 2025-11-01
JWT 签名验证间歇性失败 —— 鉴权服务器与应用服务时间偏差达 4 秒...
[0.1714] T-206 backend open high 2025-11-13
速率限制未按用户区分 —— 中间件使用的是共享 Redis 键而非用户唯一标识...查询 2:按状态和日期过滤
查询文本与前一个相同,但这次我们改变了候选池:只考虑创建于 2025 年 11 月 10 日之前的未关闭工单,模拟某个团队只想查看特定时间段内尚未解决的问题的工作流程。
results = index.search(
"authentication token expiry and session management",
top_k=4,
status="open",
before=date(2025, 11, 10),
)show("same query[status=open, before=2025-11-10]",results)输出:
1
2
3
4
5
6
7
8
9
Query:same query[status=open,before=2025-11-10]
[0.6133]T-207 backend open high 2025-11-03
登出后会话 Cookie 仍然存在——中间件缺少令牌黑名单检查...
[0.4958]T-201 backend open high 2025-11-05
OAuth2 令牌刷新间歇性失败——令牌缓存中的竞态条件导致...
[0.3459]T-203 backend open medium 2025-11-01
JWT 签名验证间歇性失败——签名服务器与验证服务之间存在 4 秒的时钟偏差...
[0.1419]T-202 backend open high 2025-11-09
负载下数据库连接池耗尽——连接池限制为 20 个连接,但实际需求更高...查询 3:跨团队搜索并使用优先级过滤器
资源耗尽可能同时出现在基础设施和后端工单中;无论归属哪个团队,它们在语义上属于同一领域。此查询测试模型是否能正确地跨越团队边界对这些工单进行分组。
results=index.search(
"resource exhaustion and memory pressure under load",
top_k=2,
status="open",
priority="high",
)
show("'resource exhaustion and memory pressure'[status=open, priority=high]",results)输出如下:
1
2
3
4
5
Query:'resource exhaustion and memory pressure'[status=open,priority=high]
[0.3877]T-202 backend open high 2025-11-09
Database connection pool exhausted under load—pool capped at 20 connections but the...
[0.2908]T-101 infrastructure open high 2025-11-03
Kubernetes pod keeps crashing with OOMKilled—memory limits on the ML inference cont...步骤 4:持久化索引
每次启动都重新编码整个语料库就失去了构建索引的意义。正确的做法是一次编码,然后将嵌入矩阵和元数据保存到磁盘,在后续运行中重新加载即可。
import json
# 将嵌入矩阵和工单元数据写入磁盘
np.save("ticket_embeddings.npy",embeddings)
with open("ticket_metadata.json","w") as f:
json.dump(
[{**t,"created":t["created"].isoformat()} for t in tickets],
f, indent=2,
)嵌入矩阵以二进制 .npy 文件形式保存。元数据则保存为 JSON 格式,但在保存前需先将 Python 的 date 对象转换为 ISO 字符串格式。当开启新的会话时,加载过程分为两个阶段:
模型加载(从缓存): SentenceTransformer 模型首先检查本地缓存(例如 .cache/huggingface/hub/)。如果模型已存在于本地,则立即加载;否则,它会从 Hugging Face 下载一次,并存储在本地以便将来避免重复下载。
索引重载(从保存的数据): 已保存的工单嵌入(ticket_embeddings.npy)和元数据(ticket_metadata.json)从磁盘加载。这使得可以瞬间重建 ContextAwareIndex 而无需重新计算嵌入向量,从而节省时间和算力。
from datetime import date
import json
import numpy as np
from sentence_transformers import SentenceTransformer
# 加载嵌入矩阵,反序列化元数据,重建索引
embeddings_loaded = np.load("ticket_embeddings.npy")
with open("ticket_metadata.json") as f:
tickets_loaded = json.load(f)
for t in tickets_loaded:
t["created"] = date.fromisoformat(t["created"])
model = SentenceTransformer("all-MiniLM-L6-v2")
index = ContextAwareIndex(embeddings_loaded, tickets_loaded)
print(f"Reloaded: {embeddings_loaded.shape[0]} docs, {embeddings_loaded.shape[1]}D.")编码步骤只需执行一次。之后每次启动只需要两次文件读取操作以及一次模型缓存加载。
总结
上下文感知的语义搜索结合了以下几个关键部分:一个用于将文本转换成向量的嵌入模型、一种使余弦相似度与点积对齐的归一化方法、一个在评分之前用来筛选候选结果的元数据掩码,以及最后按相似度排序的结果排列步骤。
接下来你可以尝试以下方向:
- 新增文档:使用
model.encode编码新文档,用np.vstack堆叠向量,并追加对应的元数据——不需要重新建立索引。 - 多值元数据过滤器:将团队信息存储为字符串列表,并通过判断
doc["team"]是否包含于该列表来进行过滤。 - 扩展至超过 10 万条记录:采用像 FAISS 这样的近似最近邻索引替代暴力打分方式,而保持原有的元数据预过滤逻辑不变。
- 混合评分机制:将语义信号与关键词匹配信号相结合,形成加权综合得分。
祝你开发顺利!
##### 尚无评论。