RAG全链路(数据清洗、切分、Embedding、向量库、召回、重排、答案生成)的设计逻辑
离线阶段:文档 → 清洗 → 切分 → Embedding → 向量库
1)文档加载
python
import os
import fitz # PyMuPDF
from loguru import logger
from tqdm import tqdm
log_dir = "log"
if not os.path.exists(log_dir):
os.makedirs(log_dir)
logger.add(
f"{log_dir}/pdf_load.log",
rotation="200 MB",
retention=1,
level="INFO",
format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {message}",
encoding="utf-8")
2)数据清洗
python
import re
#页级清洗:过滤版权/图片授权页
NOISE_KEYWORDS = [
"unsplash license",
"creative commons",
"attribution",
"photo by",
"image by",
"reused",
"license",
"copyright",
"all rights reserved",
]
def is_noise_page(text):
lower_text = text.lower()
hit_count = sum(keyword in lower_text for keyword in NOISE_KEYWORDS)
# 如果一页里多个版权/授权关键词同时出现,大概率是噪声页
if hit_count >= 3:
return True
# URL 很多的页通常是引用/授权页
url_count = len(re.findall(r"https?://|www\.", lower_text))
if url_count >= 5:
return True
return False
#过滤空格等特殊字符,合并多个换行符
def clean_text(text):
text = text.replace("\xa0", " ")
text = re.sub(r"[ \t]+", " ", text)
text = re.sub(r"(?<!\n)\n(?!\n)", " ", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
def load_pdf(pdf_path):
try:
pages = []
with fitz.open(pdf_path) as doc:
logger.info(f"开始加载PDF: {pdf_path}, 总页数: {doc.page_count}")
for page_index, page in tqdm(enumerate(doc), total=doc.page_count):
text = clean_text(page.get_text())
if not text:
logger.info(f"第{page_index}页为空,跳过")
continue
if is_noise_page(text):
logger.info(f"第{page_index}页为噪声页,跳过")
continue
pages.append({
"page": page_index,
"text": text
})
logger.info(f"PDF加载完成,有效页数: {len(pages)}")
return pages
except Exception as error:
logger.exception(f"加载PDF失败: {pdf_path}, error: {error}")
raise
pages = load_pdf("rag/simple-local-rag/human-nutrition-text.pdf")
3)文本切分
python
方法一:
from sentence_transformers import SentenceTransformer
def split_text(text, chunk_size=300):
# 简单按字符数切分,可用更智能的分句/分段
return [text[i:i+chunk_size] for i in range(0, len(text), chunk_size)]
chunks = []
for page in pages:
for chunk in split_text(page["text"]):
if len(chunk.strip()) > 50: # 过滤过短片段
chunks.append({"page": page["page"], "text": chunk})
chunks
方法二:
from langchain_text_splitters import CharacterTextSplitter
text_splitter = CharacterTextSplitter(
chunk_size=300,#切分长度
chunk_overlap=5 #相邻两个chunks之间的重叠token数量
)
all_text = "\n".join([p["text"] for p in pages])
chunks = text_splitter.split_text(all_text)
chunks
4)向量化与存储
python
import numpy as np
from sentence_transformers import SentenceTransformer
#文档向量化
model = SentenceTransformer("all-mpnet-base-v2")
doc_vectors = model.encode([c["text"] for c in chunks], normalize_embeddings=True)
doc_vectors = np.array(doc_vectors)
# 构建 FAISS 索引
import faiss
dim = doc_vectors.shape[1]
index = faiss.IndexFlatIP(dim) # 精确搜索,内积
index.add(doc_vectors)
#保存索引
faiss.write_index(index, "/opt/cyc/rag/simple-local-rag/docs.index")
在线阶段:问题 → Embedding → 召回 → 重排 → 拼上下文 → LLM 生成答案
1)问题 → Embedding → 召回
python
# 查询
query = "怎么保持饮食健康?"
query_vector = model.encode([query], normalize_embeddings=True)
query_vector = np.asarray(query_vector, dtype="float32")
texts = [d["text"] for d in pages]
# 搜索 top-k
k = 3
scores, ids = index.search(query_vector, k)
print(f"Query: {query}\n")
for score, idx in zip(scores[0], ids[0]):
print(f"score={score:.4f}, doc={texts[idx]}")
2)重排 → 拼上下文 → LLM 生成答案
python
from anthropic import Anthropic
import numpy as np
client = Anthropic()
def bge_reranker_matched(text1, text2, threshold=0.01, ratio_threshold=0.01):
"""接口仅支持 1 对多:返回得分>=threshold 的候选占比>=ratio_threshold 的 text1 元素。"""
bge_url=bge_url
bge_token=bge_token
headers = {
"Content-Type": "application/json",
"Authorization": bge_token
}
matched = []
for d in text2:
data = {
"model": "bge-reranker-v2-m3",
"text_1": text1,
"text_2": d
}
response = requests.post(bge_url, json=data, headers=headers)
res = response.json().get("data") or []
if not res:
continue
hit_count = sum(item.get("score", 0) >= threshold for item in res)
if hit_count / len(res) >= ratio_threshold:
matched.append(d)
return matched
def answer_with_rag(query, model, index, pages, k=3):
# 1. query -> vector
records = chunks
texts = [d["text"] for d in records]
doc_vectors = model.encode(
texts,
normalize_embeddings=True,
show_progress_bar=True
)
doc_vectors = np.asarray(doc_vectors, dtype="float32")
query_vector = model.encode([query], normalize_embeddings=True)
query_vector = np.asarray(query_vector, dtype="float32")
# 2. retrieve
k = 3
scores, ids = index.search(query_vector, k)
retrieved = []
for score, idx in zip(scores[0], ids[0]):
retrieved.append({
"score": float(score),
"page": pages[idx]["page"],
"text": pages[idx]["text"]
})
#3.精排
context = bge_reranker_matched(query, retrieved)
# 4. generate
prompt = f"""
你是一个基于资料回答问题的助手。
请只根据"参考资料"回答,不要编造。
如果参考资料不足以回答,就明确说"参考资料不足"。
用户问题:
{query}
参考资料:
{context}
请输出:
1. 简洁答案
"""
resp = client.messages.create(
model="claude-sonnet-5",
max_tokens=500,
messages=[{"role": "user", "content": prompt}]
)
return {
"answer": resp.content[0].text,
"retrieved": retrieved
}
answer_with_rag("蛋白质的主要功能是什么?", model, index, pages)
了解SFT、LoRA、RLHF等技术的适用场景,对比RAG与微调的成本、效果、维护难度差异。
SFT(Supervised Fine-Tuning) :一种训练方式(有监督微调)
- 用"输入 → 标准输出"的标注数据去教模型
LoRA :一种参数微调方法
- 不直接全量改模型参数,只训练少量"适配层/低秩矩阵"
微调(Fine-Tuning)
├── SFT
│
├── DPO
│
├── PPO(RLHF 里常用的一种优化算法,注:RLHF 是一整套对齐流程)
│
└── ORPO
SFT
问题:什么是ROI?
标准答案:ROI是投资回报率,用于衡量...
DPO问题:什么商品适合圣诞节促销?
回答A:应该选择具有明显圣诞节消费场景的商品,并结合历史销量...
回答B:所有商品都应该在圣诞节促销。
人工标注:A > B
RLHF
- SFT 先做监督微调
- 训练奖励模型(Reward Model)
- 用强化学习优化策略 ,常见就是 PPO
PPO(强化学习算法)
- 根据奖励模型的分数
- 继续优化模型输出
参数更新方式
├── Full Fine-Tuning
├── LoRA
├── QLoRA
├── AdaLoRA
└── DoRA
适用场景
| 技术 | 主要作用 | 适合场景 | 不适合场景 |
|---|---|---|---|
| SFT(监督微调) | 让模型学会特定任务格式、风格、流程 | 客服话术、结构化输出、领域问答、工具调用格式、固定写作风格 | 频繁变化的知识库、强实时性事实 |
| LoRA | 低成本做微调 | 预算有限、数据量中小、要快速试验、要多个版本并存 | 需要极致性能且愿意全量训练时 |
| RLHF | 对齐人类偏好,改善"好不好用" | 语气、礼貌、安全性、减少胡说、提升回答偏好一致性 | 单纯补知识、快速迭代事实内容 |
| DPO/ORPO(常见替代) | 更简单的偏好优化 | 想要类似 RLHF 的效果,但训练更简化 | 复杂奖励建模场景 |
对比差异
| 维度 | RAG | 微调(SFT/LoRA) |
|---|---|---|
| 成本 | 通常较低到中等 | 中等到高 |
| 数据准备 | 文档清洗、切分、向量化 | 需要高质量指令数据/对话数据 |
| 训练成本 | 无训练或很少训练 | 要训练,LoRA 比全量便宜很多 |
| 推理成本 | 通常更高一些(检索 + 生成) | 通常更低、更稳定 |
| 效果 | 最新知识和可追溯性以及幻觉减少更强、复杂行为学习一般 | 幻觉取决于训练质量、复杂行为学习更好 |
| 维护难度 | 改知识库、文档/索引版本管理、容易定位是检索还是生成问题、加文档即可扩展 | 需要重新训练、新能力需要加数据重训 |