上一篇已经完成 Qwen3-Embedding-0.6B 的本地部署,也分别使用了 LangChain 的 embed_documents() 和 embed_query()。前文只比较了几句话,这一篇把这些知识放进一个更接近真实使用场景的案例中。
我们会下载 1000 条美食评论,从中取 100 条生成向量,然后输入中文或英文问题,找出语义最接近的 3 条评论。
这个案例只使用 CSV、Embedding 和余弦相似度。它不是 RAG,也没有使用 FAISS、Chroma、Retriever 或其他向量数据库。
1. 关键词搜索和语义搜索有什么区别
假设评论中写的是:
My kids love these snacks. They are healthier than traditional snack bars.
用户搜索的是:
适合孩子吃的健康零食
两段文本的语言不同,也没有完全相同的单词。普通关键词匹配很难把它们联系起来,但 Embedding 模型可以分别把评论和查询转换成向量,再比较两个向量在语义空间中的方向。
因此,语义搜索比较的不是“文字是否一样”,而是“表达的意思是否接近”。
2. 整体流程

整个程序分为两个阶段。
第一个阶段是离线准备:
- 下载原始评论 CSV。
- 清洗 Summary 和 Text。
- 使用 embed_documents() 批量生成评论向量。
- 把评论与向量一起写入新的 CSV。
第二个阶段是在线查询:
- 使用 embed_query() 生成查询向量。
- 从 CSV 读取评论,将向量字符串还原为 NumPy 数组。
- 将查询向量与每条评论向量计算余弦相似度。
- 使用 nlargest() 取得分数最高的 3 条评论。
这里的“在线查询”指程序收到问题时执行的查询阶段,不是说程序需要调用外网服务。本文的模型和评论数据都保存在本机。
评论向量不需要在每次查询时重新生成。只要原始评论、文本合并方式、Embedding 模型版本、文档端编码配置和归一化配置都没有改变,就可以重复使用已经生成的向量文件。
3. 准备运行环境
本篇继续使用 RAG 系列 2 创建的 .venv_embedding,不再重复下载 Qwen/Qwen3-Embedding-0.6B。
目录中需要存在模型入口:
llm_learning/embedding_model
进入项目并安装本文依赖:
cd source/_posts/llm_learning
source .venv_embedding/bin/activate
python -m pip install \
-r rag/p03_review_semantic_search/requirements.txt
python -m pip check
requirements.txt 的内容为:
-r ../p02_local_qwen3_embeddings/requirements.txt
pandas==2.3.3
numpy==2.2.6
这里复用 RAG 系列 2 的 LangChain、SentenceTransformer 和 Transformers 版本,只增加数据处理所需的 Pandas 与 NumPy。
本机检查结果为:
No broken requirements found.
4. 准备数据
4.1. 下载数据
本文使用 OpenAI Cookbook 中的 Fine Food Reviews 数据。为了避免上游文件发生变化,下载地址固定到了具体 Git 提交,并使用 SHA-256 校验文件内容。
代码文件为 01_download_review_data.py:
curl -L -o fine_food_reviews_1k.csv \
"https://raw.githubusercontent.com/openai/openai-cookbook/2515ddc7b8905e56160693e1a1fe08a6683436e4/examples/data/fine_food_reviews_1k.csv"
4.2. 数据分析
CSV 中真正需要使用的字段如下:
| 字段 | 含义 |
|---|---|
| Time | 评论时间戳 |
| ProductId | 商品编号 |
| UserId | 用户编号 |
| Score | 评分,取值为 1 到 5 |
| Summary | 评论标题或摘要 |
| Text | 评论正文 |
原始 CSV 的第一列没有字段名,它保存的是原数据行号。因此 Pandas 读取时使用:
source_df = pd.read_csv(SOURCE_FILE, index_col=0)
这样第一列会成为 DataFrame 索引,不会被误当成业务字段。
4.3. 清洗并合并评论文本
一条评论的语义同时存在于标题和正文中。只使用标题可能丢失细节,只使用正文又可能忽略总结,所以把两者合并成统一文本:
Summary: 评论标题; Text: 评论正文
在合并之前先做三项处理:
- 删除标题或正文为空的记录。
- 去掉文本两端空格。
- 默认截取前 100 条,降低学习和重复测试的等待时间。
将 REVIEW_LIMIT 从 100 改为 1000,就可以处理完整数据集。
5. 批量生成评论向量
代码文件为 02_build_review_embeddings.py:
# 这个文件读取美食评论,清洗文本并批量生成评论向量。
# 生成结果会写入新的 CSV,供 03_search_review_embeddings.py 直接读取。
#
# 前置步骤:
# 1. RAG p02 部署 Qwen3 Embedding 模型
# 2. 01_download_review_data.py 下载 fine_food_reviews_1k.csv
#
# 流程:读 CSV → 清洗 → 合并 Summary+Text → embed_documents → 写入带 embedding 列的新 CSV
from pathlib import Path
from time import perf_counter
import pandas as pd
import torch
from langchain_huggingface import HuggingFaceEmbeddings
MODEL_PATH = Path("/Users/bianhn/git/llm/qwen3-embedding/model")
DATA_DIR = Path("/Users/bianhn/git/llm")
SOURCE_FILE = DATA_DIR / "fine_food_reviews_1k.csv"
OUTPUT_FILE = DATA_DIR / "reviews_with_qwen3_embeddings.csv"
# 默认处理前 100 条,便于学习和测试;改成 1000 可以处理完整数据集。
REVIEW_LIMIT = 100
if not MODEL_PATH.exists():
raise FileNotFoundError("没有找到 embedding_model,请先完成 RAG 系列 2 的模型部署。")
if not SOURCE_FILE.exists():
raise FileNotFoundError("没有找到评论数据,请先运行 01_download_review_data.py。")
device = "mps" if torch.backends.mps.is_available() else "cpu"
# embed_documents 走 encode_kwargs:批量把评论文本转成向量,并归一化到长度约 1。
# query_encode_kwargs 在此脚本中不会用到,但配置与 03 保持一致,便于两边模型行为相同。
embeddings_model = HuggingFaceEmbeddings(
model_name=str(MODEL_PATH),
model_kwargs={"device": device},
encode_kwargs={"normalize_embeddings": True, "batch_size": 8},
query_encode_kwargs={
"prompt_name": "query",
"normalize_embeddings": True,
},
)
# 第一列是原数据保存的行号,因此使用 index_col=0 读取。
source_df = pd.read_csv(SOURCE_FILE, index_col=0)
reviews_df = source_df[["Time", "ProductId", "UserId", "Score", "Summary", "Text"]].copy()
# 摘要或正文为空时无法形成完整评论文本,先删除再截取指定数量。
reviews_df = reviews_df.dropna(subset=["Summary", "Text"])
reviews_df["Summary"] = reviews_df["Summary"].astype(str).str.strip()
reviews_df["Text"] = reviews_df["Text"].astype(str).str.strip()
reviews_df = reviews_df[(reviews_df["Summary"] != "") & (reviews_df["Text"] != "")]
reviews_df = reviews_df.head(REVIEW_LIMIT).copy()
# 将摘要和正文合并成一段文本,让一个向量同时表达标题和正文的语义。
reviews_df["text_content"] = (
"Summary: " + reviews_df["Summary"] + "; Text: " + reviews_df["Text"]
)
# embed_documents:一次传入多条 text_content,返回 list[list[float]],每条评论一个 1024 维向量。
start_time = perf_counter()
vectors = embeddings_model.embed_documents(reviews_df["text_content"].tolist())
elapsed = perf_counter() - start_time
# Pandas 写入 CSV 时会把 list[float] 序列化成字符串,03 读取时用 literal_eval 还原。
reviews_df["embedding"] = vectors
reviews_df.to_csv(OUTPUT_FILE, index=False)
# 归一化后首条向量范数应接近 1.0,用于确认 embed 配置是否生效。
first_vector_norm = sum(value * value for value in vectors[0]) ** 0.5
print("运行设备:", device)
print("原始记录数:", len(source_df))
print("生成向量数:", len(vectors))
print("向量维度:", len(vectors[0]))
print(f"首条向量范数:{first_vector_norm:.6f}")
print(f"向量生成耗时:{elapsed:.2f} 秒")
print(f"输出文件大小:{OUTPUT_FILE.stat().st_size / 1024 / 1024:.2f} MiB")
print("输出文件:", OUTPUT_FILE)
运行:
python rag/p03_review_semantic_search/02_build_review_embeddings.py
输出为:
运行设备: mps
原始记录数: 1000
生成向量数: 100
向量维度: 1024
首条向量范数:1.000000
向量生成耗时:6.25 秒
输出文件大小:2.25 MiB
输出文件: /Users/bianhn/git/llm/reviews_with_qwen3_embeddings.csv
按照 LangChain Embeddings 接口的划分,embed_documents() 接收多条文本,因此输入类型是 list[str],返回类型是 list[list[float]]。100 条评论会得到 100 个向量,每个向量包含 1024 个浮点数。
normalize_embeddings=True 把向量归一化到长度约为 1。实测首条向量范数为 1.000000。
5.1. 为什么 CSV 中的向量变成了字符串

