- 🍨 本文为🔗365天深度学习训练营 中的学习记录博客
- 🍖 原作者:K同学啊
Day 1:Re-ranking
Step 0: 为什么需要 Re-ranking
0.1朴素RAG的问题
python
query: "What optimizer did the paper use?"
向量检索 top-5:
1. "The model was trained..." (相似度 0.62) ← 真正答案
2. "We trained for 3.5 days on 8 P100 GPUs..." (0.58) ← 噪⾳(实验细节) 3. "Adam optimizer with β1=0.9..." (0.55) ← 真正答案
4. "Section 5: Training..." (0.51) ← 噪⾳
5. "Previous work used SGD..." (0.48) ← 噪⾳
直接喂给 LLM:5 段⾥ 2 段真 + 3 段噪⾳ → LLM 可能答⾮所问
Re-ranking解决
python
向量检索 top-5(粗排):
1. "The model was trained..." (0.62)
2. "We trained for 3.5 days..." (0.58)
3. "Adam optimizer with β1=0.9..." (0.55) 4. "Section 5: Training..." (0.51)
5. "Previous work used SGD..." (0.48)
Cross-Encoder 精排(更准的模型):
1. "Adam optimizer with β1=0.9..." (0.95) ← 精排 top1 2. "The model was trained..." (0.88) ← 精排 top2 3. "We trained for 3.5 days..." (0.45) ← 噪⾳被降权 4. "Previous work used SGD..." (0.32) ← 噪⾳
5. "Section 5: Training..." (0.21) ← 噪⾳
0.2⼀句话定义
Re-ranking=⽤更强的模型给top-K重新打分,挑出真正最相关的⼏条。 Re-ranking不是"重新检索"------⽽是"在已经检索出来的top-K⾥挑最好的"。Re-ranking的本质=⽤时间换质量。
Step1:理解Bi-EncodervsCross-Encoder

