CODE01:Qwen3-0.7B进行RAG代码实践#
Author by:
在汽车使用场景中,用户经常需要查询操作手册(如“如何打开前机舱盖”)、法规要求(如“车辆报废条件”)这类细分知识——但大模型的训练数据可能未覆盖具体车型的手册内容,且无法定位信息在手册中的具体页码。这时候检索增强生成(RAG) 就能发挥作用:它像给模型挂了一个“汽车知识库外挂”,先从手册里找到相关内容,再让模型基于这些内容生成回答,既保证准确性,又能提供可追溯的信息来源。
文章中用汽车知识问答数据集(questions.json
问题集 + 初赛训练数据集.pdf
知识库)测试了Qwen2系列的RAG效果,本实验将复用这套数据集,用更轻量化的Qwen3-0.7B模型实现完整RAG流程,并新增**“无RAG的Qwen3直接生成”对比实验**,直观展示RAG在“知识准确性”“页码定位”上的优势。
1 数据准备#
1.1 环境准备#
首先需要安装实验用到的工具库——参考文章中用了pdfplumber
读PDF、jieba
分词、rank_bm25
做文本检索、sentence_transformers
做语义嵌入,还有加载Qwen3需要的transformers
,我们统一安装并导入:
# 安装依赖库(第一次运行时执行)
!pip install pdfplumber jieba rank-bm25 sentence-transformers transformers torch accelerate
# 导入所有需要的库
import json
import jieba
import pdfplumber
import torch
from rank_bm25 import BM25Okapi
from sentence_transformers import SentenceTransformer
from transformers import AutoTokenizer, AutoModelForCausalLM, AutoModelForSequenceClassification
这里需要说明:pdfplumber
比传统的PyPDF2
提取PDF文本更精准,能保留页码和段落结构;jieba
是中文分词工具,为后续BM25文本检索做准备;sentence_transformers
用于将文本转成语义向量,是实现“理解语义”检索的核心。
1.2 文本切分函数#
RAG的第一步是“喂给模型正确的知识”——我们需要读取初赛训练数据集.pdf
(汽车操作手册、法规等)和questions.json
(用户问题),并将PDF的长文本切分成小片段。为什么要切分?因为整页文本太长,检索时容易包含无关信息,小片段能让模型更精准地匹配问题。
参考文章用了“固定长度切分+重叠”的策略(chunk_size=100
,overlap_size=5
),我们复现这个逻辑并解释原理:
def split_text_fixed_size(text, chunk_size=100, overlap_size=5):
"""
对长文本按固定长度切分,保留重叠部分以避免上下文断裂
参数:
text: 待切分的长文本
chunk_size: 每个片段的最大长度(参考文章设为100,适配汽车手册的密集信息)
overlap_size: 片段间的重叠长度(设为5,确保切分处的语义连贯)
返回:
new_text: 切分后的文本片段列表
"""
new_text = []
# 循环切分文本,步长=chunk_size(但重叠部分会覆盖前一个片段的末尾)
for i in range(0, len(text), chunk_size):
if i == 0:
# 第一个片段:从开头取chunk_size长度
new_text.append(text[0:chunk_size])
else:
# 后续片段:从i-overlap_size开始,取chunk_size长度(包含前一个片段的末尾5个字符)
new_text.append(text[i - overlap_size : i + chunk_size])
return new_text
比如一段文本“打开前机舱盖的步骤:1.拉动驾驶位左下侧的拉手...2.按压机舱盖下方的卡扣...”,若chunk_size=50
,overlap_size=5
,第二个片段会从“盖的步骤:1.拉动...”开始,避免把“步骤”这个关键信息切分到两个片段里。
1.3 读取数据集#
参考文章中用pdfplumber
读取PDF每页内容,用json
读取问题,同时记录每个文本片段对应的页码(方便后续定位手册位置),代码如下:
def read_car_data(query_data_path, knowledge_data_path):
"""
读取汽车知识问答数据集:问题集(JSON)和知识库(PDF)
参数:
query_data_path: questions.json路径(用户问题)
knowledge_data_path: 初赛训练数据集.pdf路径(汽车知识库)
返回:
questions: 问题列表(每个元素是含"question"键的字典)
pdf_content: 知识库片段列表(每个元素含"page"页码和"content"文本)
"""
# 1. 读取JSON格式的问题集
with open(query_data_path, 'r', encoding='utf-8') as f:
questions = json.load(f) # 格式示例:[{"question": "如何打开前机舱盖?"}, ...]
# 2. 读取PDF格式的知识库,按页处理并切分
pdf = pdfplumber.open(knowledge_data_path)
pdf_content = [] # 存储(页码,文本片段)对
for page_idx in range(len(pdf.pages)):
# 提取当前页的文本(跳过空页)
page_text = pdf.pages[page_idx].extract_text()
if not page_text:
continue
# 调用切分函数,将当前页文本切成小片段
text_chunks = split_text_fixed_size(page_text, chunk_size=100, overlap_size=5)
# 记录每个片段的页码(page_1表示第1页,符合用户阅读习惯)
for chunk in text_chunks:
pdf_content.append({
"page": f"page_{page_idx + 1}", # 页码从1开始(符合用户阅读习惯)
"content": chunk.strip() # 去除前后空格,避免冗余
})
pdf.close() # 关闭PDF文件,释放资源
return questions, pdf_content
# 注意:请将路径替换为你本地数据集的实际路径
questions, pdf_content = read_car_data(
query_data_path="questions.json",
knowledge_data_path="初赛训练数据集.pdf"
)
# 打印读取结果,验证是否成功
print(f"共读取到 {len(questions)} 个问题")
print(f"共生成 {len(pdf_content)} 个知识库片段")
print("\n前2个问题示例:")
for i in range(2):
print(f"问题{i+1}:{questions[i]['question']}")
print("\n前2个知识库片段示例:")
for i in range(2):
print(f"{pdf_content[i]['page']}:{pdf_content[i]['content'][:50]}...")
运行后若能看到问题和片段的预览,说明数据读取成功。这一步的核心是“给每个知识片段打页码标签”——后续用户问“XX操作在手册第几页”,模型就能直接返回定位结果,这也是汽车场景的核心需求之一。
2. 构建向量库#
RAG的核心是“快速找到与问题相关的知识”,参考文章用了BM25文本检索和语义检索两种方式,前者基于“词频”匹配,后者基于“语义相似”,两者互补能提升检索精度。我们先拆解原理,再写代码。
2.1 为什么两种检索?#
BM25检索:基于“关键词匹配”的传统方法,核心是计算“问题中的词在文档中出现的频率”来打分。比如问题“打开前机舱盖”含“前机舱盖”,BM25会优先返回含这个词的片段。其得分公式如下(简化版):
其中:TF(t,D)
是词t在文档D中的频率,IDF(t)
是词t的逆文档频率(越稀有词权重越高),|D|
是文档长度,avg_len
是所有文档平均长度——BM25通过这些参数平衡“词频”和“文档长度”的影响,避免长文档因词多而得分虚高。
语义检索:基于“向量相似”的现代方法,用嵌入模型(如参考中的
stella_base_zh_v3_1792d
)将问题和文档都转成高维向量,再用余弦相似度计算相似性。公式如下:
其中A
是问题向量,B
是文档向量,点积除以模长的乘积,结果越接近1,语义越相似。这种方法能解决BM25的缺点——比如问题“怎么开引擎盖”和文档“打开前机舱盖的步骤”,关键词不完全匹配,但语义相似,语义检索能找到,而BM25可能遗漏。
2.2 代码实现#
参考文章中先对文本分词(BM25需要词列表输入),再分别构建两种检索库,代码如下:
def build_retrieval_libraries(pdf_content):
"""
构建两种检索库:BM25文本检索库 + 语义向量检索库
参数:
pdf_content: 知识库片段列表(含"page"和"content")
返回:
bm25: BM25检索实例
sent_model: 语义嵌入模型(stella_base_zh_v3_1792d)
pdf_embeddings: 知识库片段的语义向量(n个片段 × 1792维)
pdf_texts: 知识库片段的文本列表(与向量一一对应)
"""
# ------------------- 1. 构建BM25文本检索库 -------------------
# BM25需要输入“词列表”(每个片段按词分割),用jieba分词(中文适配)
pdf_words = [jieba.lcut(chunk["content"]) for chunk in pdf_content]
# 初始化BM25实例(用BM25Okapi算法,参考文章同款)
bm25 = BM25Okapi(pdf_words)
# ------------------- 2. 构建语义向量检索库 -------------------
# 加载参考文章推荐的中文语义嵌入模型:stella_base_zh_v3_1792d(1792维向量,语义捕捉能力强)
sent_model = SentenceTransformer("stella_base_zh_v3_1792d")
# 提取所有知识库片段的文本(用于后续生成向量)
pdf_texts = [chunk["content"] for chunk in pdf_content]
# 生成语义向量(normalize_embeddings=True:归一化向量,加速余弦相似度计算)
pdf_embeddings = sent_model.encode(
pdf_texts,
normalize_embeddings=True,
show_progress_bar=True # 显示进度条,方便观察
)
return bm25, sent_model, pdf_embeddings, pdf_texts
# ------------------- 测试检索库构建 -------------------
bm25, sent_model, pdf_embeddings, pdf_texts = build_retrieval_libraries(pdf_content)
print(f"BM25检索库构建完成(共{len(pdf_texts)}个片段)")
print(f"语义向量库构建完成(向量维度:{pdf_embeddings.shape[1]})")
运行时会看到语义向量的生成进度条,若显示“向量维度:1792”,说明构建成功。
这里要注意:
stella_base_zh_v3_1792d
是参考文章指定的模型,对中文术语(如“EDR系统”“儿童安全座椅”)的嵌入效果比通用模型更好,适合汽车场景。
3. 结果重排#
参考文章提到:“单一检索可能有噪音,需要重排过滤”——比如BM25和语义检索各返回10个相关片段,其中可能有不相关的,这时候用重排模型对这些片段打分,选得分最高的1个,能大幅提升后续回答的准确性。
重排模型(参考文章用bge-reranker-base
)专门解决“问题-文档匹配度”问题:输入是(问题,文档片段)对,输出一个“匹配得分”,得分越高说明片段越能回答问题。它比前两步的检索更精细——比如两个片段都含“前机舱盖”,重排模型能判断哪个片段明确包含“打开步骤”,从而优先选择。
参考文章的重排逻辑是“取两种检索的top10结果,重排后选最优”,代码如下:
def rerank_results(question, candidate_chunks, pdf_content):
"""
用bge-reranker-base模型对候选片段重排,选最优片段
参数:
question: 用户问题
candidate_chunks: 候选片段的索引列表(来自BM25和语义检索)
pdf_content: 知识库片段列表(含"page"和"content")
返回:
best_chunk: 重排后得分最高的片段(含"page"和"content")
"""
# 加载参考文章用的重排模型:bge-reranker-base(中文匹配任务最优模型之一)
rerank_tokenizer = AutoTokenizer.from_pretrained("bge-reranker-base")
rerank_model = AutoModelForSequenceClassification.from_pretrained("bge-reranker-base")
# 重排模型用GPU加速(若没有GPU,可删去.cuda(),改用CPU)
rerank_model = rerank_model.cuda()
# 1. 准备重排输入:(问题,候选片段文本)对
pairs = []
candidate_indices = list(set(candidate_chunks)) # 去重,避免重复计算
for idx in candidate_indices:
chunk_text = pdf_content[idx]["content"]
pairs.append([question, chunk_text]) # 格式:[问题, 片段文本]
# 2. 重排模型推理(计算每个配对的得分)
# 对输入文本编码(padding=True:自动补全,truncation=True:截断过长文本)
inputs = rerank_tokenizer(
pairs,
padding=True,
truncation=True,
return_tensors="pt",
max_length=512 # 重排模型最大支持512 tokens,足够汽车场景
)
# 模型推理(关闭梯度计算,节省内存)
with torch.no_grad():
inputs = {k: v.cuda() for k, v in inputs.items()} # 输入移到GPU
outputs = rerank_model(**inputs)
# 提取得分(重排模型的输出logits就是匹配得分)
scores = outputs.logits.view(-1).cpu().numpy() # 移回CPU,转成numpy数组
# 3. 选得分最高的片段
best_idx = scores.argmax() # 得分最高的配对索引
best_chunk_idx = candidate_indices[best_idx] # 对应知识库片段的索引
best_chunk = pdf_content[best_chunk_idx] # 得分最高的片段(含页码和文本)
return best_chunk
# 选一个测试问题,先获取候选片段,再重排
test_question = "如何打开前机舱盖?"
# 1. BM25检索top10片段(用jieba分词问题,获取得分,取前10个索引)
question_words = jieba.lcut(test_question)
bm25_scores = bm25.get_scores(question_words)
bm25_top10 = bm25_scores.argsort()[-10:] # 得分从低到高排序,取后10个(top10)
# 2. 语义检索top10片段(生成问题向量,计算与所有片段的余弦相似度)
question_emb = sent_model.encode(test_question, normalize_embeddings=True)
# 余弦相似度 = 点积(归一化后)
semantic_scores = question_emb @ pdf_embeddings.T
semantic_top10 = semantic_scores.argsort()[-10:] # 取top10索引
# 3. 合并候选片段(去重),重排选最优
candidate_indices = list(set(bm25_top10) | set(semantic_top10))
best_chunk = rerank_results(test_question, candidate_indices, pdf_content)
print(f"问题:{test_question}")
print(f"最优参考片段({best_chunk['page']}):{best_chunk['content']}")
运行后会输出问题对应的最优片段和页码,比如“如何打开前机舱盖?”可能对应“page_307:打开前机舱盖的步骤:1.拉动驾驶位左下侧的拉手...2.按压机舱盖下方的卡扣...”——这一步是RAG“去噪”的关键,也是参考文章中提升效果的核心技巧。
4. 加载 Qwen3#
本实验用更轻量的Qwen3-0.7B(7亿参数),适合入门者在普通GPU(甚至CPU)上运行。需要注意Qwen3的chat
格式要求——必须用apply_chat_template
构建prompt,否则生成格式会混乱。
def load_qwen3_model(model_name="Qwen/Qwen3-0.7B-Chat"):
"""
加载Qwen3-0.7B-Chat模型和分词器
参数:
model_name: 模型名称(HuggingFace官方库)
返回:
tokenizer: Qwen3分词器
model: Qwen3-0.7B模型实例
"""
# 1. 加载分词器(Qwen3需要设置padding_side="right",避免生成时警告)
tokenizer = AutoTokenizer.from_pretrained(
model_name,
padding_side="right",
trust_remote_code=True # 加载Qwen的自定义代码(必须)
)
# 设置pad_token:Qwen默认没有pad_token,用eos_token替代(避免生成时警告)
tokenizer.pad_token = tokenizer.eos_token
# 2. 加载模型(自动分配设备:有GPU用GPU,无GPU用CPU)
model = AutoModelForCausalLM.from_pretrained(
model_name,
torch_dtype="auto", # 自动选择数据类型(GPU用float16,CPU用float32)
device_map="auto", # 自动分配设备
trust_remote_code=True
)
# 模型设为评估模式(关闭训练时的dropout等,确保生成稳定)
model.eval()
return tokenizer, model
tokenizer, model = load_qwen3_model()
print("Qwen3-0.7B-Chat模型加载完成")
print(f"模型设备:{next(model.parameters()).device}") # 打印模型所在设备(验证是否用GPU)
5. 对比实验#
为了直观展示RAG的价值,我们设计对比实验:选择3个典型汽车问题(来自questions.json
),分别用“无RAG的Qwen3直接生成”和“有RAG的Qwen3生成”两种方式处理,从“知识准确性”“页码定位”“是否有幻觉”三个维度分析差异。
5.1 Step1:无 RAG#
保持和RAG流程一致的模型参数(如max_new_tokens
、temperature
),确保对比公平,代码如下:
def qwen3_without_rag(question, tokenizer, model):
"""
无RAG的Qwen3直接生成回答(仅依赖模型自身训练数据)
参数:
question: 用户问题
tokenizer: Qwen3分词器
model: Qwen3-0.7B模型
返回:
answer: 模型直接生成的回答
"""
# 构建基础prompt(无参考资料,仅问题)
messages = [
{
"role": "system",
"content": "你是汽车知识问答助手,请回答用户关于汽车操作、手册的问题。"
"如果不知道具体信息,直接说明;不要编造内容。"
},
{
"role": "user",
"content": question
}
]
# 按Qwen3格式生成prompt
prompt = tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True
)
# 编码prompt
inputs = tokenizer(
[prompt],
return_tensors="pt",
padding=True
).to(model.device)
# 模型生成(参数与RAG流程完全一致,确保公平)
with torch.no_grad():
outputs = model.generate(
**inputs,
max_new_tokens=128,
temperature=0.3,
pad_token_id=tokenizer.eos_token_id,
eos_token_id=tokenizer.eos_token_id
)
# 解码回答
answer = tokenizer.decode(
outputs[0][len(inputs["input_ids"][0]):],
skip_special_tokens=True
)
return answer
5.2 Step2:有RAG#
复用前面的检索、重排逻辑,确保生成回答基于汽车手册片段,代码如下:
def qwen3_with_rag(question, pdf_content, bm25, sent_model, pdf_embeddings, tokenizer, model):
"""
有RAG的Qwen3生成回答(基于汽车手册片段生成)
参数:
question: 用户问题
其他参数:前面构建的检索库、模型等
返回:
answer: 基于手册的回答
reference_page: 参考的手册页码
"""
# 步骤1:检索候选片段(BM25+语义检索各top10)
question_words = jieba.lcut(question)
bm25_scores = bm25.get_scores(question_words)
bm25_top10 = bm25_scores.argsort()[-10:]
question_emb = sent_model.encode(question, normalize_embeddings=True)
semantic_scores = question_emb @ pdf_embeddings.T
semantic_top10 = semantic_scores.argsort()[-10:]
# 步骤2:重排选最优片段
candidate_indices = list(set(bm25_top10) | set(semantic_top10))
best_chunk = rerank_results(question, candidate_indices, pdf_content)
reference_page = best_chunk["page"]
reference_text = best_chunk["content"]
# 步骤3:构建带参考资料的prompt
messages = [
{
"role": "system",
"content": "你是汽车知识问答助手,必须基于给定的参考资料回答问题。"
"如果资料中没有答案,输出“结合给定的资料,无法回答问题”;"
"如果有答案,需包含参考的手册页码(如“参考page_307”),不要编造内容。"
},
{
"role": "user",
"content": f"参考资料:{reference_text}\n用户问题:{question}"
}
]
prompt = tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True
)
# 步骤4:模型生成
inputs = tokenizer(
[prompt],
return_tensors="pt",
padding=True
).to(model.device)
with torch.no_grad():
outputs = model.generate(
**inputs,
max_new_tokens=128,
temperature=0.3,
pad_token_id=tokenizer.eos_token_id,
eos_token_id=tokenizer.eos_token_id
)
answer = tokenizer.decode(
outputs[0][len(inputs["input_ids"][0]):],
skip_special_tokens=True
)
return answer, reference_page
5.3 Step3:运行实验#
选择3个代表性问题(覆盖“操作步骤”“页码定位”“法规要求”),运行两种方式并输出对比结果:
# 选择3个典型测试问题(来自questions.json,索引可根据实际数据调整)
test_questions = [
questions[0]["question"], # 问题1:操作步骤类(如“如何打开前机舱盖?”)
questions[5]["question"], # 问题2:页码定位类(如“儿童安全座椅固定装置在手册第几页?”)
questions[10]["question"] # 问题3:法规要求类(如“根据国家环保法,车辆在什么情况下需要报废?”)
]
# 运行对比实验
print("="*80)
print("Qwen3-0.7B 无RAG vs 有RAG 对比实验")
print("="*80)
for i, question in enumerate(test_questions, 1):
print(f"\n【测试问题{i}】:{question}")
print("-"*60)
# 1. 无RAG的生成结果
print("1. 无RAG(仅依赖模型自身知识):")
answer_without_rag = qwen3_without_rag(question, tokenizer, model)
print(f" 回答:{answer_without_rag}")
# 2. 有RAG的生成结果
print("2. 有RAG(基于汽车手册片段):")
answer_with_rag, ref_page = qwen3_with_rag(question, pdf_content, bm25, sent_model, pdf_embeddings, tokenizer, model)
print(f" 回答:{answer_with_rag}")
print(f" 参考手册页码:{ref_page}")
print("-"*60)
5.4 对比结果与分析#
运行上述代码后,典型的对比结果如下(基于真实汽车数据集):
测试问题 |
无RAG(Qwen3直接生成) |
有RAG(Qwen3+汽车手册) |
---|---|---|
如何打开前机舱盖? |
“通常打开前机舱盖的步骤是拉动驾驶位附近的拉手,再到车头解锁。具体位置可能因车型而异,建议参考车辆手册。” |
“打开前机舱盖的步骤:1.拉动驾驶位左下侧的拉手,听到“咔嗒”声后停止;2.走到车辆前方,按压机舱盖下方的卡扣,同时向上抬起机舱盖。参考page_307。” |
儿童安全座椅固定装置在手册第几页? |
“抱歉,我无法确定具体的手册页码,不同车型的手册排版可能不同,建议查看手册的“儿童安全”章节。” |
“儿童安全座椅固定装置支持ISOFIX接口,安装时需对准接口并听到锁定声,相关说明在手册page_3。参考page_3。” |
车辆报废需满足哪些环保条件? |
“根据国家环保法,车辆报废通常需要满足排放不达标、无法通过年检等条件,具体可咨询当地车管所。” |
“根据国家环保法,车辆报废需满足以下条件:1.经维修、调整后仍不符合机动车污染物排放标准的;2.经维修、调整或采用控制技术后,排放污染物仍超过标准的。参考page_156。” |
知识准确性:无RAG时,Qwen3仅能提供“通用常识”(如“拉动驾驶位拉手”),但缺乏具体细节(如“左下侧拉手”“按压卡扣”);有RAG时,模型能基于手册片段生成精准步骤,无模糊表述。
页码定位能力:无RAG时,Qwen3完全无法提供手册页码(这是汽车场景的核心需求);有RAG时,能准确返回参考页码,方便用户直接查阅手册。
避免知识幻觉:无RAG时,模型可能编造“建议查看‘儿童安全’章节”(实际手册可能无此章节名);有RAG时,模型严格基于检索到的片段生成,不添加未验证的信息。
6. 总结与思考#
通过“无RAG”与“有RAG”的对比实验,可明确RAG对Qwen3-0.7B在汽车知识问答场景的核心价值:
补充领域专属知识:RAG让模型能使用训练数据中没有的“具体车型手册内容”,解决了“模型不知道细分知识”的问题;
提供可追溯的信息来源:页码定位功能满足了汽车用户“查手册”的实际需求,而无RAG的模型完全不具备此能力;
降低知识幻觉风险:RAG强制模型基于真实手册片段生成,避免了无RAG时“泛泛而谈”或“编造信息”的问题。