上一篇介绍了向量、One-Hot 和余弦相似度,但使用的都是手工设置的二维向量。
本文开始使用真正的 Embedding 模型。我们会在 M1 Max 上下载 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
本机真实结果为:
No broken requirements found.
本次最终使用的主要版本为:
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
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。
import os
from pathlib import Path
# 本地下载不经过系统代理,避免 VPN 导致连接中断或下载不完整。
os.environ.setdefault("NO_PROXY", "*")
os.environ.setdefault("no_proxy", "*")
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. 在项目中建立统一模型入口
进入 llm_learning 根目录,建立符号链接:
ln -s \
/Users/bianhn/Documents/git/llm/qwen3-embedding/model \
embedding_model
检查:
ls -l embedding_model
真实结果为:
embedding_model -> /Users/bianhn/Documents/git/llm/qwen3-embedding/model
后面的代码都从 embedding_model 加载模型,不需要在每个文件中重复写外部模型目录。
如果链接已经存在,不要重复执行 ln -s。可以使用 readlink embedding_model 检查它是否指向预期目录;模型目录迁移后,只需要更新这一处链接。
6. 使用 SentenceTransformer 生成向量
先检查 PyTorch 是否支持 Apple MPS:
import torch
print(torch.backends.mps.is_built())
print(torch.backends.mps.is_available())
本机两个结果都是 True,因此可以使用 M1 Max 的 GPU。
代码文件为 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(__file__).resolve().parents[2] / "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)
print("运行设备:", device)
print("返回类型:", type(embeddings).__name__)
print("向量形状:", embeddings.shape)
print("单条向量维度:", len(embeddings[0]))
print("第一条向量前五个值:", embeddings[0][:5].tolist())
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.026383759453892708, 0.017712516710162163,
0.0002087365573970601, 3.1363568268716335e-05,
0.07342326641082764]
第一条向量范数: 1.0
(2, 1024) 表示输入了两条文本,每条文本得到一个 1024 维向量。
设置 normalize_embeddings=True 后,第一条向量的范数为 1.0。后续两个归一化向量的点积就可以作为余弦相似度。
首次独立进程加载模型并生成两条向量耗时 31.14 秒,最大常驻内存为 3,974,922,240 字节,约 3.70 GiB,系统报告 0 swaps。
7. 使用真实向量比较语义
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(__file__).resolve().parents[2] / "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}")
真实输出为:
查询: 我想找一个适合周末散步的地方。
1. 0.5894 西湖边有步道和树荫,很适合慢慢散步。
2. 0.1805 苹果是一种常见的水果。
3. 0.1605 汽车需要定期更换机油和检查轮胎。
查询中没有“西湖”“步道”或“树荫”,模型仍然把散步场景排在第一位。这就是语义向量和关键字机械匹配的重要区别。
模型文件已经进入系统文件缓存后,这个独立进程总耗时为 5.99 秒,最大常驻内存约 3.70 GiB。
8. 接入 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 模型。
# embed_documents() 处理多条文本,embed_query() 处理一条查询。
from pathlib import Path
import torch
from langchain_huggingface import HuggingFaceEmbeddings
MODEL_PATH = Path(__file__).resolve().parents[2] / "embedding_model"
device = "mps" if torch.backends.mps.is_available() else "cpu"
# 文档和查询都做归一化;查询另外使用模型内置的 query prompt。
embeddings_model = HuggingFaceEmbeddings(
model_name=str(MODEL_PATH),
model_kwargs={"device": device},
encode_kwargs={"normalize_embeddings": True},
query_encode_kwargs={
"prompt_name": "query",
"normalize_embeddings": True,
},
)
query = "我想找一个适合周末散步的地方。"
documents = [
"西湖边有步道和树荫,很适合慢慢散步。",
"汽车需要定期更换机油和检查轮胎。",
"苹果是一种常见的水果。",
]
query_vector = embeddings_model.embed_query(query)
document_vectors = embeddings_model.embed_documents(documents)
# 向量已经归一化,因此点积就是余弦相似度。
scores = [
sum(query_value * document_value for query_value, document_value in zip(query_vector, vector))
for vector in document_vectors
]
results = sorted(zip(documents, scores), key=lambda item: item[1], reverse=True)
print("查询向量类型:", type(query_vector).__name__)
print("文档向量类型:", type(document_vectors).__name__)
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。
9. 常见问题
9.1 报错 KeyError: qwen3
通常是 Transformers 版本过旧。Qwen3 Embedding 要求 Transformers 至少支持 Qwen3 架构,本文固定使用 transformers==4.57.6。
9.2 找不到 embedding_model
检查是否在 llm_learning 根目录建立了符号链接:
ls -l embedding_model
链接目标必须是包含 config.json 和 model.safetensors 的模型目录。
9.3 MPS 不可用
脚本会在 MPS 不可用时自动使用 CPU:
device = "mps" if torch.backends.mps.is_available() else "cpu"
CPU 也能运行 0.6B 模型,但速度通常会更慢。不要为了强制使用 MPS 删除 CPU 回退逻辑。
9.4 第一次运行明显更慢
第一次需要读取约 1.19 GB 权重并初始化模型,文件还没有进入系统缓存,因此本次冷启动用了 31.14 秒。后续独立进程缩短到约 6-7 秒,这是正常的文件缓存差异。
9.5 查询和文档应该使用相同参数吗
归一化方式需要保持一致,但 Qwen3 Embedding 官方建议查询端增加任务提示,文档端不增加。本文分别使用 query_encode_kwargs 和 encode_kwargs,不要把 prompt_name=”query” 同时应用到候选文档,也不要让查询向量与文档向量来自不同模型版本。
9.6 能否用 Qwen3-14B 聊天模型代替
不能直接代替。聊天模型主要生成回答,Embedding 模型专门输出可以比较的固定长度向量。二者模型目录和调用方式都不同。
10. 小结
本文已经完成 Qwen3 Embedding 的真实本地部署和验证:
- 使用独立 Python 3.12 环境隔离依赖。
- 下载并校验 Qwen3-Embedding-0.6B 的 13 个文件。
- 在 M1 Max 的 MPS 上生成 1024 维归一化向量。
- 使用真实向量完成生活化文本的语义排序。
- 使用 HuggingFaceEmbeddings 接入 LangChain 标准接口。
| 检查项 | 本机真实结果 |
|---|---|
| 模型 | Qwen/Qwen3-Embedding-0.6B |
| 模型文件数量 | 13 |
| 模型目录大小 | du -sh 显示 1.1G |
| 权重文件大小 | 1,191,586,416 字节 |
| Python | 3.12 |
| 推理设备 | Apple MPS |
| 输出维度 | 1024 |
| 归一化后范数 | 1.0 |
| 冷启动和两条文本编码 | 31.14 秒 |
| 热文件缓存下直接调用 | 5.99 秒 |
| 热文件缓存下 LangChain 调用 | 7.21 秒 |
| 最大常驻内存 | 约 3.70-3.72 GiB |
| Swap | 0 |
| 最相关文本 | 西湖散步文本,余弦相似度 0.5894 |
| embed_query() 返回类型 | list[float] |
| embed_documents() 返回类型 | list[list[float]] |