RAG 系列 2:本地部署 Qwen3 Embedding 并接入 LangChain


上一篇介绍了向量、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. 本地调用流程

本地 Qwen3 Embedding 调用流程

本文的数据流可以分成五步:

  1. 从 ModelScope 把模型下载到博客仓库之外的固定目录。
  2. 在学习项目根目录建立 embedding_model 符号链接,作为统一模型入口。
  3. 直接调用 SentenceTransformer,或者通过 LangChain 的 HuggingFaceEmbeddings 调用它。
  4. SentenceTransformer 从本地目录加载 Qwen3 Embedding,并使用 Apple MPS 推理。
  5. 模型生成 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 的真实本地部署和验证:

  1. 使用独立 Python 3.12 环境隔离依赖。
  2. 下载并校验 Qwen3-Embedding-0.6B 的 13 个文件。
  3. 在 M1 Max 的 MPS 上生成 1024 维归一化向量。
  4. 使用真实向量完成生活化文本的语义排序。
  5. 使用 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]]

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