RAG 系列 3:使用 Qwen3 Embedding 实现评论数据语义搜索


上一篇已经完成 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. 整体流程

评论数据语义搜索流程

整个程序分为两个阶段。

第一个阶段是离线准备:

  1. 下载原始评论 CSV。
  2. 清洗 Summary 和 Text。
  3. 使用 embed_documents() 批量生成评论向量。
  4. 把评论与向量一起写入新的 CSV。

第二个阶段是在线查询:

  1. 使用 embed_query() 生成查询向量。
  2. 从 CSV 读取评论,将向量字符串还原为 NumPy 数组。
  3. 将查询向量与每条评论向量计算余弦相似度。
  4. 使用 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: 评论正文

在合并之前先做三项处理:

  1. 删除标题或正文为空的记录。
  2. 去掉文本两端空格。
  3. 默认截取前 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 中的向量变成了字符串

向量在 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. 总结

这次完成了一个最小但完整的语义搜索流程:

  1. 使用 Pandas 清洗真实评论。
  2. 使用 LangChain 的 embed_documents() 批量生成文档向量。
  3. 将评论和向量保存到 CSV。
  4. 使用 literal_eval() 还原向量。
  5. 使用 embed_query() 生成查询向量。
  6. 使用 NumPy 计算余弦相似度。
  7. 按分数降序返回 Top-N 评论。

这个案例把 RAG 系列 1 的向量原理和 RAG 系列 2 的本地 Qwen3 Embedding 接口真正组合了起来。同时也能看到,Embedding 解决的是语义表示问题,不会自动解决数据去重、业务过滤和大规模索引问题。


文章作者: hnbian
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 hnbian !
评论
  目录