

代码开源Github地址 ↗
构建本地Q&A系统#
本系列代码都来自博客 ↗,在博主的基础上实现了本地调用模型,同时基本跑通所有代码。实现了一个从本地构建知识库,并进行问答的系统。
这个实战可以帮助理解RAG从文本分块-构建向量数据库-检索-生成的整个流程以及优化技巧等。
加载文档#
在构建 RAG(Retrieval-Augmented Generation)系统时,第一步是将外部知识源(如网页、PDF、Word 文档等)加载为程序可处理的格式。LangChain 提供了丰富的 Document Loaders,用于从不同来源提取文本内容,并统一封装为 Document 对象。
什么是 Document?
在 LangChain 中,Document 是一个轻量级的数据结构,用于表示一段文本及其相关的元信息(metadata)。其基本结构如下:
from langchain_core.documents import Document
doc = Document(
page_content="这是文档的正文内容。",
metadata={"source": "example.pdf", "page": 1, "title": "示例标题"}
)pythonpage_content:字符串类型,存储实际的文本内容。metadata:字典类型,包含与该文档片段相关的附加信息,例如来源 URL、页码、标题、作者、创建时间等。
这种设计使得后续的文本分割、向量化、检索等步骤能够保留上下文和溯源信息,对于构建可解释、可追踪的 RAG 系统至关重要。
加载 Web 文档#
我们使用 WebBaseLoader 从指定的 CSDN 博客链接加载内容。该 loader 基于 requests 和 BeautifulSoup,能自动解析 HTML 并提取正文文本。
from langchain_community.document_loaders import WebBaseLoader
loader = WebBaseLoader("https://blog.csdn.net/weixin_44919384/article/details/154616759?spm=1001.2014.3001.5501")
docs = loader.load()
print(f"加载了 {len(docs)} 个文档")
title = docs[0].metadata.get('title', 'N/A')
print(f"第一个文档的标题:{title}")
print(f"第一个文档长度: {len(docs[0].page_content)} 字符")python输出:
加载了 1 个文档
第一个文档的标题:模型训练(四)梯度累计Gradient Accumulation-CSDN博客
第一个文档长度: 11642 字符plaintext💡 提示:
WebBaseLoader默认会尝试提取页面的<title>和部分元标签作为metadata,但具体效果取决于目标网站的 HTML 结构。对于复杂或动态渲染的网页,可能需要结合Selenium或Playwright等工具。
加载 PDF 文档#
对于本地 PDF 文件,我们使用 PyPDFLoader。它基于 pypdf 库,按页读取 PDF 内容,每一页会被封装为一个独立的 Document 对象。
from langchain_community.document_loaders import PyPDFLoader
loader = PyPDFLoader("./Dataset/PDF/基于视-触觉融合感知的机器人抓取滑动检测与力控研究_闫腾.pdf")
docs = loader.load()
first_doc = docs[0]
print("meta_data:", first_doc.metadata)
print("content:", first_doc.page_content[:500] + "..." if len(first_doc.page_content) > 500 else first_doc.page_content)python输出:
meta_data: {'producer': 'TTKN', 'creator': 'ReaderEx_DIS 2.5.0 Build 4088', 'creationdate': '2025-11-03T14:24:27-08:00', 'author': 'CNKI', 'source': './Dataset/PDF/基于视-触觉融合感知的机器人抓取滑动检测与力控研究_闫腾.pdf', 'total_pages': 79, 'page': 0, 'page_label': '1'}
content: 硕士学位论 文
学位申请人姓名 闫腾
学位申请人学号 2200411007
专 业 名 称 机械工程
学 科 门 类 工学
学院(部、研究院) 应用技术学院
导 师 姓 名 李文贤
二〇二五年六月
分类号 学校代码 10590
UDC 密 级 公开
基于视-触觉融合感知的机器人
抓取滑动检测与力控研究
ധᎪն࿐plaintext📌 注意:
PyPDFLoader对扫描版 PDF(即图片型 PDF)无效,仅适用于文字可复制的 PDF。- 每个
Document的metadata中通常包含"source"(文件路径)和"page"(页码),便于后续定位原文位置。
通过上述步骤,我们成功将异构数据源统一转换为 LangChain 的 Document 格式,为下一步的文本分割和向量嵌入做好了准备。
Part1: 文本分块(Text Chunking)#
在将原始文档加载为 Document 对象后,下一步是将其切分为更小的“文本块”(chunks)。这一步对 RAG 系统的性能和效果至关重要。
为什么需要分块?
-
LLM 上下文长度限制
当前主流大语言模型(如 GPT-4、Claude、Llama 等)都有最大 token 输入限制(例如 8K、32K 或 128K)。若直接将整篇长文档送入模型,会超出上下文窗口,导致截断或报错。 -
提升检索精度
向量数据库在检索时,会将用户查询与每个文本块的嵌入向量进行相似度匹配。较小且语义完整的文本块更容易与特定问题对齐,避免无关信息干扰。 -
降低计算与推理成本
RAG 只需将最相关的几个文本块送入 LLM 进行生成,而非整篇文档。这显著减少了 token 消耗和响应延迟,尤其在调用付费 API 时能有效控制成本。
1.1 创建文本分块器#
LangChain 提供了多种文本分块策略。我们首先使用推荐的 递归字符分块器(RecursiveCharacterTextSplitter):
# 创建文本分块器
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=1000, # 每块最大字符数
chunk_overlap=200, # 块之间的重叠
length_function=len, # 长度计算函数
is_separator_regex=False,
)
# 分块文档
splits = text_splitter.split_documents(docs)
print(f"原始文档: {len(docs)} 个")
print(f"分块后: {len(splits)} 个")
print(f"\n第一个分块示例:\n{splits[0].page_content[:200]}...")python输出示例:
原始文档: 79 个
分块后: 99 个
第一个分块示例:
硕士学位论 文
学位申请人姓名 闫腾
学位申请人学号 2200411007
专 业 名 称 机械工程
学 科 门 类 工学
学院(部、研究院) 应用技术学院
导 师 姓 名 李文贤
二〇二五年六月
分类号 学校代码 10590
UDC 密 级 公开
基于视-触觉融合感知的机器人
抓取滑动检测与力控研究
ധᎪն࿐...plaintext💡 chunk_overlap 的作用:通过保留相邻块的部分重叠内容,可减少因切分导致的关键信息丢失(例如一个句子被切成两半),提升后续检索与生成的连贯性。
1.2 分块策略对比#
LangChain 支持多种分块方式,适用于不同场景。下面我们对比四种常见策略:
1. 字符分块(CharacterTextSplitter)#
- 原理:按固定字符数硬切分。
- 优点:实现简单、速度快。
- 缺点:极易切断句子或段落,破坏语义完整性。
- 适用场景:对语义要求不高的粗略处理。
from langchain_text_splitters import CharacterTextSplitter
text_splitter = CharacterTextSplitter(chunk_size=1000, chunk_overlap=200)python2. 递归分块(RecursiveCharacterTextSplitter)✅ 推荐默认#
- 原理:按优先级尝试在语义边界(如
\n\n→\n→ 空格 → 任意字符)处分割,尽可能保持段落完整。 - 优点:兼顾效率与语义,适用于大多数文本(论文、网页、报告等)。
- 缺点:仍基于字符长度,无法精确控制 token 数。
- 适用场景:通用 RAG 项目首选。
from langchain_text_splitters import RecursiveCharacterTextSplitter
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=200,
separators=["\n\n", "\n", " ", ""]
)python3. Token 分块(TokenTextSplitter)#
- 原理:使用指定 tokenizer(如 GPT 的 tiktoken)精确按 token 切分。
- 优点:严格控制输入长度,避免超限。
- 缺点:依赖具体模型的 tokenizer,速度较慢;仍可能切断句子。
- 适用场景:对接特定 LLM 且对 token 预算敏感的任务。
from langchain_text_splitters import TokenTextSplitter
text_splitter = TokenTextSplitter(chunk_size=256, chunk_overlap=50)python4. 语义分块(SemanticChunker)#
- 原理:利用嵌入模型计算句子间语义相似度,在“语义突变点”处分割。
- 优点:块内语义高度一致,检索质量高。
- 缺点:计算开销大,需调用 embedding 模型;分块大小不固定。
- 适用场景:对检索精度要求极高的专业领域(如法律、医疗)。
from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings
embeddings = OpenAIEmbeddings()
text_splitter = SemanticChunker(embeddings)python1.3 分块策略对比总结#
| 策略 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 字符分块 | 简单、快速 | 易切断语义,质量差 | 快速原型、非关键任务 |
| 递归分块 ✅ | 语义友好、高效、通用 | 基于字符,非 token 精确 | 大多数 RAG 项目推荐 |
| Token 分块 | 精确控制 token 长度 | 依赖 tokenizer,可能断句 | 对接特定 LLM,严格 token 限制 |
| 语义分块 | 语义连贯性最佳 | 计算开销大,速度慢 | 高精度检索(法律、科研等) |
接下来,我们将把分块后的文本转换为向量,并存入向量数据库,为检索阶段做准备。
Part2: 向量化:将文本转化为语义向量#
在 RAG 系统中,向量化(Embedding) 是实现语义检索的核心步骤。其目标是将分块后的文本转换为高维数学向量,使得语义相近的文本在向量空间中距离更近,从而支持高效的相似性搜索。
2.1 为什么需要向量化?#
- 超越关键词匹配:传统关键词检索无法理解“苹果”和“水果”的语义关系,而向量嵌入能捕捉深层语义。
- 支持语义搜索:通过计算查询与文档块之间的向量相似度(如余弦相似度),可返回最相关的内容,即使措辞不同。
- 为向量数据库提供输入:后续我们将这些向量存入 FAISS、Chroma、Milvus 等向量数据库,实现毫秒级检索。
2.2 常用嵌入模型对比#
选择合适的嵌入模型对 RAG 效果至关重要。以下是主流模型的横向对比:
| 模型 | 提供商 | 维度 | 成本 | 性能 | 特点 |
|---|---|---|---|---|---|
text-embedding-3-small | OpenAI | 1536 | $ | ⭐ 高性价比 | 通用场景首选,速度快、效果好 |
text-embedding-3-large | OpenAI | 3072 | $$ | 最高质量 | 适合高精度要求任务 |
text-embedding-ada-002 | OpenAI | 1536 | $ | 上一代 | 兼容旧系统,逐渐被 small 替代 |
all-MiniLM-L6-v2 | Hugging Face | 384 | 免费 | 轻量快速 | 适合本地部署,英文为主 |
bce-embedding-base_v1 | 百度 / ModelScope | 768 | 免费 | ⭐ 中文优化 | 中文任务表现优异,推荐本地使用 |
bce-reranker-base_v1 | 百度 | - | 免费 | 重排序专用 | 用于检索后精排,非嵌入模型 |
💡 中文项目建议:
若你的数据以中文为主(如学位论文、技术文档),bce-embedding-base_v1是目前开源免费模型中表现最出色的之一,专为中文语义理解优化,且支持本地 GPU 加速。
2.3 使用 BCE 嵌入模型进行本地向量化#
我们通过 ModelScope 下载百度开源的 bce-embedding-base_v1 模型,并使用 LangChain 封装调用:
from langchain_community.embeddings import HuggingFaceEmbeddings
from modelscope import snapshot_download
import os
# 创建本地模型目录
local_models_dir = "./Models"
os.makedirs(local_models_dir, exist_ok=True)
# 从 ModelScope 下载模型
model_id = "maidalun/bce-embedding-base_v1"
local_model_path = snapshot_download(model_id, cache_dir=local_models_dir)
# 初始化嵌入模型(启用 GPU)
embeddings = HuggingFaceEmbeddings(
model_name=local_model_path,
model_kwargs={"device": "cuda"}, # 使用 GPU 加速
encode_kwargs={"normalize_embeddings": True} # 归一化便于计算余弦相似度
)
# 测试单条文本向量化
text = "RAG是一种强大的AI技术"
vector = embeddings.embed_query(text)
print(f"文本: {text}")
print(f"向量维度: {len(vector)}")
print(f"向量前5个值: {[round(x, 4) for x in vector[:5]]}")python输出结果:
文本: RAG是一种强大的AI技术
向量维度: 768
向量前5个值: [0.0048, 0.0216, -0.005, 0.02, -0.0113]plaintext✅ 成功生成 768 维的语义向量!
2.4 向量相似度验证:语义是否被正确捕捉?#
我们通过余弦相似度验证模型的语义理解能力:
from sklearn.metrics.pairwise import cosine_similarity
import numpy as np
text1 = "苹果是一种水果"
text2 = "香蕉是一种水果"
text3 = "苹果是一种好吃的水果"
# 获取向量
v1 = np.array(embeddings.embed_query(text1)).reshape(1, -1)
v2 = np.array(embeddings.embed_query(text2)).reshape(1, -1)
v3 = np.array(embeddings.embed_query(text3)).reshape(1, -1)
# 计算相似度
sim_1_2 = cosine_similarity(v1, v2)[0][0]
sim_1_3 = cosine_similarity(v1, v3)[0][0]
print(f"「{text1}」 vs 「{text2}」 相似度: {sim_1_2:.4f}")
print(f"「{text1}」 vs 「{text3}」 相似度: {sim_1_3:.4f}")python输出结果:
「苹果是一种水果」 vs 「香蕉是一种水果」 相似度: 0.7481
「苹果是一种水果」 vs 「苹果是一种好吃的水果」 相似度: 0.9040plaintext🔍 分析:
- 两者都提到“水果”,因此相似度较高(>0.7);
- text1 与 text3 主体完全一致(“苹果”),仅增加形容词“好吃”,语义更接近,相似度达 0.904,说明模型有效捕捉了中文语义细微差别。
下一步:构建向量数据库 现在,我们已将每个文本块转换为 768 维向量。接下来,我们将把这些向量与原始文本一起存入 向量数据库(如 FAISS 或 Chroma),为用户查询提供高效、精准的语义检索能力。
Part3: 存储到向量数据库#
在完成文档加载、分块和向量化后,下一步是将这些文本块及其对应的嵌入向量持久化存储到向量数据库中。这是 RAG 系统实现高效语义检索的关键基础设施。
为什么需要向量数据库?
- 快速相似性搜索:面对成千上万的文本块,暴力计算余弦相似度效率极低。向量数据库通过近似最近邻(ANN)算法(如 HNSW、IVF)实现毫秒级检索。
- 元数据关联:除了向量,还能存储原始文本、来源文件、页码等 metadata,便于溯源和结果展示。
- 持久化与复用:一次构建,多次查询,避免重复加载和嵌入计算。
3.1 主流向量数据库对比#
| 数据库 | 类型 | 优势 | 适用场景 |
|---|---|---|---|
| Chroma | 嵌入式 | 简单易用,无需额外服务,LangChain 深度集成 | ✅ 开发、小规模应用、本地原型 |
| Pinecone | 云服务 | 高性能、全托管、自动扩缩容 | 生产环境(需网络 & 账号) |
| Weaviate | 自建/云 | 功能丰富(支持 GraphQL、分类、生成),开源 | 大规模部署、企业级应用 |
| FAISS | 库(非数据库) | 速度快,Meta 开源,适合研究 | 离线实验、临时索引 |
🚀 本项目选择 Chroma:因其轻量、本地运行、与 LangChain 无缝对接,非常适合学术论文类 RAG 应用的开发与测试。
3.2 批量加载目录下所有 PDF 并预处理#
我们的目标是将 ./Dataset/PDF/ 目录下的 10 篇机器人抓取相关硕士论文统一加载、分块、清洗并存入向量库。
步骤 1:递归加载所有 PDF#
import os
from langchain_community.document_loaders import PyPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
def load_all_pdfs_from_directory(directory_path="./Dataset/PDF/"):
"""加载目录下所有PDF文件,并为每个分块添加唯一ID"""
all_splits = []
pdf_files = []
if not os.path.exists(directory_path):
print(f"❌ 目录不存在: {directory_path}")
return all_splits, pdf_files
# 获取所有 PDF 文件
for filename in os.listdir(directory_path):
if filename.lower().endswith('.pdf'):
pdf_files.append(filename)
if not pdf_files:
print(f"⚠️ 目录中没有找到PDF文件: {directory_path}")
return all_splits, pdf_files
print(f"📁 找到 {len(pdf_files)} 个PDF文件:")
# 创建分块器(注意:此处 chunk_size=100 是示例,实际可调大)
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=100,
chunk_overlap=20,
length_function=len,
)
global_chunk_index = 0
for filename in pdf_files:
file_path = os.path.join(directory_path, filename)
base_name = os.path.splitext(filename)[0]
try:
print(f"📖 正在加载: {filename}")
loader = PyPDFLoader(file_path)
docs = loader.load()
splits = text_splitter.split_documents(docs)
for i, split in enumerate(splits):
page_num = split.metadata.get('page', 'unknown')
split_id = f"{base_name}_p{page_num}_c{i}"
split.metadata.update({
'id': split_id,
'source_file': filename
})
all_splits.append(split)
print(f"✅ {filename}: {len(docs)}页 -> {len(splits)}个分块")
except Exception as e:
print(f"❌ 加载失败 {filename}: {str(e)}")
print(f"🎉 总共加载 {len(all_splits)} 个文本分块(均已添加 id)")
return all_splits, pdf_files
# 执行加载
splits, loaded_files = load_all_pdfs_from_directory("./Dataset/PDF/")python实际输出:
📁 找到 10 个PDF文件:
✅ ...(略)
🎉 总共加载 12729 个文本分块(均已添加 id)plaintext⚠️ 注意:
chunk_size=100在演示中偏小(导致分块数多),实际建议设为 500–1000 字符 以平衡粒度与上下文完整性。
步骤 2:清洗非法 Unicode 字符(关键!)#
在尝试保存到 Chroma 时,我们遇到了以下错误:
UnicodeEncodeError: 'utf-8' codec can't encode characters in position 795-798: surrogates not allowedplaintext这是因为某些 PDF 解析后包含 非法代理字符(surrogate characters),而 ChromaDB(底层基于 Rust)要求所有字符串必须是合法 UTF-8。
解决方案:文本清洗函数#
def clean_text(text: str) -> str:
"""移除无法编码为 UTF-8 的非法字符,并清理首尾空白"""
return text.encode('utf-8', errors='ignore').decode('utf-8').strip('\n').strip()
# 对所有分块内容进行清洗
for doc in splits:
doc.page_content = clean_text(doc.page_content)python✅ 清洗后即可安全写入 Chroma。
步骤 3:智能创建并填充 Chroma 向量库#
为避免重复创建或覆盖问题,我们设计一个“智能初始化”函数:
from langchain_chroma import Chroma
def smart_vectorstore_creation(documents, embeddings, persist_directory="./chroma_db", collection_name="langchain"):
"""智能向量数据库创建:自动清理旧数据,分批写入"""
print(f"🔧 处理集合: {collection_name}")
os.makedirs(persist_directory, exist_ok=True)
try:
# 尝试连接现有集合
vectorstore = Chroma(
collection_name=collection_name,
persist_directory=persist_directory,
embedding_function=embeddings
)
collection_info = vectorstore.get()
if collection_info['ids']:
print(f"🗑️ 清理集合中的 {len(collection_info['ids'])} 个文档")
vectorstore.delete(ids=collection_info['ids'])
else:
print("✅ 集合为空,无需清理")
except Exception:
print(f"🆕 集合不存在,将创建新集合")
vectorstore = None
# 分批写入(Chroma 单次写入不宜过大)
total_docs = len(documents)
batch_size = 1000
for i in range(0, total_docs, batch_size):
batch = documents[i:i + batch_size]
batch_num = i // batch_size + 1
total_batches = (total_docs - 1) // batch_size + 1
print(f"📦 添加批次 {batch_num}/{total_batches}: {len(batch)} 个文档")
if vectorstore is None and i == 0:
vectorstore = Chroma.from_documents(
documents=batch,
embedding=embeddings,
persist_directory=persist_directory,
collection_name=collection_name
)
else:
vectorstore.add_documents(batch)
print(f"✅ 完成!集合 '{collection_name}' 现有 {total_docs} 个文档")
return vectorstore
# 执行存储
vectorstore = smart_vectorstore_creation(splits, embeddings, collection_name="robot_grasping_rag")python执行日志:
🔧 处理集合: robot_grasping_rag
✅ 集合为空,无需清理
📦 添加批次 1/13: 1000 个文档
...
📦 添加批次 13/13: 729 个文档
✅ 完成!集合 'robot_grasping_rag' 现有 12729 个文档plaintext💾 数据已持久化到
./chroma_db/目录,下次可直接加载复用,无需重新嵌入!
下一步:语义检索与问答
现在,我们的 10 篇中文论文已被切分为 12,729 个清洗后的文本块,并成功存入 Chroma 向量数据库。接下来,我们将实现:
- 用户自然语言查询的向量化
- Top-K 语义相似块检索
- 将检索结果注入 LLM 生成最终答案
这是 RAG 的核心流程!
Part4: 检索#
向量数据库构建完成后,RAG 系统的核心能力之一——语义检索——正式启用。这一步的目标是:根据用户自然语言问题,从海量文档块中精准召回最相关的上下文片段,为后续大模型生成答案提供高质量依据。
4.1 常见检索策略对比#
LangChain 支持多种检索方式,适用于不同场景:
| 策略 | 配置示例 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|---|
| 相似度搜索 (Similarity) | search_type="similarity", k=5 | 简单快速,直接返回最相关结果 | 可能返回高度重复内容 | 默认首选,通用问答 |
| 最大边际相关性 (MMR) | search_type="mmr", k=5, fetch_k=20, lambda_mult=0.5 | 平衡相关性与多样性 | 计算稍慢 | 需要多角度信息(如综述类问题) |
| 相似度阈值过滤 | search_type="similarity_score_threshold", score_threshold=0.5 | 过滤低质量结果,保证精度 | 可能无结果返回 | 对答案可靠性要求极高 |
MMR 工作原理示意
以查询 “机器学习算法” 为例:
-
第一步:先用相似度搜索获取 top-20 候选:
- #1: “深度学习是机器学习的一个分支…” (相似度 0.95)
- #2: “深度学习使用神经网络…” (0.94,与 #1 高度重合)
- #3: “决策树是一种机器学习算法…” (0.90)
- #4: “支持向量机(SVM)用于分类…” (0.88)
-
第二步:MMR 选择时,优先选 #1(最相关),然后跳过 #2(太相似),转而选择 #3 和 #4 以增加主题多样性。
💡 在学术论文检索中,MMR 尤其有用——避免只返回同一章节的重复段落。
4.2 加载向量库并初始化检索器#
我们首先从磁盘加载已持久化的 Chroma 数据库:
import os
from langchain_chroma import Chroma
if os.path.exists("./chroma_db"):
vectorstore = Chroma(
persist_directory="./chroma_db",
embedding_function=embeddings
)
collection = vectorstore._collection
print(f"✅ 向量数据库加载成功!")
print(f" 已存在 {collection.count()} 个文档块")
print(f" Collection 名称: {collection.name}")python输出:
✅ 向量数据库加载成功!
已存在 12721 个文档块
Collection 名称: langchainplaintext📌 向量数据库 vs Collection 类比
- 向量数据库 ≈ MySQL 实例(如
my_company_db)- Collection ≈ 数据表(如
papers表)- 每条记录 = 文本块 + 向量 + 元数据(来源、页码、ID)
接着创建基础检索器(返回 top-5):
retriever = vectorstore.as_retriever(
search_type="similarity",
search_kwargs={"k": 5}
)python4.3 执行检索并清理乱码文本#
PDF 解析常引入排版残留字符(如 , , 多余换行等),需在展示前清洗。
增强版中文文本清洗函数
import re
def clean_text(text: str) -> str:
"""
移除中文字符之间的任意空白(包括全角空格、不间断空格等),
同时保留英文/数字间的正常空格。
"""
chinese_char = r'[\u4e00-\u9fff]'
any_whitespace = r'[\s\u00A0\u2000-\u200F\u2028-\u202F\u3000]+'
pattern = f'({chinese_char}){any_whitespace}(?={chinese_char})'
result = re.sub(pattern, r'\1', text) # 中文间空白直接删除
result = re.sub(r'\s+', ' ', result) # 其他区域合并多余空格
return result.strip()python检索示例:查询“什么是视触觉?”
query = "什么是视触觉?"
docs = retriever.invoke(query)
for i, doc in enumerate(docs, 1):
cleaned_content = clean_text(doc.page_content)
source = doc.metadata.get('source_file', 'N/A')
doc_id = doc.metadata.get('id', 'N/A')
print(f"\n📄 结果 {i}:")
print(f"内容: {cleaned_content[:300]}...")
print(f"来源: {source}")
print(f"ID: {doc_id}")python实际输出节选:
📄 结果 1:
内容: perception 皮肤。使用一种被称为Gelsight的触觉传感器作为机器人的指尖触觉感受器...
来源: 基于触觉图像序列的机器人抓取目标状态感知_韩筱.pdf
ID: 基于触觉图像序列的机器人抓取目标状态感知_韩筱_p23_c383
📄 结果 4:
内容: 人们对一个物体的描述往往是从多个角度去描述的,依靠的是多个器官的共同感知,其中最重要的是视觉感知和触觉感知...
来源: 基于视触觉融合的机械手分类抓取方法研究_余航.pdfplaintext✅ 成功召回多篇论文中关于“视触觉融合”的定义与技术描述!
5.4 高级检索:自定义检索器#
基础检索有时不够精准。我们可通过以下四步构建高级检索流水线:
- 扩大候选范围(如 k=10)
- 按元数据过滤(如仅限某作者论文)
- 重排序(用更精细打分模型)
- 返回 Top-K
def custom_retriever(question: str) -> list:
# 1. 扩大检索范围
base_docs = vectorstore.similarity_search(question, k=10)
# 2. 按来源过滤(例如只看“张静”的论文)
filtered_docs = [
doc for doc in base_docs
if "张静" in doc.metadata.get('source_file', '')
]
# 3. 重排序(此处简化为关键词匹配,实际可用 BCE-Reranker)
def calculate_relevance_score(doc, query):
return sum(1 for word in query if word in doc.page_content)
scored_docs = [(doc, calculate_relevance_score(doc, question)) for doc in filtered_docs]
scored_docs.sort(key=lambda x: x[1], reverse=True)
# 4. 返回 top-5
return [doc for doc, _ in scored_docs[:5]]python🔍 进阶建议:可集成
bce-reranker-base_v1(百度开源重排序模型)对初检结果重新打分,显著提升 top-1 准确率。
5.5 评估检索质量#
仅靠人工判断不够客观。我们设计简单指标评估检索器性能:
def evaluate_retrieval(retriever, test_cases):
metrics = {"precision": [], "recall": []}
for query, expected_ids in test_cases:
retrieved = retriever.invoke(query)
retrieved_ids = [d.metadata['id'] for d in retrieved]
relevant = set(retrieved_ids) & set(expected_ids)
precision = len(relevant) / len(retrieved_ids) if retrieved_ids else 0
recall = len(relevant) / len(expected_ids) if expected_ids else 0
metrics["precision"].append(precision)
metrics["recall"].append(recall)
return {
"avg_precision": sum(metrics["precision"]) / len(metrics["precision"]),
"avg_recall": sum(metrics["recall"]) / len(metrics["recall"])
}
# 测试用例
test_cases = [
("什么是抓取检测?", [
"基于视触感知协同的多指灵巧手抓取方法研究_张静_p164_c3266",
"基于视触感知协同的机器人抓取技术研究_祝会龙_p39_c500",
# ...其他相关 ID
]),
("什么是机械臂?", ["基于触觉图像序列的机器人抓取目标状态感知_韩筱_p25_c407"]),
]
results = evaluate_retrieval(retriever, test_cases)
print(f"平均精确率: {results['avg_precision']:.2%}")
print(f"平均召回率: {results['avg_recall']:.2%}")python输出:
平均精确率: 40.00%
平均召回率: 87.50%plaintext🔍 分析:
- 高召回率:说明系统能覆盖大部分相关文档(不错过重要信息);
- 较低精确率:部分返回结果不相关,可能因 chunk_size 过小或 PDF 噪声干扰。
✅ 优化方向:增大分块粒度、引入重排序、添加元数据过滤(如排除目录页)。
下一步:整合 LLM 生成答案
现在,我们已能从 10 篇中文论文中高效检索相关内容。下一步将把这些上下文注入大语言模型(如 Qwen、ChatGLM),让其基于真实文献回答用户问题,真正实现 “有据可依” 的智能问答系统。
Part5: 生成:基于检索结果让 LLM 生成精准答案#
检索到相关文档后,RAG 系统进入最终环节——生成。这一步的目标是:将检索到的上下文信息与大语言模型的知识相结合,生成准确、流畅且基于事实的答案。
5.1 本地问答系统架构设计#
我们构建了一个完整的本地问答系统 LocalQASystem,核心组件包括:
| 组件 | 技术选型 | 作用 | 关键配置 |
|---|---|---|---|
| 对话模型 | Qwen-7B-Chat-Int8 + vLLM | 生成答案 | GPTQ量化,推理加速 |
| 嵌入模型 | bce-embedding-base_v1 | 查询向量化 | 与构建时保持一致 |
| 向量数据库 | ChromaDB | 存储和检索 | 持久化存储 |
系统初始化流程
class LocalQASystem:
def __init__(self, model_dir, chroma_db_path="./chroma_db", embeddings_model_path=None):
self.model_dir = self._setup_model_dir(model_dir) # 模型路径处理
self.chroma_db_path = chroma_db_path
self.embeddings_model_path = embeddings_model_path
self._setup_model() # 初始化vLLM
self._setup_exact_embeddings() # 关键:使用相同嵌入模型
self._setup_vectorstore() # 加载向量库python初始化输出:
📁 使用本地模型: ../Qwen-vllm/Models/Qwen/Qwen-7B-Chat-Int8
🤖 vLLM模型初始化完成
✅ 使用嵌入模型: ./Models/maidalun/bce-embedding-base_v1
🔢 嵌入模型维度: 768
🗂️ 向量数据库加载成功: ./chroma_db
📄 文档数量: 12721
✅ 问答系统初始化完成plaintext⚠️ 关键点:必须使用与构建向量库时完全相同的嵌入模型,否则向量空间不一致会导致检索失败!
5.2 问答流程四步走#
步骤1:检索相关文档#
def retrieve_with_exact_embedding(self, query, n_results=5):
"""使用精确匹配的嵌入模型进行检索"""
query_embedding = self.embeddings.embed_query(query) # 关键步骤!
results = self.collection.query(
query_embedding=[query_embedding], # 使用相同模型生成向量
n_results=n_results
)
return resultspython步骤2:上下文清洗与预处理#
PDF解析常产生格式问题,需专门清洗:
def _clean_context(self, text):
"""清洗PDF解析产生的格式问题"""
# 1. 合并被错误分割的文字(如:\n运\n动\n → 运动)
cleaned = re.sub(r'(?<=[^\s])\n(?=[^\s])', '', text)
# 2. 处理多余空白和空行
cleaned = re.sub(r'\n\s+\n', '\n\n', cleaned)
return cleaned.strip()python清洗效果对比:
- 清洗前:
机\n器\n人\n 抓\n取\n 技\n术\n 研\n究 - 清洗后:
机器人抓取技术研究
步骤3:构建精准提示词(Prompt Engineering)#
采用ChatML格式,明确约束LLM行为:
def _build_prompt(self, question, context):
cleaned_context = self._clean_context(context)
return f"""<|im_start|>system
你是一个专业的AI助手。请严格基于以下上下文信息回答问题:
{cleaned_context}
请遵循以下规则:
1. 只使用上下文中的信息回答
2. 如果上下文不包含相关信息,请回答"我不知道"
3. 保持回答准确、简洁
4. 不要编造信息<|im_end|>
<|im_start|>user
{question}<|im_end|>
<|im_start|>assistant
"""python💡 提示词设计原则:明确角色、限定知识范围、设定回答规则、防止幻觉。
步骤4:vLLM高效生成答案#
def _generate_answer(self, prompt, max_tokens, temperature):
sampling_params = SamplingParams(
max_tokens=max_tokens, # 控制生成长度
temperature=temperature, # 控制随机性(0.1-0.3更确定)
top_p=0.8, # 核采样,提高相关性
stop=["<|im_end|>", "<|endoftext|>"] # 停止标记
)
outputs = self.llm.generate([prompt], sampling_params)
return outputs[0].outputs[0].textpython5.3 实战测试:验证系统效果#
questions = [
"什么是机械臂?它有什么能力?",
"解释一下滑动检测的基本概念",
]
print("🚀 开始测试问答系统")
print("=" * 70)
for i, question in enumerate(questions, 1):
print(f"\n🎯 问题 {i}: {question}")
print("-" * 50)
result = qa_system.ask(question, max_tokens=400, temperature=0.3)
print(f"💡 答案: {result['answer']}")
print(f"📚 参考文档: {result['sources']} 个")python实际输出:
🚀 开始测试问答系统
======================================================================
🎯 问题 1: 什么是机械臂?它有什么能力?
--------------------------------------------------
🔍 查询嵌入维度: 768
✅ 检索到 5 个相关文档
💡 答案: 机械臂是一种可以自动执行任务的机器人手臂,它具有精确度高、可重复性好、负载能力强、工作半径大、自由度多等能力。
📚 参考文档: 5 个
🎯 问题 2: 解释一下滑动检测的基本概念
--------------------------------------------------
🔍 查询嵌入维度: 768
✅ 检索到 5 个相关文档
💡 答案: 滑动检测是一种用于检测物体是否发生滑动的算法。它通过比较帧间的标准差来判断物体是否发生滑动。如果帧间的标准差超过设定的阈值,那么就认为物体发生了滑动。滑动检测算法通常用于机械臂的抓取任务中,以防止物体在抓取过程中滑落。
📚 参考文档: 5 个plaintext✅ 成功指标:
- 答案准确基于论文内容(非模型固有知识)
- 回答简洁专业,符合学术规范
- 检索到多个相关文档作为支撑
5.4 进阶话题:Chain Types 详解#
LangChain 提供了多种文档处理链类型,适用于不同场景:
5.4.1 四种链类型对比#
| 链类型 | 工作原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| Stuff | 所有文档拼接后一次性提问 | 简单高效,一次LLM调用 | 文档多时会超长 | 文档少(<10),默认首选 |
| Map-Reduce | 先分别处理每个文档,再合并答案 | 可处理大量文档,支持并行 | 成本高,丢失文档关联 | 文档非常多时 |
| Refine | 迭代处理,用后续文档改进答案 | 答案质量高,保持关联 | 顺序敏感,不能并行 | 需要高质量答案 |
| Map-Rerank | 对每个文档生成答案并评分,选最佳 | 自动选择最相关答案 | 每个文档都需LLM调用 | 找最准确答案 |
5.4.2 链类型选择指南#
# 根据场景选择合适的链类型
chain_configs = {
"default": {"chain_type": "stuff", "k": 5},
"many_docs": {"chain_type": "map_reduce", "k": 20},
"high_quality": {"chain_type": "refine", "k": 8},
"most_relevant": {"chain_type": "map_rerank", "k": 10}
}
def select_chain_type(scenario, document_count):
if document_count > 15:
return "map_reduce" # 文档太多,需要分治
elif scenario == "precision_critical":
return "map_rerank" # 精度要求高
elif scenario == "quality_first":
return "refine" # 质量优先
else:
return "stuff" # 默认选择python🔍 实践建议:从
stuff开始测试,如遇上下文长度问题再切换到map_reduce。
5.5 生成质量评估与优化#
评估指标#
- 相关性:答案是否直接回应问题
- 准确性:是否基于提供的上下文
- 完整性:是否覆盖问题的关键方面
- 可读性:语言是否流畅自然
常见问题与解决方案#
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 答案与上下文无关 | 提示词约束不够强 | 加强system提示词约束 |
| 出现”幻觉”信息 | 温度参数过高 | 降低temperature(0.1-0.3) |
| 答案过于简短 | max_tokens设置太小 | 适当增加生成长度 |
| 包含无关内容 | 检索文档不相关 | 优化检索策略,增加重排序 |
# 质量优化配置
optimized_params = {
"temperature": 0.2, # 降低随机性,提高确定性
"top_p": 0.85, # 平衡相关性和多样性
"max_tokens": 512, # 保证答案完整
"stop_tokens": ["<|im_end|>", "\n\n"] # 合理终止
}python总结:RAG 流程闭环#
至此,我们完成了完整的 RAG 流水线:
- 文档处理 → PDF解析、文本分块、向量化
- 向量存储 → ChromaDB持久化存储
- 语义检索 → 相似度搜索、MMR多样性优化
- 答案生成 → 提示词工程、vLLM高效推理
✅ 核心成就:构建了一个能够基于10篇中文学术论文进行有据可查、准确可靠的智能问答系统。系统完全在本地运行,保障数据安全,且答案可追溯至具体文献来源。