Cross-Encoder看到query和doc之间的关系------能理解"query'Whatoptimizer'和doc'Adamoptimizer'"之间的语义匹配。Bi-Encoder只能各⾃⽐较------"query是什么"和"doc是什么"分开看,再算相似度。 Cross-Encoder缺点:每次(query,doc)对都要跑⼀次模型------top-1000精排=1000次模型调⽤。所以只对top-K(K=20-50)做精排。
Step2:3个Re-ranker实战
写rerank_demo.py
python
# rerank_demo.py 作者:K同学啊
"""
Day 1:3 个 Re-ranker 对⽐ demo
"""
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.joinpath("week04"))) sys.path.insert(0, str(Path(__file__).parent))
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma from dotenv import load_dotenv
import os
from embedder import get_embeddings
load_dotenv()
# === 准备数据 ===
def prepare_data():
"""加载 W4 建好的 Chroma"""
embeddings = get_embeddings()
vs = Chroma( persist_directory="outputs/week04/chroma_db", embedding_function=embeddings, collection_name="papers",
)
return vs
# === 1. FlashRank(本地,最快)===
def rerank_with_flashrank(query: str, docs, top_k: int = 3) -> list[dict]:
"""⽤ FlashRank 精排"""
from langchain.retrievers.document_compressors import FlashrankRerank from langchain.retrievers import ContextualCompressionRetriever
compressor = FlashrankRerank(top_n=top_k)
# 准备 retriever
vs = prepare_data()
base_retriever = vs.as_retriever(search_kwargs={"k": 20}) # 粗排 20
compression_retriever = ContextualCompressionRetriever( base_compressor=compressor,
base_retriever=base_retriever,
)
reranked_docs = compression_retriever.invoke(query)
return [
{
"content": d.page_content[:150],
"page": d.metadata.get("page", "?"),
"score": d.metadata.get("relevance_score", "?"), # FlashRank 写⼊
metadata
}
for d in reranked_docs ]
# === 2. CohereRerank(API,最准)===
def rerank_with_cohere(query: str, docs, top_k: int = 3) -> list[dict]:
"""⽤ Cohere 精排"""
from langchain.retrievers.document_compressors import CohereRerank
compressor = CohereRerank(
cohere_api_key=os.getenv("COHERE_API_KEY"),
model="rerank-english-v3.0", # 或 "rerank-multilingual-v3.0"(⽀持中⽂)
top_n=top_k,
)
from langchain.retrievers import ContextualCompressionRetriever vs = prepare_data()
base_retriever = vs.as_retriever(search_kwargs={"k": 20}) compression_retriever = ContextualCompressionRetriever( base_compressor=compressor,
base_retriever=base_retriever,
)
reranked_docs = compression_retriever.invoke(query)
return [
{
"content": d.page_content[:150],
"page": d.metadata.get("page", "?"),
"score": d.metadata.get("relevance_score", "?"), }
for d in reranked_docs
]
# === 3. BGE-Reranker(本地,中⽂友好)===
def rerank_with_bge(query: str, docs, top_k: int = 3) -> list[dict]:
"""⽤ BGE-Reranker 精排"""
# BGE-Reranker 暂时需要 sentence_transformers 直调
# LangChain 1.x 还在集成中,W5 ⽤ sentence_transformers 调
from sentence_transformers import CrossEncoder
model = CrossEncoder("BAAI/bge-reranker-base", max_length=512)
# 准备 (query, doc) 对
pairs = [[query, d.page_content] for d in docs]
# 精排
scores = model.predict(pairs)
# 排序
ranked = sorted(zip(scores, docs), key=lambda x: x[0], reverse=True) top = ranked[:top_k]
return [
{
"content": d.page_content[:150],
"page": d.metadata.get("page", "?"), "score": float(s),
}
for s, d in top
]
# === 演⽰ ===
if __name__ == "__main__":
query = "What optimizer did the paper use?"
# 粗排(向量检索 top-20)
vs = prepare_data()
coarse_docs = vs.similarity_search(query, k=20)
print(f"=== 粗排:top-20 ===\n")
for i, d in enumerate(coarse_docs[:5], 1): # 只看 top 5 print(f" [{i}] 第 {d.metadata.get('page', '?')} ⻚ |
{d.page_content[:80]}...")
print(f" ... (还有 15 段)\n")
# 3 个 Re-ranker 对⽐(FlashRank/Cohere ⾃⼰重做粗排;BGE ⽤传⼊的粗排) for name, func in [
("FlashRank", lambda q, d, k: rerank_with_flashrank(q, d, k)), ("CohereRerank", lambda q, d, k: rerank_with_cohere(q, d, k)), ("BGE-Reranker", rerank_with_bge),
]:
print(f"=== {name} 精排(top-3)===")
try:
results = func(query, coarse_docs, top_k=3)
for i, r in enumerate(results, 1):
print(f" [{i}] 分数 {r['score']:.4f} | 第 {r['page']} ⻚")
print(f" {r['content']}...")
print()
except Exception as e:
print(f" ❌ 错误:{e}\n")
python
python rerank_demo.py
预期输出:
python
=== 粗排:top-20 ===
[1] 第 7 ⻚ | The base model contains 65M parameters...
[2] 第 4 ⻚ | We trained the model using the Adam optimizer...
[3] 第 7 ⻚ | training details, we used Adam with β1 = 0.9, β2 = 0.98... [4] 第 2 ⻚ | The base model architecture follows...
[5] 第 8 ⻚ | 3.5 days on 8 P100 GPUs...
... ...
=== BGE-Reranker 精排(top-3)===
[1] 分数 8.5234 | 第 4 ⻚
We trained the model using the Adam optimizer...
[2] 分数 7.8912 | 第 7 ⻚
training details, we used Adam with β1 = 0.9, β2 = 0.98...
[3] 分数 1.2345 | 第 8 ⻚
3.5 days on 8 P100 GPUs...
Step3:把Re-ranking拼到RAGChain
python
"""
Day 1:完整 RAG + Re-ranking chain
对应教程:W5 Day 1 Step 3
运⾏:python src/week05/rerank_chain.py
"""
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.joinpath("week04"))) sys.path.insert(0, str(Path(__file__).parent))
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser
from langchain_openai import ChatOpenAI
from langchain_community.vectorstores import Chroma
from langchain.retrievers import ContextualCompressionRetriever from dotenv import load_dotenv
import os
# 复⽤ W4 的 prompt + 多后端 embedding
sys.path.insert(0, str(Path(__file__).parent.parent.joinpath("week04"))) from rag_prompt import RAG_PROMPT
from embedder import get_embeddings # ⾃动选 OpenAI 官⽅ / 硅基流动
# ⽤硅基流动 BGE Rerank 替代 FlashrankRerank(避免国内下模型失败)
from rerank_siliconflow import SiliconFlowRerank
load_dotenv()
def format_docs(docs) -> str:
"""把 Document 列表转成 context 字符串"""
formatted = []
for i, d in enumerate(docs, 1):
page = d.metadata.get("page", "?")
source = Path(d.metadata.get("source", "?")).name
formatted.append(f"[{i}] (来源: {source}, 第 {page} ⻚)\n{d.page_content}")
return "\n\n---\n\n".join(formatted)
def build_rerank_rag_chain(top_k_coarse: int = 20, top_k_fine: int = 3): """拼出 RAG + Re-ranking chain"""
# 1. 加载向量库
embeddings = get_embeddings()
vectorstore = Chroma(
persist_directory="outputs/week04/chroma_db", embedding_function=embeddings,
collection_name="papers",
)
# 2. 粗排 retriever(取 top-20)
base_retriever = vectorstore.as_retriever(search_kwargs={"k": top_k_coarse})
# 3. 精排 compressor(top-20 → top-3,硅基流动 BGE Rerank) compressor = SiliconFlowRerank(top_n=top_k_fine)
# 4. 压缩 retriever = 粗排 + 精排
compression_retriever = ContextualCompressionRetriever(
base_compressor=compressor, base_retriever=base_retriever, )
# 5. LLM
llm = ChatOpenAI(
model="deepseek-chat",
temperature=0, openai_api_key=os.getenv("DEEPSEEK_API_KEY"), openai_api_base="https://api.deepseek.com/v1", )
# 6. RAG + Re-ranking chain
rag_chain = (
{
"context": compression_retriever | format_docs, "question": RunnablePassthrough(),
}
| RAG_PROMPT
| llm
| StrOutputParser()
)
return rag_chain
# === 演⽰ ===
if __name__ == "__main__":
chain = build_rerank_rag_chain(top_k_coarse=20, top_k_fine=3)
print("=== RAG + Re-ranking chain 构建完成 ===\n")
question = "What optimizer did the paper use?" print(f"Q: {question}\n")
print("A: ", end="", flush=True)
for chunk in chain.stream(question): print(chunk, end="", flush=True)
print()
python
python rerank_chain.py
python
=== RAG + Re-ranking chain 构建完成 ===
Q: What optimizer did the paper use?
A: 论⽂使⽤了 Adam 优化器(Adam optimizer),β1 = 0.9,β2 = 0.98。
Day2:BM25混合检索
Step 0:为什么需要 BM25
现在大家都在用 Bi‑Encoder 向量召回(语义检索) ,向量检索擅长语义相似、同义词、意译;但是向量检索有短板:实体、专有名词、编号、人名、项目代号容易丢召回,实体、专有名词、编号、人名、项目代号容易丢召回
Step1:第⼀个BM25检索
写hybrid_search.py
python
# hybrid_search.py
"""
Day 2:3 种检索对⽐(纯向量 / 纯 BM25 / 混合)
"""
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.joinpath("week04"))) sys.path.insert(0, str(Path(__file__).parent))
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma
from langchain_community.retrievers import BM25Retriever from langchain.retrievers import EnsembleRetriever
from dotenv import load_dotenv
import os
load_dotenv()
def prepare_data():
"""准备 2 种 retriever 共享的 Document 列表"""
from paper_loader import load_paper from chunker import chunk_documents
docs = load_paper("./outputs/week04/sample_text.pdf")
chunks = chunk_documents(docs, chunk_size=500, chunk_overlap=50) return chunks
def get_vector_retriever(chunks, k: int = 5):
"""纯向量检索"""
embeddings = OpenAIEmbeddings(
model="BAAI/bge-m3", openai_api_key=os.getenv("SILICONFLOW_API_KEY"), openai_api_base="https://api.siliconflow.cn/v1",
)
vectorstore = Chroma.from_documents(
documents=chunks,
embedding=embeddings, collection_name="hybrid_demo",
persist_directory=None, # 内存模式(不持久化)
)
return vectorstore.as_retriever(search_kwargs={"k": k})
def get_bm25_retriever(chunks, k: int = 5):
"""纯 BM25 关键词检索"""
return BM25Retriever.from_documents(chunks, k=k)
def get_hybrid_retriever(chunks, k: int = 5, weights: list[float] = [0.5, 0.5]):
"""混合检索(向量 + BM25,权重各 0.5)"""
vector_retriever = get_vector_retriever(chunks, k=k)
bm25_retriever = get_bm25_retriever(chunks, k=k)
return EnsembleRetriever(
retrievers=[bm25_retriever, vector_retriever], # BM25 在前 weights=weights, # 权重:[BM25 权重, 向量权重]
)
# === 演⽰ ===
if __name__ == "__main__":
print("=== 准备数据 ===")
chunks = prepare_data()
print(f"切了 {len(chunks)} 个 chunk\n")
# 缓存 vectorstore(避免每次 retriever 都重新 embed 67 个 chunk) embeddings = OpenAIEmbeddings(
model="BAAI/bge-m3", openai_api_key=os.getenv("SILICONFLOW_API_KEY"), openai_api_base="https://api.siliconflow.cn/v1", )
cached_vectorstore = Chroma.from_documents( documents=chunks,
embedding=embeddings, collection_name="hybrid_demo", persist_directory=None,
)
query = "What optimizer did the paper use?"
# 1. 纯向量检索
print(f"=== 1. 纯向量检索(top-5)===")
print(f"Query: {query}\n")
vector_retriever = cached_vectorstore.as_retriever(search_kwargs={"k": 5}) vector_results = vector_retriever.invoke(query)
for i, d in enumerate(vector_results, 1):
page = d.metadata.get("page", "?")
print(f" [{i}] 第 {page} ⻚ | {d.page_content[:80]}...")
# 2. 纯 BM25
print(f"\n=== 2. 纯 BM25 检索(top-5)===")
bm25_retriever = BM25Retriever.from_documents(chunks, k=5) bm25_results = bm25_retriever.invoke(query)
for i, d in enumerate(bm25_results, 1):
page = d.metadata.get("page", "?")
print(f" [{i}] 第 {page} ⻚ | {d.page_content[:80]}...")
# 3. 混合检索
print(f"\n=== 3. 混合检索(top-5,BM25:向量 = 0.5:0.5)===")
hybrid_retriever = EnsembleRetriever( retrievers=[bm25_retriever, vector_retriever], weights=[0.5, 0.5],
)
hybrid_results = hybrid_retriever.invoke(query)
for i, d in enumerate(hybrid_results, 1):
page = d.metadata.get("page", "?")
print(f" [{i}] 第 {page} ⻚ | {d.page_content[:80]}...")
# 4. 不同权重对⽐
print(f"\n=== 4. 混合检索(top-5,BM25:向量 = 0.7:0.3)===")
weighted_retriever = get_hybrid_retriever(chunks, k=5, weights=[0.7, 0.3]) weighted_results = weighted_retriever.invoke(query)
for i, d in enumerate(weighted_results, 1):
page = d.metadata.get("page", "?")
print(f" [{i}] 第 {page} ⻚ | {d.page_content[:80]}...")
python
python hybrid_search.py
python
=== 准备数据 ===
切了 67 个 chunk
=== 1. 纯向量检索(top-5)===
Query: What optimizer did the paper use?
[1] 第 2 ⻚ | The base model architecture follows the encoder-decoder structure...
[2] 第 7 ⻚ | The base model contains 65M parameters, while the big model... [3] 第 4 ⻚ | We trained the model using the Adam optimizer (Kingma & Ba,
2014)...
[4] 第 8 ⻚ | 3.5 days on 8 P100 GPUs...
[5] 第 2 ⻚ | Section 5: Training...
......
=== 4. 混合检索(top-5,BM25:向量 = 0.7:0.3)===
[1] 第 4 ⻚ | We trained the model using the Adam optimizer...
[2] 第 7 ⻚ | training details, we used Adam with β1 = 0.9, β2 = 0.98...
[3] 第 7 ⻚ | warmup_steps = 4000...
[4] 第 5 ⻚ | Label Smoothing...
[5] 第 2 ⻚ | The base model architecture follows...
Step2:⽤BM25持久化避免重复索引
写bm25_store.py
python
"""
Day 2:BM25 索引的持久化(pickle)
"""
import pickle
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.joinpath("week04")))
from langchain_community.retrievers import BM25Retriever from paper_loader import load_paper
from chunker import chunk_documents
BM25_INDEX_PATH = "outputs/week05/bm25_index.pkl"
def build_bm25_index(save: bool = True) -> BM25Retriever:
"""建 BM25 索引(⾸次 30 秒)"""
docs = load_paper("outputs/week04/sample_text.pdf")
chunks = chunk_documents(docs, chunk_size=500, chunk_overlap=50)
print(f"切了 {len(chunks)} 个 chunk")
bm25_retriever = BM25Retriever.from_documents(chunks, k=5)
if save:
Path(BM25_INDEX_PATH).parent.mkdir(parents=True, exist_ok=True) with open(BM25_INDEX_PATH, "wb") as f: pickle.dump(bm25_retriever, f)
print(f"✅ BM25 索引已保存到 {BM25_INDEX_PATH}")
return bm25_retriever
def load_bm25_index() -> BM25Retriever: """从磁盘加载 BM25 索引(1 秒)"""
with open(BM25_INDEX_PATH, "rb") as f: return pickle.load(f)
if __name__ == "__main__": import os
if os.path.exists(BM25_INDEX_PATH): print("BM25 索引已存在,直接加载...")
bm25 = load_bm25_index()
else:
print("BM25 索引不存在,开始建索引...")
bm25 = build_bm25_index()
# 测试
results = bm25.invoke("Adam optimizer")
print(f"\n查询 'Adam optimizer' 的 top-3:")
for i, d in enumerate(results[:3], 1):
print(f" [{i}] 第 {d.metadata.get('page', '?')} ⻚ |
{d.page_content[:80]}...")
python
# 第⼀次:建索引(30 秒)
python bm25_store.py
# 第⼆次:直接加载(1 秒) python bm25_store.py
预期输出
python
BM25 索引不存在,开始建索引...
切了 67 个 chunk
✅ BM25 索引已保存到 outputs/week05/bm25_index.pkl
查询 'Adam optimizer' 的 top-3:
[1] 第 4 ⻚ | We trained the model using the Adam optimizer...
[2] 第 7 ⻚ | training details, we used Adam with β1 = 0.9, β2 = 0.98... [3] 第 5 ⻚ | Label Smoothing...
Step3:把BM25拼到RAGChain
写hybrid_chain.py
python
# src/week05/hybrid_chain.py
"""
Day 2:完整 RAG + BM25 混合检索 chain
"""
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.joinpath("week04"))) sys.path.insert(0, str(Path(__file__).parent))
from langchain_core.runnables import RunnablePassthrough from langchain_core.output_parsers import StrOutputParser from langchain_openai import ChatOpenAI, OpenAIEmbeddings from langchain_community.vectorstores import Chroma
from langchain_community.retrievers import BM25Retriever from langchain.retrievers import EnsembleRetriever
from dotenv import load_dotenv
import os
from rag_prompt import RAG_PROMPT # 复⽤ W4(src/week04/rag_prompt.py)
load_dotenv()
def format_docs(docs) -> str:
formatted = []
for i, d in enumerate(docs, 1):
page = d.metadata.get("page", "?")
source = Path(d.metadata.get("source", "?")).name
formatted.append(f"[{i}] (来源: {source}, 第 {page} ⻚)\n{d.page_content}")
return "\n\n---\n\n".join(formatted)
def build_hybrid_rag_chain(weights: list[float] = [0.5, 0.5], top_k: int = 5): """拼出 RAG + BM25 混合检索 chain"""
# 1. 向量 retriever
embeddings = OpenAIEmbeddings(
model="BAAI/bge-m3", openai_api_key=os.getenv("SILICONFLOW_API_KEY"), openai_api_base="https://api.siliconflow.cn/v1",
)
vectorstore = Chroma(
persist_directory="outputs/week04/chroma_db", embedding_function=embeddings,
collection_name="papers",
)
vector_retriever = vectorstore.as_retriever(search_kwargs={"k": top_k})
from bm25_store import load_bm25_index
bm25_retriever = load_bm25_index() bm25_retriever.k = top_k
# 3. 混合 retriever
hybrid_retriever = EnsembleRetriever( retrievers=[bm25_retriever, vector_retriever],
weights=weights, # [BM25 权重, 向量权重]
)
# 4. LLM
llm = ChatOpenAI(
model="deepseek-chat",
temperature=0, openai_api_key=os.getenv("DEEPSEEK_API_KEY"), openai_api_base="https://api.deepseek.com/v1", )
# 5. RAG + 混合 chain
rag_chain = (
{
"context": hybrid_retriever | format_docs, "question": RunnablePassthrough(),
}
| RAG_PROMPT
| llm
| StrOutputParser()
)
return rag_chain
# === 演⽰ ===
if __name__ == "__main__":
chain = build_hybrid_rag_chain(weights=[0.5, 0.5], top_k=5)
print("=== RAG + BM25 混合检索 chain 构建完成 ===\n")
# 测试 1:关键词密集的问题
question1 = "What are the values of β1 and β2 in Adam?" print(f"Q1: {question1}\n")
print("A1: ", end="", flush=True)
for chunk in chain.stream(question1):
print(chunk, end="", flush=True)
print("\n")
# 测试 2:语义问题
question2 = "Why is the Transformer better than RNN?" print(f"Q2: {question2}\n")
print("A2: ", end="", flush=True)
for chunk in chain.stream(question2): print(chunk, end="", flush=True) print()
python hybrid_chain.py
python
=== RAG + BM25 混合检索 chain 构建完成 ===
Q1: What are the values of β1 and β2 in Adam?
A1: Adam 优化器的 β1 = 0.9,β2 = 0.98(big model),β2 = 0.999(base model)。
Q2: Why is the Transformer better than RNN?
A2: Transformer 通过 self-attention 实现了并⾏计算,能更好地捕捉⻓距离依赖, ⽽ RNN 必须按顺序计算,难以并⾏。
Day3:Query改写
本文记录了 RAG(检索增强生成)系统优化过程中的两个关键环节:Query 改写
前言:为什么要做这两件事
做过 RAG 的同学大概率遇到过下面两类问题:
- 检索不准:用户随口一问(比如"那个啥,论文讲了什么"),向量检索直接懵了,召回的内容文不对题;
- 答案错了不知道哪一步锅:是检索没找到?重排序把正确答案排掉了?还是 LLM 瞎编的?不追踪根本无从下手。
这两个问题分别对应本文的两个主题:Query 改写 解决第一个问题,LangSmith 解决第二个问题。两者结合,才能把 RAG 从"能跑"做到"好用、可调试"。
一、Query 改写:让检索更准
1 用户的 query 到底有什么问题
用户手打的问题往往不适合直接拿去做向量检索,常见的三类毛病:
| 问题 | 例子 | 后果 |
|---|---|---|
| 太口语 | "那个啥,论文讲了什么" | 检索不到"摘要"段 |
| 太具体 | "Adam 的 β1" | 漏掉"training details"整段 |
| 语义模糊 | "这模型好吗" | 系统不知道"好"指的是什么维度 |
解决思路很简单:不要直接拿用户原话去检索,而是先让 LLM 把这句话"翻译"成 1~3 个更适合检索的版本,分别检索后再合并去重、取 top-K。这就是 Query 改写的核心逻辑:
原始 query: "那个啥,论文讲了什么"
↓
[LLM 改写]
- 变体 1: "这篇论文的核心方法和结论是什么?"
- 变体 2: "论文的摘要和主要贡献是什么?"
- 变体 3: "论文的研究问题、方法和实验结果是什么?"
↓
[分别检索 → 合并去重] → top-K
核心思想一句话总结:3 个 query 比 1 个 query 检索覆盖面广------总有一个能命中。
2 三种改写策略怎么选
业界常用的三种 Query 改写策略各有适用场景:
| 策略 | 原理 | 适合场景 |
|---|---|---|
| MultiQuery | LLM 自动生成 3 个不同角度的 query 变体,分别检索后合并 | 一般问答,万金油 |
| Step-back | 把具体问题抽象成更上层的通用问题 | "为什么"类、原理类问题 |
| HyDE | 让 LLM 先"编"一段假设性答案,再用这段答案的 embedding 去检索 | query 特别短、信息量不够时 |
策略一:MultiQueryRetriever(自动生成变体)
LangChain 自带的 MultiQueryRetriever 可以直接开箱用,核心是自定义一个生成变体的 prompt:
python
from langchain.retrievers.multi_query import MultiQueryRetriever
from langchain.prompts import PromptTemplate
def get_multiquery_retriever(base_retriever, llm, num_queries: int = 3):
"""MultiQuery 检索器:自动生成 num_queries 个 query 变体"""
custom_prompt = PromptTemplate(
input_variables=["question"],
template=f"""你是一个 AI 助手。
你的任务是基于用户的原始问题,生成 {num_queries} 个不同角度的检索 query。
要求:
1. 每个 query 用不同词汇、不同角度表述同一个问题
2. 保留核心关键词(人名、数字、专有名词)
3. 用中文输出,每个 query 1 行,不要编号
原始问题:{{question}}
生成的 {num_queries} 个 query:""",
)
return MultiQueryRetriever.from_llm(
retriever=base_retriever,
llm=llm,
prompt=custom_prompt,
)
注意 :temperature 要调到 0.7 左右------如果设成 0,LLM 每次生成的"变体"其实长得都差不多,起不到多角度覆盖的作用。
策略二:Step-back Prompting(抽象到上层)
思路是先让 LLM 把具体问题"拔高一层",再拿抽象后的问题去检索:
python
from langchain.prompts import PromptTemplate
from langchain.chains import LLMChain
from langchain_core.retrievers import BaseRetriever
STEP_BACK_PROMPT = PromptTemplate(
input_variables=["question"],
template="""你是一个论文检索助手。
用户的原始问题可能太具体,不适合直接检索。
请生成一个更抽象、更通用的"step-back"问题,能涵盖原始问题的核心概念。
原始问题:{question}
Step-back 问题:""",
)
class StepBackRetriever(BaseRetriever):
"""先抽象 query,再检索"""
def _get_relevant_documents(self, query, **kwargs):
step_back_chain = LLMChain(llm=llm, prompt=STEP_BACK_PROMPT)
step_back_q = step_back_chain.invoke({"question": query})["text"].strip()
print(f"[Step-back] {query} → {step_back_q}")
return base_retriever.invoke(step_back_q)
比如原问题是"Adam 的 β1 β2 是多少?",Step-back 之后会变成"Transformer 论文的训练超参数设置是什么?"------检索范围更准,不容易漏掉相关段落。
策略三:HyDE(假设性文档嵌入)
HyDE 的思路比较反直觉:不用问题本身去检索,而是让 LLM 先编一段"假设性答案",再用这段答案的 embedding 去检索。原理是假设性答案的语义和真实答案的语义更接近,检索效果反而更好,尤其适合原始 query 很短、信息量不够的场景。
python
from langchain.prompts import PromptTemplate
from langchain.chains import LLMChain
from langchain_core.retrievers import BaseRetriever
HYDE_PROMPT = PromptTemplate(
input_variables=["question"],
template="""请基于以下问题,写一段 100-200 字的假设性回答(即使你不知道答案,也要根据常识写)。
问题:{question}
假设性回答:""",
)
class HydeRetriever(BaseRetriever):
"""用假设性答案的 embedding 检索"""
def _get_relevant_documents(self, query, **kwargs):
hyde_chain = LLMChain(llm=llm, prompt=HYDE_PROMPT)
hypothetical = hyde_chain.invoke({"question": query})["text"].strip()
print(f"[HyDE 假设性答案] {hypothetical[:100]}...")
return base_retriever.invoke(hypothetical)
3 三种策略效果实测对比
以问题"Adam 的 β1 β2 是多少?"为例,三种策略跑出来的效果差异很明显:
=== Baseline:纯向量检索(top-3)===
[1] training details, we used Adam with β1 = 0.9...
[2] Label Smoothing...
[3] The base model architecture follows...
=== MultiQuery:自动生成 3 个 query 变体 ===
共检索 8 个去重结果(来自 3 个 query)
[1] training details, we used Adam with β1 = 0.9...
[2] We trained the model using the Adam optimizer...
...
=== Step-back:抽象到上层 ===
[Step-back] Adam 的 β1 β2 是多少? → Transformer 论文的训练超参数设置是什么?
[1] training details, we used Adam with β1 = 0.9...
[2] We trained the model using the Adam optimizer...
=== HyDE:假设性答案 ===
[HyDE 假设性答案] Adam 是一种常用的优化器,常用于训练 Transformer 模型。它的 β1 控制
一阶矩估计的指数衰减率,通常设为 0.9。β2 控制二阶矩估计的指数衰减率,通常设为 0.98 或 0.999...
[1] training details, we used Adam with β1 = 0.9, β2 = 0.98...
[2] We trained the model using the Adam optimizer...
关键观察:
- MultiQuery 召回了 8 个去重结果(3 个 query 合并),比纯向量检索多了近 2 倍的候选量;
- Step-back 把口语化问题抽象成"训练超参数"这个更准确的检索目标;
- HyDE 生成的假设性答案本身信息量大,在处理短 query、长文档时命中率更高。
4 把改写策略拼进完整 RAG Chain
实际生产中最常用的是 MultiQuery,因为它通用性最强。把它接入完整的 RAG chain 也很简单,本质是把 retriever 换成改写后的 retriever:
python
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain.retrievers.multi_query import MultiQueryRetriever
from langchain_community.vectorstores import Chroma
def format_docs(docs) -> str:
formatted = []
for i, d in enumerate(docs, 1):
page = d.metadata.get("page", "?")
source = d.metadata.get("source", "?")
formatted.append(f"[{i}] (来源: {source}, 第 {page} 页)\n{d.page_content}")
return "\n\n---\n\n".join(formatted)
def build_rewrite_rag_chain(num_queries: int = 3):
"""拼出 RAG + Query 改写 chain(用 MultiQuery)"""
vectorstore = Chroma(
persist_directory="outputs/chroma_db",
embedding_function=embeddings,
collection_name="papers",
)
base_retriever = vectorstore.as_retriever(search_kwargs={"k": 5})
# MultiQuery retriever:3 行代码替代手写 30 行改写逻辑
rewrite_retriever = MultiQueryRetriever.from_llm(
retriever=base_retriever,
llm=llm,
)
rag_chain = (
{
"context": rewrite_retriever | format_docs,
"question": RunnablePassthrough(),
}
| RAG_PROMPT
| llm
| StrOutputParser()
)
return rag_chain
实测效果:原本"那个啥,论文讲了什么"这种口语化问题,接入改写后能被自动拆成"论文核心贡献是什么" / "论文的摘要" / "论文讲了什么"三个角度分别检索,回答准确率明显提升。
1.5 调参经验(踩坑总结)
变体数量 num_queries:
| 数量 | 适用场景 | 速度 |
|---|---|---|
| 1 | 等同于不改写 | 快 |
| 3 | 默认推荐,速度与覆盖面的甜点 | 中等 |
| 5 | 复杂问答 | 慢 |
| 10+ | 不推荐,收益递减但延迟剧增 | 很慢 |
LLM 温度 temperature :设为 0 会导致改写"无效"(每次生成的变体几乎一样);0.7 是兼顾多样性和稳定性的默认值;1.0 容易跑题。
什么时候可以跳过改写:关键词查询(如"Adam")、精确数字查询(如"65M parameters")不需要改写,改写反而会让检索变模糊;口语化、模糊、复杂对比类问题才需要改写。
常见失败模式排查表:
| 失败现象 | 原因 | 解决办法 |
|---|---|---|
| 改写后失去原意 | LLM 改写过头 | temperature 降到 0.5 |
| 改写后全是同义句 | 改写太相似 | temperature 提到 1.0 |
| 改写后 query 变长 | LLM 啰嗦 | prompt 里限制"每个 query 不超过 10 字" |
| 改写后 query 变短丢信息 | LLM 丢关键词 | prompt 里强调"保留核心关键词" |
| 检索结果全是同一主题 | 3 个 query 太相似 | 提高 temperature + prompt 强制"不同角度" |
Day4:LangSmith追踪、LangSmith 全链路追踪:把 RAG 从黑盒变透明
1 为什么必须上追踪
没有追踪的 RAG 就是个黑盒------出了问题你只能靠猜:
| 问题 | 现象 | 可能的根因 |
|---|---|---|
| 答案错 | LLM 说"用了 SGD" | 检索错?重排序错?LLM 瞎编? |
| 答案不全 | 缺信息 | top-K 太小?chunk 切太大? |
| 答案慢 | 5 秒才出结果 | 检索慢?LLM 慢?重排序慢? |
| 答案贵 | 一天消耗 100 万 token | prompt 太长?模型选大了? |
LangSmith 的作用就是把 RAG 内部每一步的输入、输出、耗时、token 消耗全部可视化,四大能力:
| 能力 | 用途 |
|---|---|
| Traces | 看 RAG 每一步的输入/输出/延迟 |
| Datasets | 测试集管理,批量评估 |
| Hub | Prompt 模板共享,团队协作 |
| Monitoring | 生产环境的成本与性能监控 |
2 环境配置(3 个变量搞定)
去 smith.langchain.com 免费注册(不用绑卡),在 Settings → API Keys 创建一个 key,然后写入 .env:
bash
echo "LANGCHAIN_API_KEY=lsv2_你的_key" >> .env
echo "LANGCHAIN_TRACING_V2=true" >> .env
echo "LANGCHAIN_PROJECT=my-rag-project" >> .env
三个变量的作用:
| 变量 | 作用 | 是否必设 |
|---|---|---|
LANGCHAIN_API_KEY |
身份认证 | 必设 |
LANGCHAIN_TRACING_V2 |
开启追踪(true/false) | 必设 |
LANGCHAIN_PROJECT |
项目名,在 UI 里分组展示 | 推荐设置 |
配好之后跑一次最简单的 LLM 调用验证是否生效:
python
from langchain_openai import ChatOpenAI
import os
from dotenv import load_dotenv
load_dotenv()
llm = ChatOpenAI(
model="deepseek-chat",
temperature=0,
openai_api_key=os.getenv("DEEPSEEK_API_KEY"),
openai_api_base="https://api.deepseek.com/v1",
)
response = llm.invoke("Say 'LangSmith works!'")
print(response.content)
跑完去 LangSmith 后台看对应项目下是否出现了这次调用的 trace,没看到的话检查 .env 三个变量是否正确,或者重启一下虚拟环境。
关键认知 :不需要改任何业务代码,只要环境变量配对,LangChain 会自动把每一次 chain 调用的完整链路上传到 LangSmith。
3 追踪一条完整的 RAG 调用链
对于混合检索 + 重排序 + LLM 生成的完整 RAG pipeline,直接正常写代码即可,LangSmith 会自动把 retriever、reranker、prompt、llm 每一步都记录下来:
python
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_community.vectorstores import Chroma
from langchain.retrievers import EnsembleRetriever, ContextualCompressionRetriever
def build_full_rag_chain():
"""RAG + 混合检索 + Re-ranking,全程被 LangSmith 自动追踪"""
# 1. 向量检索
vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 20})
# 2. BM25 关键词检索
bm25_retriever.k = 20
# 3. 混合检索(向量 + 关键词各占一半权重)
hybrid_retriever = EnsembleRetriever(
retrievers=[bm25_retriever, vector_retriever],
weights=[0.5, 0.5],
)
# 4. 重排序(粗排 20 条 → 精排 top-3)
compressor = SiliconFlowRerank(top_n=3)
final_retriever = ContextualCompressionRetriever(
base_compressor=compressor,
base_retriever=hybrid_retriever,
)
# 5. 完整 chain ------ 这一次调用会全部进 LangSmith
rag_chain = (
{
"context": final_retriever | format_docs,
"question": RunnablePassthrough(),
}
| RAG_PROMPT
| llm
| StrOutputParser()
)
return rag_chain
跑完之后去 LangSmith UI 打开对应的 trace,重点看这几个标签:
| 标签 | 看什么 |
|---|---|
| Inputs | 原始 query |
| Outputs | 最终答案 |
| Latency | 总耗时(ms) |
| Tokens | 总 token 数(输入+输出) |
| Cost | 本次调用的总成本 |
展开每个子节点,能看到类似这样的耗时分解:
retriever (50ms)
→ ensemble (45ms)
→ bm25 (10ms) ← 能看到 top-20 具体内容
→ vector (30ms) ← 能看到 top-20 具体内容
→ rerank (5ms) ← 能看到精排后的 top-3
format_docs (1ms)
prompt (1ms)
llm (3000ms, 200 tokens, $0.001) ← 能看到完整 input prompt + output
一目了然:这条链路里最耗时的是 LLM 生成(3000ms),检索和重排序加起来才 100ms 左右------如果要做性能优化,明显该优化的是 LLM 调用(比如换更快的模型或者开流式输出),而不是死磕检索速度。
4 出错了怎么查?4 步调试法
答案错了,按照下面 4 步顺藤摸瓜就能定位到具体是哪一环出的问题:
| 步骤 | 在 LangSmith 里看什么 | 判断标准 |
|---|---|---|
| 1. 检索 | retriever 的 top-K 结果 | 正确答案在 top-K 里吗? |
| 2. 重排序 | rerank 后的 top-3 | 正确答案还在 top-3 里吗? |
| 3. Prompt | 拼好的最终 prompt | context 里含答案吗? |
| 4. LLM | LLM 的最终输出 | 有 context 的情况下 LLM 还答错了吗? |
四种典型错误的排查示例:
情况一:检索这一步就没找到答案
retriever.top-K = ["训练细节", "GPU 数量", "模型架构"] ← 里面根本没有"Adam"相关内容
LLM 输出: "SGD 优化器" ← 只能瞎编
根因是检索漏掉了含"Adam"的段落,解法是调整 chunk_size、优化 query(这时候前面讲的 Query 改写就派上用场了)、或者调整 BM25 和向量检索的权重比例。
情况二:检索找到了,但重排序把它挤掉了
retriever.top-K = ["Adam optimizer", "训练细节", "GPU 数量"] ← 第 1 名就是正确答案
rerank.top-3 = ["训练细节", "GPU 数量", "模型架构"] ← 但重排后被挤出局了!
根因是重排序模型效果不好,考虑换一个 Re-ranker(比如从某个模型换成 BGE 或 Cohere 的重排模型)。
情况三:检索和重排都对,但 context 拼接时丢了
retriever.top-K = ["Adam optimizer"] ← 找到了
context = "" ← 但 format_docs 函数把内容拼丢了
LLM 收到空 context → 回答"我不知道"
这种典型是代码 bug,检查 format_docs 这类拼接函数的逻辑。
情况四:一切都对,但 LLM 不看 context 硬编
context 已经正确拼好,包含 "Adam optimizer" 相关内容
LLM 输出: "SGD 优化器" ← 依然瞎编
这种情况通常是 temperature 设置过高,或者 prompt 里没有明确强调"必须基于 context 回答",需要把 temperature 调到 0,并在 prompt 里加强约束。
5 让任意函数都可追踪:@traceable 装饰器
如果不想改动整个 LangChain chain 的结构,只是想单独观察某个自定义函数内部的行为,用 @traceable 装饰器最方便,不侵入原有代码:
python
from langsmith import traceable
@traceable(name="retriever_step")
def retriever_step(query: str, k: int = 3) -> list[str]:
"""被追踪的 retriever 步骤"""
docs = vectorstore.similarity_search(query, k=k)
return [d.page_content for d in docs]
@traceable(name="llm_step")
def llm_step(query: str, context: str) -> str:
"""被追踪的 LLM 步骤"""
prompt = f"基于以下 context 回答问题:\n\n<context>\n{context}\n</context>\n\n问题:{query}\n\n回答:"
response = llm.invoke(prompt)
return response.content
@traceable(name="full_rag")
def full_rag(query: str) -> dict:
"""完整 RAG------每一步都单独进 LangSmith,方便逐步排查"""
contexts = retriever_step(query, k=3)
context_str = "\n".join(contexts)
answer = llm_step(query, context_str)
return {
"query": query,
"chunks": contexts, # LangSmith 会把这几段内容展示出来
"answer": answer,
}
核心价值 :@traceable 让任何普通 Python 函数都能自动进入 LangSmith 的追踪链路,不需要用 LangChain 的 Runnable 体系去重写逻辑,适合自己手写检索/生成逻辑(而不是完全依赖 LangChain 封装)的场景。推荐的最小追踪粒度是:retriever / format / llm 各加一个 @traceable,这样出问题时三步就能定位到具体环节。
2.6 成本与性能优化
LangSmith 的 Monitoring 面板可以看四个核心指标:延迟(Latency)、token 消耗(Token Usage)、成本(Cost)、失败率(Error Rate)。基于这些数据,常见的优化手段:
降成本:
| 优化动作 | 效果 |
|---|---|
| 精简 prompt(去掉冗余字段) | 节省 30%~50% token |
| 换更便宜的模型 | 成本可降低 70%+ |
| 对相同 query 做结果缓存 | 减少 50% 的重复调用 |
提速度:
| 优化动作 | 效果 |
|---|---|
| 减少检索 top-K(如 20→10) | 检索速度提升约 50% |
| 换轻量级 Re-ranker | 精排速度提升约 3 倍 |
用流式输出(.stream() 替代 .invoke()) |
用户感知速度提升约 50% |
一个经验组合是:混合检索 + 轻量重排序模型 + 流式输出,可以把端到端响应时间压到 3 秒以内。
三、总结
这两个环节其实是 RAG 优化里"一攻一守"的关系:
- Query 改写是"进攻"------想办法让检索这一步本身就更准,从源头减少"文不对题"的问题,MultiQuery / Step-back / HyDE 三种策略各有适用场景,日常问答优先用 MultiQuery。
- LangSmith 追踪是"防守"------当结果依然出错时,能快速定位是检索、重排序、prompt 拼接还是 LLM 生成哪一环出的问题,把排查时间从"盲猜"变成"看数据说话"。
两者结合,才能把一个"能跑起来的 RAG demo"打磨成一个"稳定可控、可持续迭代"的生产级系统。