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


本文开始使用真正的 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. 本地调用流程

本地 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

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 的真实本地部署和验证:

  1. 使用独立 Python 3.12 环境隔离依赖。
  2. 下载并校验 Qwen3-Embedding-0.6B 的 13 个文件。
  3. 使用真实向量完成生活化文本的语义排序。
  4. 使用 HuggingFaceEmbeddings 接入 LangChain 标准接口。

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