RAG实战(七)使用 LlamaIndex + Qwen 微调 BCE 中文嵌入模型
基于本地 PDF 文档,利用 Qwen 大模型自动生成问答对,并微调开源中文嵌入模型 bce-embedding-base_v1,打造适用于特定领域的高精度语义检索系统。:全程使用(Qwen + BCE),无需联网调用 API;完整支持中文场景;适配 LlamaIndex RAG 流程。
使用 LlamaIndex + Qwen 微调 BCE 中文嵌入模型
—— 从本地 PDF 到领域适配的高质量向量检索
目标:基于本地 PDF 文档,利用 Qwen 大模型自动生成问答对,并微调开源中文嵌入模型
bce-embedding-base_v1,打造适用于特定领域的高精度语义检索系统。
亮点:全程使用本地模型(Qwen + BCE),无需联网调用 API;完整支持中文场景;适配 LlamaIndex RAG 流程。
📦 环境准备
确保已安装以下依赖(建议在 Python 3.9+ 环境中运行):
pip install llama-index sentence-transformers transformers torch modelscope pypdf accelerate
💡 若使用 Jupyter Notebook,请在单元格中以
!pip install ...安装。
第一步:下载 BCE 嵌入模型到本地
BCE(Bidirectional Cosine Embedding)是由 maidalun 开发的高性能中文嵌入模型,在 MTEB 中文榜单表现优异。我们先将其从 ModelScope 下载至本地。
import os
from modelscope import snapshot_download
# 模型存储目录
local_models_dir = "./Models"
os.makedirs(local_models_dir, exist_ok=True)
# 下载 BCE 模型
model_id = "maidalun/bce-embedding-base_v1"
bce_raw_path = snapshot_download(model_id, cache_dir=local_models_dir)
print(f"✅ BCE 原始模型已下载至: {bce_raw_path}")
第二步:将 BCE 转换为 Sentence Transformers 格式(关键!)
LlamaIndex 的微调引擎 SentenceTransformersFinetuneEngine 仅支持标准 Sentence Transformers 模型结构。而 BCE 默认发布格式不兼容,需手动转换。
from sentence_transformers import SentenceTransformer, models
def convert_bce_to_st_format(bce_path: str, output_path: str):
"""
将 BCE 模型转换为标准 SentenceTransformer 格式
BCE 使用 [CLS] token 作为句向量,因此 pooling_mode_cls_token=True
"""
# 构建 Transformer + Pooling 结构
transformer = models.Transformer(bce_path)
pooling = models.Pooling(
transformer.get_word_embedding_dimension(),
pooling_mode_cls_token=True, # ✅ BCE 使用 [CLS]
pooling_mode_mean_tokens=False,
pooling_mode_max_tokens=False
)
# 组装并保存
model = SentenceTransformer(modules=[transformer, pooling])
model.save(output_path)
print(f"✅ 已转换为 SentenceTransformer 格式并保存至: {output_path}")
# 执行转换
st_bce_path = "./Models/bce-embedding-base_v1-st"
convert_bce_to_st_format(bce_raw_path, st_bce_path)
✅ 转换后,该模型即可被
SentenceTransformersFinetuneEngine正常加载和微调。
第三步:加载并切分本地 PDF 数据
所有 PDF 文件存放在 Dataset/PDF/ 目录下。
import os
import random
from langchain_community.document_loaders import PyPDFLoader
from llama_index.core import Document
from llama_index.core.node_parser import SentenceSplitter as LlamaSentenceSplitter
# === 文本清洗函数 ===
def clean_text(text: str) -> str:
"""移除非法字符、多余换行和首尾空白"""
return text.encode('utf-8', errors='ignore').decode('utf-8').strip('\n\n').strip('\n').strip()
# === 第一步:用 LangChain 加载 PDF 并清洗文本 ===
def load_docs_with_langchain(pdf_files):
all_langchain_docs = []
for file_path in pdf_files:
print(f"📖 正在加载: {os.path.basename(file_path)}")
loader = PyPDFLoader(file_path)
docs = loader.load()
# 清洗每一页的文本内容
for doc in docs:
doc.page_content = clean_text(doc.page_content)
if doc.page_content:
all_langchain_docs.append(doc)
return all_langchain_docs
# === 第二步:转换为 LlamaIndex Document(文本已清洗)===
def convert_langchain_to_llama_docs(langchain_docs):
llama_docs = []
for doc in langchain_docs:
llama_doc = Document(
text=doc.page_content,
metadata={
"source": doc.metadata.get("source", ""),
"page": doc.metadata.get("page", None),
}
)
llama_docs.append(llama_doc)
return llama_docs
# === 主流程 ===
PDF_DIR = "Dataset/PDF"
pdf_files = [os.path.join(PDF_DIR, f) for f in os.listdir(PDF_DIR) if f.endswith('.pdf')]
random.seed(42)
random.shuffle(pdf_files)
split_idx = int(len(pdf_files) * 0.7)
train_files = pdf_files[:split_idx]
val_files = pdf_files[split_idx:]
print(f"📚 总 PDF 数: {len(pdf_files)} | 训练: {len(train_files)} | 验证: {len(val_files)}")
# 加载并转换
print("\n🔄 正在用 LangChain 加载训练集 PDF...")
train_langchain_docs = load_docs_with_langchain(train_files)
train_llama_docs = convert_langchain_to_llama_docs(train_langchain_docs)
print("\n🔄 正在用 LangChain 加载验证集 PDF...")
val_langchain_docs = load_docs_with_langchain(val_files)
val_llama_docs = convert_langchain_to_llama_docs(val_langchain_docs)
# 使用 LlamaIndex 切分器
parser = LlamaSentenceSplitter(chunk_size=300, chunk_overlap=30)
train_nodes = parser.get_nodes_from_documents(train_llama_docs, show_progress=True)
val_nodes = parser.get_nodes_from_documents(val_llama_docs, show_progress=True)
print(f"\n✂️ 训练文本块: {len(train_nodes)} | 验证文本块: {len(val_nodes)}")
输出如下:
📚 总 PDF 数: 2 | 训练: 1 | 验证: 1
🔄 正在用 LangChain 加载训练集 PDF...
📖 正在加载: 基于视触觉融合的机械手分类抓取方法研究_余航.pdf
Multiple definitions in dictionary at byte 0x35029d for key /MediaBox
🔄 正在用 LangChain 加载验证集 PDF...
📖 正在加载: 基于触视觉融合的机器人目标识别和滑动检测研究_林为梁.pdf
Parsing nodes: 100%
67/67 [00:00<00:00, 645.76it/s]
Parsing nodes: 100%
71/71 [00:00<00:00, 576.79it/s]
✂️ 训练文本块: 252 | 验证文本块: 310
第四步:使用本地 Qwen 模型生成问答对
使用本地部署的 Qwen-8B 模型,为每个文本块生成 1 个问题,构建 QA 数据集。
首先加载模型:
from typing import Any
from vllm import LLM, SamplingParams
from llama_index.core.llms import (
CustomLLM,
CompletionResponse,
CompletionResponseGen,
LLMMetadata,
)
from llama_index.core.llms.callbacks import llm_completion_callback
from modelscope import snapshot_download
import os
import re
class QWEN3(CustomLLM):
"""使用vLLM加载的Qwen3模型"""
context_window: int = 1000
num_output: int = 512
model_name: str = "Qwen3-8B-vLLM"
# 添加字段定义
llm: Any = None
sampling_params: Any = None
def __init__(self, model_path: str = "Qwen/Qwen3-8B", **kwargs):
super().__init__()
print(f"🚀 使用vLLM加载Qwen3-8B模型...")
# 下载模型
local_path = snapshot_download(model_path, cache_dir="Models")
print(f"✅ 模型下载完成: {local_path}")
# 使用vLLM加载模型
self.llm = LLM(
model=local_path,
trust_remote_code=True,
tensor_parallel_size=1,
gpu_memory_utilization=0.9,
max_model_len=1000
)
# 配置采样参数
self.sampling_params = SamplingParams(
temperature=0.2,
top_p=0.9,
max_tokens=60,
repetition_penalty=1.2
)
print("✅ vLLM模型加载成功!")
def extract_first_question(self, text: str) -> str:
"""提取第一个问号及其之前的内容"""
if not text:
return ""
# 方法1:使用正则表达式找到第一个问号及其前面的内容
match = re.search(r'^[^??]*[??]', text)
if match:
return match.group(0).strip()
# 方法2:如果没找到问号,找到第一个句子
sentences = re.split(r'[。!;]', text)
if sentences:
return sentences[0].strip() + "?" # 添加问号
return text.strip()
@property
def metadata(self) -> LLMMetadata:
return LLMMetadata(
context_window=self.context_window,
num_output=self.num_output,
model_name=self.model_name,
)
@llm_completion_callback()
def complete(self, prompt: str, **kwargs: Any) -> CompletionResponse:
# 动态调整采样参数
sampling_params = SamplingParams(
temperature=kwargs.get('temperature', 0.5),
top_p=kwargs.get('top_p', 0.9),
max_tokens=kwargs.get('max_tokens', 512),
repetition_penalty=kwargs.get('repetition_penalty', 1.2)
)
# 使用vLLM生成
outputs = self.llm.generate([prompt], sampling_params)
# 提取原始响应
raw_response = outputs[0].outputs[0].text.strip()
# print(f"📝 原始响应: {raw_response}")
# 提取第一个问题
cleaned_response = self.extract_first_question(raw_response)
# print(f"🧹 清理后: {cleaned_response}")
return CompletionResponse(text=cleaned_response)
@llm_completion_callback()
def stream_complete(self, prompt: str, **kwargs: Any) -> CompletionResponseGen:
response = self.complete(prompt, **kwargs)
yield CompletionResponse(text=response.text, delta=response.text)
# 测试模型
llm = QWEN3()
然后通过调用本地大模型生成QA数据集:
import os
from typing import List
from llama_index.core.schema import BaseNode
from llama_index.finetuning import generate_qa_embedding_pairs
from llama_index.core.evaluation import EmbeddingQAFinetuneDataset
# ============ 1. 加载你的 QWEN3 模型 ============
# llm = QWEN3(model_path="Qwen/Qwen3-8B")
# ============ 2. 使用最简单的提示模板 ============
QA_PROMPT_TEMPLATE = """
以下是相关信息。
---------------------
{context_str}
--------------------
请基于上述内容生成1个问题。只输出生成的问题,不要任何思考过程。
下面是一个参考例子:
文本内容:抓取检测是机械臂抓取过程中实现目标检测和规划执行的一个过程。
生成的问题:抓取检测在机械臂抓取过程中起到什么作用?
问题:"""
# ============ 3. 生成数据集 ============
print("🧠 正在生成训练集 QA 对...")
train_dataset = generate_qa_embedding_pairs(
llm=llm,
nodes=train_nodes,
qa_generate_prompt_tmpl=QA_PROMPT_TEMPLATE,
num_questions_per_chunk=1,
)
print("🧠 正在生成验证集 QA 对...")
val_dataset = generate_qa_embedding_pairs(
llm=llm,
nodes=val_nodes,
qa_generate_prompt_tmpl=QA_PROMPT_TEMPLATE,
num_questions_per_chunk=1,
)
# ============ 4. 保存 ============
os.makedirs("Dataset/datasets", exist_ok=True)
train_dataset.save_json("Dataset/datasets/train_dataset.json")
val_dataset.save_json("Dataset/datasets/val_dataset.json")
print("✅ 数据集已保存!")
# ============ 5. 检查结果 ============
print(f"训练集: {len(train_dataset.queries)} queries, {len(train_dataset.corpus)} docs")
print(f"验证集: {len(val_dataset.queries)} queries, {len(val_dataset.corpus)} docs")
# 显示前几个查询示例
print("\n📋 查询示例:")
for i, (qid, query) in enumerate(list(train_dataset.queries.items())[:3]):
print(f"{i+1}. {query}")
输出内容如下:
🧠 正在生成训练集 QA 对...
🧠 正在生成验证集 QA 对...
✅ 数据集已保存!
训练集: 252 queries, 252 docs
验证集: 310 queries, 310 docs
📋 查询示例:
1. 该研究的主要创新点是什么?
2. 答辩委员会中有哪些成员?
3. 如何设计主动激励触觉传感器以提高物体分类识别的准确性?
第五步:微调 BCE 嵌入模型
现在,我们使用生成的 QA 对,对转换后的 BCE 模型进行微调。
import os
import json
import time
from sentence_transformers import SentenceTransformer, InputExample, losses
from torch.utils.data import DataLoader
print("🚀 开始处理标准RAG格式数据...")
# 1. 加载数据
with open("Dataset/datasets/train_dataset.json", "r", encoding="utf-8") as f:
data = json.load(f)
print("📊 数据格式分析:")
print(f" - queries数量: {len(data['queries'])}")
print(f" - corpus数量: {len(data['corpus'])}")
print(f" - relevant_docs数量: {len(data['relevant_docs'])}")
# 2. 显示示例
print("\n📋 数据示例:")
first_query_id = list(data['queries'].keys())[0]
first_doc_id = list(data['corpus'].keys())[0]
print(f" - 第一个查询: {data['queries'][first_query_id][:100]}...")
print(f" - 第一个文档: {data['corpus'][first_doc_id][:100]}...")
print(f" - 相关文档: {data['relevant_docs'][first_query_id]}")
# 3. 准备训练数据
train_examples = []
for query_id, query_text in data['queries'].items():
# 获取相关的文档ID
relevant_doc_ids = data['relevant_docs'].get(query_id, [])
for doc_id in relevant_doc_ids:
if doc_id in data['corpus']:
# 创建正样本:查询和相关的文档
train_examples.append(InputExample(
texts=[query_text, data['corpus'][doc_id]]
))
print(f"📊 生成 {len(train_examples)} 个训练样本")
# 4. 加载模型
model = SentenceTransformer("./Models/maidalun/bce-embedding-base_v1")
print(f"✅ 模型加载到: {model.device}")
# 5. 数据加载器
train_dataloader = DataLoader(train_examples, batch_size=8, shuffle=True)
# 6. 损失函数 - 适合检索任务
train_loss = losses.MultipleNegativesRankingLoss(model=model)
# 7. 微调
start_time = time.time()
print("🔥 开始微调...")
model.fit(
train_objectives=[(train_dataloader, train_loss)],
epochs=3,
warmup_steps=10,
output_path="Models/model_outdir/bce-finetuned-rag",
show_progress_bar=True
)
elapsed = time.time() - start_time
print(f"✅ 微调完成!耗时: {elapsed:.2f}秒")
print("💾 模型保存到: Models/model_outdir/bce-finetuned-rag")
输出内容如下:
🚀 开始处理标准RAG格式数据...
📊 数据格式分析:
- queries数量: 252
- corpus数量: 252
- relevant_docs数量: 252
📋 数据示例:
- 第一个查询: 该研究的主要创新点是什么?...
- 第一个文档: 分 类 号: TP39 密级: 公 开
论文编号: 2022022725
贵 州 大 学
2025届硕士研究生学位论文
基于视触觉融合的机械手分类
抓取方法研究
学科专业: 电子信息
研究方向: 控制...
- 相关文档: ['f7513665-5aa4-400d-8e3b-6d98f268a79a']
📊 生成 252 个训练样本
✅ 模型加载到: cuda:0
🔥 开始微调...
[96/96 00:11, Epoch 3/3]
Step Training Loss
✅ 微调完成!耗时: 274.85秒
💾 模型保存到: Models/model_outdir/bce-finetuned-rag
第六步:评估微调模型
import os
from sentence_transformers import SentenceTransformer
from sentence_transformers.evaluation import InformationRetrievalEvaluator
from llama_index.core.evaluation import EmbeddingQAFinetuneDataset
# 加载验证集
val_dataset = EmbeddingQAFinetuneDataset.from_json("Dataset/datasets/val_dataset.json")
print(f"验证集: {len(val_dataset.queries)} queries, {len(val_dataset.corpus)} docs")
# 评估函数
def evaluate_model(model_path, model_name):
model = SentenceTransformer(model_path)
evaluator = InformationRetrievalEvaluator(
val_dataset.queries, val_dataset.corpus, val_dataset.relevant_docs,
name=model_name
)
results = evaluator(model, output_path="./results/")
return results
# 评估并打印结果
def print_results(results, model_name):
print(f"\n{model_name}:")
for key, value in results.items():
print(f" {key}: {value}")
# 评估原始模型
orig_results = evaluate_model("./Models/maidalun/bce-embedding-base_v1", "原始BCE")
print_results(orig_results, "原始BCE")
# 评估微调模型
fine_results = evaluate_model("./Models/model_outdir/bce-finetuned-rag", "微调BCE")
print_results(fine_results, "微调BCE")
输出如下:
验证集: 310 queries, 310 docs
原始BCE:
原始BCE_cosine_accuracy@1: 0.6129032258064516
原始BCE_cosine_accuracy@3: 0.8225806451612904
原始BCE_cosine_accuracy@5: 0.8774193548387097
原始BCE_cosine_accuracy@10: 0.9290322580645162
原始BCE_cosine_precision@1: 0.6129032258064516
原始BCE_cosine_precision@3: 0.27419354838709675
原始BCE_cosine_precision@5: 0.1754838709677419
原始BCE_cosine_precision@10: 0.09290322580645159
原始BCE_cosine_recall@1: 0.6129032258064516
原始BCE_cosine_recall@3: 0.8225806451612904
原始BCE_cosine_recall@5: 0.8774193548387097
原始BCE_cosine_recall@10: 0.9290322580645162
原始BCE_cosine_ndcg@10: 0.7772748685524999
原始BCE_cosine_mrr@10: 0.7279518689196108
原始BCE_cosine_map@100: 0.7312418636149842
微调BCE:
微调BCE_cosine_accuracy@1: 0.8290322580645161
微调BCE_cosine_accuracy@3: 0.9548387096774194
微调BCE_cosine_accuracy@5: 0.9709677419354839
微调BCE_cosine_accuracy@10: 0.9806451612903225
微调BCE_cosine_precision@1: 0.8290322580645161
微调BCE_cosine_precision@3: 0.3182795698924731
微调BCE_cosine_precision@5: 0.19419354838709674
微调BCE_cosine_precision@10: 0.09806451612903225
微调BCE_cosine_recall@1: 0.8290322580645161
微调BCE_cosine_recall@3: 0.9548387096774194
微调BCE_cosine_recall@5: 0.9709677419354839
微调BCE_cosine_recall@10: 0.9806451612903225
微调BCE_cosine_ndcg@10: 0.9136570720819376
微调BCE_cosine_mrr@10: 0.8911059907834101
微调BCE_cosine_map@100: 0.8924456586277176
📊 评估指标解释
🎯 核心指标说明
| 指标 | 含义 | 评估重点 |
|---|---|---|
| accuracy@k | 前k个结果中是否包含正确答案 | 检索准确性 |
| precision@k | 前k个结果中相关文档的比例 | 结果精确度 |
| recall@k | 找到的相关文档占所有相关文档的比例 | 召回能力 |
| NDCG@k | 考虑排序质量的加权评分 | 排序质量 |
| MRR | 第一个正确答案的倒数排名平均值 | 排名效率 |
| MAP | 平均精度均值 | 整体性能 |
🔍 详细分析
1. Accuracy@k(准确率)
原始BCE_accuracy@1: 61.3% → 微调BCE_accuracy@1: 82.9% (+21.6%)
原始BCE_accuracy@3: 82.3% → 微调BCE_accuracy@3: 95.5% (+13.2%)
原始BCE_accuracy@5: 87.7% → 微调BCE_accuracy@5: 97.1% (+9.4%)
原始BCE_accuracy@10: 92.9% → 微调BCE_accuracy@10: 98.1% (+5.2%)
分析:显著提升!特别是top1准确率提升21.6%,说明微调后模型能更准确地将相关文档排在第一位。
2. Precision@k(精确率)
precision@1: 61.3% → 82.9% (+21.6%) ✅ 大幅提升
precision@3: 27.4% → 31.8% (+4.4%) ⚠️ 小幅提升
precision@5: 17.5% → 19.4% (+1.9%) ⚠️ 小幅提升
precision@10: 9.3% → 9.8% (+0.5%) ⚠️ 几乎不变
分析:precision@k随着k增大而降低是正常的,但微调后各项都有提升,说明结果质量整体改善。
3. Recall@k(召回率)
recall@1: 61.3% → 82.9% (+21.6%) ✅
recall@3: 82.3% → 95.5% (+13.2%) ✅
recall@5: 87.7% → 97.1% (+9.4%) ✅
recall@10: 92.9% → 98.1% (+5.2%) ✅
分析:全面显著提升!说明微调后模型能找到更多相关文档。
4. 排序质量指标
NDCG@10: 0.777 → 0.914 (+17.7%) ✅ 排序质量大幅提升
MRR@10: 0.728 → 0.891 (+16.3%) ✅ 正确答案排名更靠前
MAP@100: 0.731 → 0.892 (+16.1%) ✅ 整体性能显著改善
分析:所有排序相关指标都有显著提升,说明微调有效改善了文档排序。
🎯 性能提升总结
| 指标 | 提升幅度 | 效果评价 |
|---|---|---|
| accuracy@1 | +21.6% | 🎉 优秀提升 |
| NDCG@10 | +17.7% | 🎉 优秀提升 |
| MRR@10 | +16.3% | 🎉 优秀提升 |
| MAP@100 | +16.1% | 🎉 优秀提升 |
| recall@3 | +13.2% | ✅ 良好提升 |
| precision@1 | +21.6% | 🎉 优秀提升 |
更多推荐


所有评论(0)