week05

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 的同学大概率遇到过下面两类问题:

  1. 检索不准:用户随口一问(比如"那个啥,论文讲了什么"),向量检索直接懵了,召回的内容文不对题;
  2. 答案错了不知道哪一步锅:是检索没找到?重排序把正确答案排掉了?还是 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"打磨成一个"稳定可控、可持续迭代"的生产级系统。


相关推荐
johnsong1 小时前
AI 前沿日报:2026年9月4日
人工智能
lee_curry1 小时前
AI Agent 工程师完整学习路线(面向生产级项目与面试)
人工智能·学习·ai·面试·agent
大模型真好玩1 小时前
DeepSeek Harness 入门很简单(二)——DeepSeek Harness通用设置及Agent预设详解
人工智能·agent·deepseek
前端世界1 小时前
基于 Linux 的本地 AI 智能问答平台搭建实践 —— Ollama + FastAPI + Nginx 实现本地大模型 Web 服务
linux·运维·人工智能
小小小小钰儿2 小时前
1.2-AI核心理论与术语
人工智能·计算机·网络安全·信息安全·程序员·编程
桃西西呀2 小时前
"循环深度"让小模型免费变大?GPT-6 Astra 背后的潜空间推理原理,8 个误读我替你拆了
人工智能·gpt·llm
数智工坊2 小时前
NTIRE2024×4图像超分竞赛综述|Mamba崛起、数据缩放、频域增强成为新趋势
网络·人工智能·深度学习·cnn·机器人
Raas1002 小时前
AI网关和大模型网关区别?MAI Gateway(魔芋企业级AI网关)如何实现AI流量统一治理
大数据·人工智能·gateway·ai网关·mai gateway·企业级产品
APSandafa2 小时前
安达发|灯具制造数字化:靠APS自动排程跑出交付优势
人工智能·制造·aps系统·aps自动排产·aps自动排单·aps智能排产排程排单软件