在 Python 中,一条向量是浮点数列表:
[0.0123, -0.0456, 0.0789]
CSV 只有行、列和文本单元格,并没有“浮点数列表”这种字段类型。Pandas 写入 CSV 后,整个列表会变成一段字符串:
"[0.0123, -0.0456, 0.0789]"
重新读取时不能直接参与 NumPy 运算,需要先还原:
from ast import literal_eval
vector = literal_eval("[0.0123, -0.0456, 0.0789]")
不要使用 eval() 解析外部数据。literal_eval() 只接受 Python 的基础字面量,风险更小。即便如此,生产系统仍应保证数据文件来源可信。
5.2. 计算余弦相似度
查询向量为 (A),评论向量为 (B),余弦相似度公式为:
$$
\cos(\theta) = \frac{A \cdot B}{\lVert A \rVert \lVert B \rVert}
$$
对应代码:
def cosine_similarity(vector_a: np.ndarray, vector_b: np.ndarray) -> float:
denominator = np.linalg.norm(vector_a) * np.linalg.norm(vector_b)
if denominator == 0:
raise ValueError("余弦相似度不能用于零向量。")
return float(np.dot(vector_a, vector_b) / denominator)
当前查询向量和评论向量都已经归一化。理论上两个向量的模长都是 1,此时点积就等于余弦相似度。不过这里仍保留完整公式,便于理解,也提醒关闭归一化后需要同步修改计算方法。
5.3. 使用 embed_query() 生成查询向量
评论使用 embed_documents(),查询使用 embed_query()。两者调用的是同一个模型,但 Qwen3 Embedding 为查询端提供了专门的 query prompt:
query_encode_kwargs={
"prompt_name": "query",
"normalize_embeddings": True,
}
文档端不应使用查询提示。否则生成向量时,查询和文档的角色会混在一起,可能降低检索效果。
还需要满足两个条件:
- 查询和评论必须使用同一个 Embedding 模型。
- 两边必须使用兼容的维度、归一化和提示配置。
不同模型生成的向量通常不在同一个向量空间中,不能直接计算相似度。
6. 完整搜索代码
代码文件为 03_search_review_embeddings.py:
# 这个文件把用户问题转换成向量,再从评论数据中找出语义最接近的内容。
#
# 前置步骤:
# 1. 下载评论 CSV
# 2. 为每条评论生成向量并写入 CSV
#
# 流程:
# 用户 query → embed_query 转向量
# → 与 CSV 里每条评论的 embedding 算余弦相似度
# → 取 similarity 最高的 top_n 条打印
#
# 与 02 的分工:02 离线算好「文档向量」;本文件在线只算「查询向量」,再逐条比对。
# 本文件使用完整的余弦相似度公式,方便理解语义搜索(向量已归一化时,点积即余弦相似度)。
from ast import literal_eval
from pathlib import Path
import numpy as np
import pandas as pd
import torch
from langchain_huggingface import HuggingFaceEmbeddings
MODEL_PATH = Path("/Users/bianhn/git/llm/qwen3-embedding/model")
DATA_DIR = Path("/Users/bianhn/git/llm")
# 02 输出的 CSV:每行评论附带一列 embedding(字符串形式的 1024 维向量)。
DATA_FILE = DATA_DIR / "reviews_with_qwen3_embeddings.csv"
if not DATA_FILE.exists():
raise FileNotFoundError("没有找到评论向量文件,请先运行 02_build_review_embeddings.py。")
device = "mps" if torch.backends.mps.is_available() else "cpu"
# embed_query 走 query_encode_kwargs(含 query prompt),与 02 里 embed_documents 的文档侧不同。
embeddings_model = HuggingFaceEmbeddings(
model_name=str(MODEL_PATH),
model_kwargs={"device": device},
encode_kwargs={"normalize_embeddings": True, "batch_size": 8},
query_encode_kwargs={
"prompt_name": "query",
"normalize_embeddings": True,
},
)
def parse_embedding(cell_value: str) -> np.ndarray:
"""把 CSV 里存的向量字符串还原成 numpy 数组。
02 写入 CSV 时,Pandas 会把 list[float] 转成字符串,例如 "[0.1, 0.2, ...]"。
literal_eval 可以安全地把这种字符串解析回 Python 列表(比 eval 更安全)。
"""
number_list = literal_eval(cell_value)
return np.asarray(number_list, dtype=np.float32)
def cosine_similarity(vector_a: np.ndarray, vector_b: np.ndarray) -> float:
"""余弦相似度 = 点积 / (|A| × |B|),取值约 -1 到 1,越大越相似。
02、03 中向量均已 normalize_embeddings=True,理论上可直接用点积;
这里仍写完整公式,便于对照 p01 的学习示例。
"""
length_a = np.linalg.norm(vector_a)
length_b = np.linalg.norm(vector_b)
if length_a == 0 or length_b == 0:
raise ValueError("余弦相似度不能用于零向量。")
dot_product = np.dot(vector_a, vector_b)
return float(dot_product / (length_a * length_b))
def load_reviews_with_vectors() -> pd.DataFrame:
"""读取带 embedding 列的评论表,并新增 embedding_vector 列(numpy 数组)。
按 CSV 行顺序逐条解析;第 i 个 embedding 仍对应第 i 行评论(与 02 写入时一致)。
"""
reviews_df = pd.read_csv(DATA_FILE)
embedding_vectors = []
for cell_value in reviews_df["embedding"]:
embedding_vectors.append(parse_embedding(cell_value))
reviews_df["embedding_vector"] = embedding_vectors
return reviews_df
def shorten_text(text: str, max_length: int = 220) -> str:
"""合并多余空白并截断,避免终端被一整篇评论占满。"""
cleaned = " ".join(str(text).split())
if len(cleaned) <= max_length:
return cleaned
return cleaned[:max_length] + "..."
def search_reviews(query: str, reviews_df: pd.DataFrame, top_n: int = 3) -> None:
"""对一条 query 做语义检索,打印相似度最高的前 top_n 条评论。
参数:
query 用户检索问题(中/英文均可)
reviews_df 含 embedding_vector 列的评论表
top_n 返回几条最相似的结果
"""
# embed_query:只把当前这一条问题转成 1024 维向量。
query_vector = np.asarray(embeddings_model.embed_query(query), dtype=np.float32)
# 暴力扫描:每条评论向量都与 query_vector 算一次余弦相似度(数据量大时会改用向量数据库)。
similarities = []
for document_vector in reviews_df["embedding_vector"]:
score = cosine_similarity(document_vector, query_vector)
similarities.append(score)
# copy() 避免在共用 reviews_table 时把 similarity 列写回全局缓存。
reviews_with_scores = reviews_df.copy()
reviews_with_scores["similarity"] = similarities
# nlargest:按 similarity 列降序取前 top_n 行。
top_results = reviews_with_scores.nlargest(top_n, "similarity")
print(f"\n查询:{query}")
for rank, (_, row) in enumerate(top_results.iterrows(), start=1):
print(f"{rank}. 相似度:{row['similarity']:.4f},评分:{int(row['Score'])}")
print(" 标题:", row["Summary"])
print(" 评论:", shorten_text(row["Text"]))
# 评论表只加载一次,下面三条 query 共用,避免重复读 CSV 和解析 embedding。
reviews_table = load_reviews_with_vectors()
# 三条示例 query:英文、中文、英文,用于观察跨语言语义检索效果。
queries = [
"delicious beans",
"适合孩子吃的健康零食",
"I like juicy barbecued meat.",
]
for query_text in queries:
search_reviews(query_text, reviews_table)
运行:
python rag/p03_review_semantic_search/03_search_review_embeddings.py
6.1 查询 delicious beans
查询:delicious beans
1. 相似度:0.5121,评分:5
标题: Delicious!
评论: I enjoy this white beans seasoning, it gives a rich flavor to the beans I just love it, my mother in law didn't know about this Zatarain's brand and now she is traying different seasoning and she likes it very much.<br /...
2. 相似度:0.4874,评分:3
标题: This price is ridiculous!
评论: There's nothing wrong with variety, except when it comes at too high a price. If you want variety, go in with a friend on a few of the 9 bags for $26.01 deals that are offered for different flavors of Beanitos. My favori...
3. 相似度:0.3984,评分:5
标题: yummy
评论: these are the best super yummy and i never get sick of the flavor. a little bit pricey but not filled with sugar
第一条评论正文明确提到了 white beans 和 seasoning,与查询语义一致。第二条虽然标题看不出来,但正文讨论了不同口味的 Beanitos,因此也得到了较高分数。
6.2 查询适合孩子吃的健康零食
查询:适合孩子吃的健康零食
1. 相似度:0.6257,评分:5
标题: Yum
评论: My kids love these Earnest Eats snacks. I like that they are more nutritional and healthier that traditional snack bars in the grocery store. I also admire the use non-oil alternative products such as almond butter and n...
2. 相似度:0.5089,评分:5
标题: Woody and Arlo's favorites!
评论: My two dogs LOVE these! I cut each strip into bite-sized six pieces. I like the fact that they are quite a healthy treat and low fat.<br /><a href="http://www.amazon.com/gp/product/B002UV2V7C">Healthy Partner Pet Snacks ...
3. 相似度:0.5026,评分:5
标题: Tasty, and the yogurt coating is a nice addition
评论: While I like yogurt out of a cup, I'm usually not fond of things that are dipped in yogurt. These are the exception - the yogurt coating on the bottom adds a very nice taste to the granola. Sweet, but not candy bar sweet...
第一条英文评论写到孩子喜欢这些零食,而且比传统零食棒更健康。查询是中文,评论是英文,仍然排在第一位,说明模型能够进行跨语言语义匹配。
第二条实际是健康、低脂的宠物零食。它命中了“健康零食”的含义,却不满足“给孩子吃”这个业务条件。这是单纯向量相似度的典型局限。
12.3 查询 I like juicy barbecued meat.
查询:I like juicy barbecued meat.
1. 相似度:0.5491,评分:5
标题: Makes me drool just thinking of them
评论: The Brit's have out done us. The flavor is supreme,they satisfy my hunger for steak and onions...<br />Get them while you can... Their other flavors are great tooo
2. 相似度:0.5491,评分:5
标题: Makes me drool just thinking of them
评论: The Brit's have out done us. The flavor is supreme,they satisfy my hunger for steak and onions...<br />Get them while you can... Their other flavors are great tooo
3. 相似度:0.4694,评分:5
标题: Tasty and Quick Pasta
评论: Barilla Whole Grain Fusilli with Vegetable Marinara is tasty and has an excellent chunky vegetable marinara. I just wish there was more of it. If you aren't starving or on a diet, the 9oz serving is enough for lunch alth...
前两条内容相同,正文提到了 steak and onions,因此与肉类查询接近。重复结果不是计算错误,而是原始 100 条数据中存在重复评论。
7. 总结
这次完成了一个最小但完整的语义搜索流程:
- 使用 Pandas 清洗真实评论。
- 使用 LangChain 的 embed_documents() 批量生成文档向量。
- 将评论和向量保存到 CSV。
- 使用 literal_eval() 还原向量。
- 使用 embed_query() 生成查询向量。
- 使用 NumPy 计算余弦相似度。
- 按分数降序返回 Top-N 评论。
这个案例把 RAG 系列 1 的向量原理和 RAG 系列 2 的本地 Qwen3 Embedding 接口真正组合了起来。同时也能看到,Embedding 解决的是语义表示问题,不会自动解决数据去重、业务过滤和大规模索引问题。