本文开始使用真正的 Embedding 模型。我们会下载 Qwen/Qwen3-Embedding-0.6B,使用 SentenceTransformer 生成 1024 维向量,比较几句话的语义相似度,最后通过 LangChain 的标准 Embeddings 接口调用同一个本地模型。
整个过程不需要外部模型密钥,也不会启动 HTTP 服务。Python 程序会直接从本地目录加载模型。
1. 为什么选择 Qwen3-Embedding-0.6B
Qwen3 Embedding 系列提供 0.6B、4B 和 8B 等不同规模。模型越大,下载、内存和推理成本通常也越高。
本文选择 0.6B,主要考虑以下几点:
- 适合在个人电脑上学习和反复运行。
- 模型权重约 1.19 GB,下载和加载压力较小。
- 默认输出 1024 维向量。
- 支持中文和英文等多种语言。
- 官方模型目录已经包含 SentenceTransformer 所需的池化、归一化和查询提示配置。
本文使用的模型页为:
需要注意,这是 Embedding 模型,不是前面部署的 Qwen3-14B 聊天模型。它的输出是向量,不能用来生成聊天回答。
2. 本地调用流程

本文的数据流可以分成五步:
- 从 ModelScope 把模型下载到博客仓库之外的固定目录。
- 在学习项目根目录建立 embedding_model 符号链接,作为统一模型入口。
- 直接调用 SentenceTransformer,或者通过 LangChain 的 HuggingFaceEmbeddings 调用它。
- SentenceTransformer 从本地目录加载 Qwen3 Embedding,并使用 Apple MPS 推理。
- 模型生成 1024 维归一化向量,再用于点积或余弦相似度计算。
HuggingFaceEmbeddings 不是另一套模型运行时,它在内部仍然使用 SentenceTransformer 加载同一个本地模型。这个阶段也不需要把模型启动成网络服务:每个 Python 脚本启动时加载模型,生成向量后结束。
3. 为什么使用独立虚拟环境
当前 llm_learning 主环境已经安装 MLX-LM、MLX Whisper 和 Gradio,它的依赖组合需要继续服务前面的示例。
本文固定使用:
langchain==1.0.3
langchain-core==1.0.2
langchain-huggingface==1.0.0
sentence-transformers==5.1.2
transformers==4.57.6
modelscope==1.34.0
torch==2.9.1
numpy==2.2.6
如果直接安装到主环境,pip 可能调整已经安装的 Transformers 和 Hugging Face Hub 版本,影响前面的示例。因此新建 .venv_embedding:
cd source/_posts/llm_learning
python3.12 -m venv \
--prompt llm_learning_embedding \
.venv_embedding
.venv_embedding/bin/python -m pip install -U pip
.venv_embedding/bin/python -m pip install \
-r rag/p02_local_qwen3_embeddings/requirements.txt
安装完成后检查依赖:
.venv_embedding/bin/python -m pip check
4. 使用代码下载模型
模型保存到独立于博客仓库的目录:
/Users/bianhn/Documents/git/llm/qwen3-embedding/model
这样不会把 1 GB 以上的模型权重提交到博客仓库,也不会因为移动学习代码而重复下载模型。
代码文件为 01_download_embedding_model.py:
# 这个文件使用 ModelScope SDK 下载 Qwen3-Embedding-0.6B。
# 模型统一保存到 ~/Documents/git/llm/qwen3-embedding/model。
from pathlib import Path
from modelscope import snapshot_download
MODEL_ID = "Qwen/Qwen3-Embedding-0.6B"
MODEL_REVISION = "6a58e49965123c0a3012d9576414b8c920faef7e"
MODEL_DIR = Path.home() / "Documents/git/llm/qwen3-embedding/model"
MODEL_DIR.mkdir(parents=True, exist_ok=True)
model_dir = snapshot_download(
model_id=MODEL_ID,
revision=MODEL_REVISION,
local_dir=str(MODEL_DIR),
)
print("模型下载完成:", model_dir)
运行:
.venv_embedding/bin/python \
rag/p02_local_qwen3_embeddings/01_download_embedding_model.py
ModelScope SDK 会检查并下载 13 个文件。下载完成后的真实输出为:
Downloading 13 files from Qwen/Qwen3-Embedding-0.6B@6a58e49965123c0a3012d9576414b8c920faef7e
Downloading: 100%|██████████| 13/13
模型下载完成: /Users/bianhn/Documents/git/llm/qwen3-embedding/model
检查目录大小:
du -sh /Users/bianhn/Documents/git/llm/qwen3-embedding/model
本机结果为:
1.1G /Users/bianhn/Documents/git/llm/qwen3-embedding/model
其中 model.safetensors 的实际字节数为 1,191,586,416。网页常用十进制 GB,du -sh 在 macOS 上显示的是约 1.1 GiB,因此显示值会有差别。
4.1 13 个文件分别有什么作用
| 文件 | 作用 |
|---|---|
| .gitattributes | 模型仓库的 Git LFS 和文件属性配置,不参与推理 |
| README.md | 模型说明、使用示例、限制和引用信息 |
| config.json | 模型架构配置,包括 qwen3、隐藏层维度、数据类型和最大位置长度 |
| configuration.json | ModelScope 识别模型任务和框架时使用的补充配置 |
| generation_config.json | Transformers 的生成配置;该模型主要用于向量计算,不是聊天生成 |
| model.safetensors | 约 1.19 GB 的核心模型权重 |
| tokenizer.json | 完整 tokenizer 定义,用于把文本转换成 token |
| tokenizer_config.json | tokenizer 的特殊 token、填充方向和其他参数 |
| vocab.json | tokenizer 使用的词表 |
| merges.txt | BPE tokenizer 的子词合并规则 |
| modules.json | SentenceTransformer 模块顺序,包括 Transformer、Pooling 和 Normalize |
| config_sentence_transformers.json | SentenceTransformer 的 query/document prompt 和相似度配置 |
| 1_Pooling/config.json | 池化配置,当前模型使用最后一个 token 形成 1024 维句向量 |
modules.json 还声明了一个 2_Normalize 模块。它本身没有额外参数文件,因此目录中不需要出现单独的配置文件。
4.2 下载速度过慢时怎么办
本次 ModelScope 下载配置文件很快,但主权重下载到约 341 MB 后速度下降到 0.3-0.5 MB/s。中止进程不会破坏已经下载的部分,临时文件名为:
model.safetensors.incomplete
如果重新运行下载脚本,ModelScope 会继续检查并下载。也可以从 Qwen 官方 Hugging Face 地址断点续传同一个权重文件:
mv \
/Users/bianhn/Documents/git/llm/qwen3-embedding/model/model.safetensors.incomplete \
/Users/bianhn/Documents/git/llm/qwen3-embedding/model/model.safetensors
curl --location --continue-at - \
--output /Users/bianhn/Documents/git/llm/qwen3-embedding/model/model.safetensors \
https://huggingface.co/Qwen/Qwen3-Embedding-0.6B/resolve/c54f2e6e80b2d7b7de06f51cec4959f6b3e03418/model.safetensors
ModelScope 和 Hugging Face 分别使用各自的仓库 revision,因此两个地址中的提交 ID 不相同。这里固定的是 Hugging Face 模型仓库中的权重版本;下载完成后仍要重新运行 ModelScope 下载脚本,让 13 个文件重新接受完整性检查。
本次从 341 MB 继续下载剩余约 811 MB,用时约 56 秒,平均速度约 14.4 MB/s。完成后重新运行 ModelScope 下载脚本,13 个文件全部通过检查。
网络环境不同,ModelScope 和 Hugging Face 的速度也会不同。不要看到进度慢就删除整个目录,优先保留临时文件并尝试续传。
5. 使用 SentenceTransformer 生成向量
代码文件为 02_test_sentence_transformer.py:
# 这个文件使用 SentenceTransformer 直接加载本地 Qwen3 Embedding 模型。
# 程序会打印运行设备、向量形状、维度、前五个数值和向量范数。
from pathlib import Path
import numpy as np
import torch
from sentence_transformers import SentenceTransformer
# embedding_model 是项目根目录中指向真实模型目录的符号链接。
MODEL_PATH = Path("/Users/bianhn/git/llm/qwen3-embedding/model")
device = "mps" if torch.backends.mps.is_available() else "cpu"
model = SentenceTransformer(str(MODEL_PATH), device=device)
texts = [
"今天阳光很好,适合去公园散步。",
"汽车需要定期进行保养。",
]
# normalize_embeddings=True 会把每个向量归一化到长度约为 1。
embeddings = model.encode(texts, normalize_embeddings=True)
# 通常是 ndarray(numpy 数组)
print("返回类型:", type(embeddings).__name__)
# (文本条数, 向量维度),如 (2, 1024)
print("向量形状:", embeddings.shape)
# 模型输出的 Embedding 维度,由模型架构决定
print("单条向量维度:", len(embeddings[0]))
# 浮点分量示例(完整向量有上千维)
print("第一条向量前五个值:", embeddings[0][:5].tolist())
# 归一化后应接近 1.0
print("第一条向量范数:", float(np.linalg.norm(embeddings[0])))
# 向量范数就是向量的「长度」,衡量从原点到该向量终点有多远。
使用 /usr/bin/time -l 同时记录耗时和内存:
/usr/bin/time -l \
.venv_embedding/bin/python \
rag/p02_local_qwen3_embeddings/02_test_sentence_transformer.py
真实输出为:
运行设备: mps
返回类型: ndarray
向量形状: (2, 1024)
单条向量维度: 1024
第一条向量前五个值: [-0.026383737102150917, 0.017712509259581566, 0.0002087596949422732, 3.148262112517841e-05, 0.07342341542243958]
第一条向量范数: 1.0
(2, 1024) 表示输入了两条文本,每条文本得到一个 1024 维向量。
设置 normalize_embeddings=True 后,第一条向量的范数为 1.0。后续两个归一化向量的点积就可以作为余弦相似度。
首次独立进程加载模型并生成两条向量耗时 31.14 秒,最大常驻内存为 3,974,922,240 字节,约 3.70 GiB,系统报告 0 swaps。
6. 使用真实向量比较语义
Qwen3 Embedding 会区分“查询”和“候选文本”。模型目录中的 config_sentence_transformers.json 定义了名为 query 的提示:
Instruct: Given a web search query, retrieve relevant passages that answer the query
Query:
查询端使用这个 prompt,候选文本不增加 prompt。提示只帮助模型理解当前文本是查询,不会出现在最终向量中。
更准确地说,Query Prompt 会参与查询向量的计算,因此会改变最终向量;但它不会作为独立文本保存在向量中,也不能从向量中还原出来。查询和候选文档仍然由同一个模型编码到兼容的 1024 维空间。

