使用 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% 🎉 优秀提升

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