图中两条路径的差别只发生在模型输入前:查询端增加任务说明,文档端保持原文。两侧都使用同一个模型并执行相同的归一化,得到向量后才能进行点积或余弦相似度排序。
代码文件为 03_compare_semantic_similarity.py:
# 这个文件使用真实 Qwen3 Embedding 向量比较一句查询和多条文本的语义相似度。
# 查询使用模型内置的 query prompt,候选文本不增加查询提示。
from pathlib import Path
import torch
from sentence_transformers import SentenceTransformer
MODEL_PATH = Path("/Users/bianhn/git/llm/qwen3-embedding/model")
device = "mps" if torch.backends.mps.is_available() else "cpu"
model = SentenceTransformer(str(MODEL_PATH), device=device)
query = "我想找一个适合周末散步的地方。"
documents = [
"西湖边有步道和树荫,很适合慢慢散步。",
"汽车需要定期更换机油和检查轮胎。",
"苹果是一种常见的水果。",
]
# Qwen3 Embedding 官方建议在检索查询一侧使用 query prompt。
query_embedding = model.encode(
[query],
prompt_name="query",
normalize_embeddings=True,
)
document_embeddings = model.encode(
documents,
normalize_embeddings=True,
)
scores = model.similarity(query_embedding, document_embeddings)[0].tolist()
results = sorted(zip(documents, scores), key=lambda item: item[1], reverse=True)
print("查询:", query)
for index, (document, score) in enumerate(results, start=1):
print(f"{index}. {score:.4f} {document}")
# --- 补充演示:encode 传入 str 与 list 时,返回数组形状不同 ---
# 上面用 [query] → 二维数组 (1, 1024),len(...) 为 1(表示 1 条文本)
# 下面用 query(str)→ 一维数组 (1024,), len(...) 为 1024(表示向量维度)
single_query_vector = model.encode(query)
print(single_query_vector) # 一维 ndarray,1024 个浮点数
print(len(single_query_vector)) # 1024
print(single_query_vector.shape) # (1024,) — 对比上方 query_embedding.shape 为 (1, 1024)
真实输出为:
查询: 我想找一个适合周末散步的地方。
1. 0.5894 西湖边有步道和树荫,很适合慢慢散步。
2. 0.1805 苹果是一种常见的水果。
3. 0.1605 汽车需要定期更换机油和检查轮胎。
查询中没有“西湖”“步道”或“树荫”,模型仍然把散步场景排在第一位。这就是语义向量和关键字机械匹配的重要区别。
模型文件已经进入系统文件缓存后,这个独立进程总耗时为 5.99 秒,最大常驻内存约 3.70 GiB。
7. 接入 LangChain Embeddings 接口
直接使用 SentenceTransformer 已经可以生成向量,但后续 LangChain 组件期望接收统一的 Embeddings 对象。
LangChain 的 Embeddings 接口把不同厂商和不同本地模型统一为两个核心方法:
embed_query(text: str) -> list[float]
embed_documents(texts: list[str]) -> list[list[float]]
两者不是重复方法。embed_query() 面向一条查询,可以增加查询任务提示;embed_documents() 面向一批候选文本,不应套用查询端提示。上层代码只依赖这两个方法,不需要知道底层使用的是 Qwen3、OpenAI 还是其他 Embedding 模型。
langchain-huggingface==1.0.0 提供 HuggingFaceEmbeddings,并支持分别设置:
- encode_kwargs:文档编码参数。
- query_encode_kwargs:查询编码参数。
因此不需要再手写一个继承 Embeddings 的包装类。
代码文件为 04_test_langchain_embeddings.py:
# 这个文件演示如何通过 LangChain 的标准 Embeddings 接口使用本地 Qwen3 模型。
#
# 与 03 的区别:03 直接调用 SentenceTransformer;这里用 LangChain 的 HuggingFaceEmbeddings 包装,
# 后续 RAG 阶段(Chroma、向量检索链)会复用同一套 embed_documents / embed_query 接口。
#
# embed_documents() → 批量向量化知识库文档
# embed_query() → 向量化用户检索问题(Qwen3 会自动加 query prompt)
from pathlib import Path
import torch
from langchain_huggingface import HuggingFaceEmbeddings
MODEL_PATH = Path("/Users/bianhn/git/llm/qwen3-embedding/model")
device = "mps" if torch.backends.mps.is_available() else "cpu"
# HuggingFaceEmbeddings 是 LangChain 对 SentenceTransformer / HF 模型的统一封装。
embeddings_model = HuggingFaceEmbeddings(
model_name=str(MODEL_PATH),
model_kwargs={"device": device},
# 文档侧:普通 encode,向量归一化到长度约 1。
encode_kwargs={"normalize_embeddings": True},
# 查询侧:额外使用 Qwen3 内置的 query prompt(与 03 的 prompt_name="query" 等价)。
query_encode_kwargs={
"prompt_name": "query",
"normalize_embeddings": True,
},
)
query = "我想找一个适合周末散步的地方。"
documents = [
"西湖边有步道和树荫,很适合慢慢散步。",
"汽车需要定期更换机油和检查轮胎。",
"苹果是一种常见的水果。",
]
# embed_documents:把知识库里的多条文档批量转成向量,入库或检索时与 query 配对使用。
# 走 encode_kwargs(无 query prompt),返回 list[list[float]],外层每条文档对应一个向量。
document_vectors = embeddings_model.embed_documents(documents)
# embed_query:把用户检索问题转成一条向量,供后续和文档向量比相似度。
# 会走 query_encode_kwargs(含 query prompt),返回 list[float]。
query_vector = embeddings_model.embed_query(query)
def dot_product(vector_a: list[float], vector_b: list[float]) -> float:
"""计算两个向量的点积;向量已归一化时,结果等于余弦相似度。"""
total = 0.0
for a, b in zip(vector_a, vector_b):
total += a * b
return total
# 逐条文档计算与查询的点积(与 03 的 model.similarity 结果一致)。
scores = []
for document_vector in document_vectors:
score = dot_product(query_vector, document_vector)
scores.append(score)
# 把文档和分数配对,按相似度从高到低排序。
results = list(zip(documents, scores))
results.sort(key=lambda item: item[1], reverse=True)
print("查询向量类型:", type(query_vector).__name__) # list
print("文档向量类型:", type(document_vectors).__name__) # list,外层每个元素对应一条文档
print("查询向量维度:", len(query_vector))
print("文档数量:", len(document_vectors))
for index, (document, score) in enumerate(results, start=1):
print(f"{index}. {score:.4f} {document}")
真实输出为:
查询向量类型: list
文档向量类型: list
查询向量维度: 1024
文档数量: 3
1. 0.5894 西湖边有步道和树荫,很适合慢慢散步。
2. 0.1805 苹果是一种常见的水果。
3. 0.1605 汽车需要定期更换机油和检查轮胎。
LangChain 结果和直接使用 SentenceTransformer 的结果一致,但返回类型发生了变化:
- embed_query() 返回 list[float]。
- embed_documents() 返回 list[list[float]]。
LangChain 使用统一接口隐藏了底层模型差异。以后更换 Embedding 提供商时,上层代码仍然围绕这两个方法工作。
这个脚本实测总耗时为 7.21 秒,最大常驻内存约 3.72 GiB,系统报告 0 swaps。
8. 常见问题
8.1 第一次运行明显更慢
第一次需要读取约 1.19 GB 权重并初始化模型,文件还没有进入系统缓存,因此本次冷启动用了 31.14 秒。后续独立进程缩短到约 6-7 秒,这是正常的文件缓存差异。
8.2 查询和文档应该使用相同参数吗
归一化方式需要保持一致,但 Qwen3 Embedding 官方建议查询端增加任务提示,文档端不增加。本文分别使用 query_encode_kwargs 和 encode_kwargs,不要把 prompt_name=”query” 同时应用到候选文档,也不要让查询向量与文档向量来自不同模型版本。
8.3 能否用 Qwen3-14B 聊天模型代替
不能直接代替。聊天模型主要生成回答,Embedding 模型专门输出可以比较的固定长度向量。二者模型目录和调用方式都不同。
9. 小结
本文已经完成 Qwen3 Embedding 的真实本地部署和验证:
- 使用独立 Python 3.12 环境隔离依赖。
- 下载并校验 Qwen3-Embedding-0.6B 的 13 个文件。
- 使用真实向量完成生活化文本的语义排序。
- 使用 HuggingFaceEmbeddings 接入 LangChain 标准接口。