一.架构(Architecture)

1.1 首次回答正确,多次调用,检索跑偏、答非所问
python
#原代码
from langchain.messages import AIMessage
...
last_query = request.state["messages"][-1].text
request.state["messages"] 是完整对话历史 (LangGraph state 的 messages 列表会累积)。你现在的测试是单轮,列表里只有 1 条 user 消息,所以 [-1] 碰巧取对。一旦多轮对话,列表变成:
| 轮次 | messages 列表内容 | [-1] 取到的是 |
|---|---|---|
| 第 1 轮 | [HumanMessage("langchain安装...")] |
用户问题 ✅ |
| 第 2 轮 | [Human, AI回答1, Human("pip怎么升级?")] |
用户新问题 ✅ |
| 第 2 轮(AI 内部再次调用模型时) | [Human, AI回答1, Human, AI含tool_call...] |
上一轮 AI 的回复 ❌ |
检索 query 变成 AI 自己上一句回答 → 召回错误文档 → 注入错误上下文 → 答非所问。而且这个 bug 是静默的------不报错,只看结果不太对劲。
python
#修改后
from langchain.messages import AIMessage, HumanMessage
...
user_msgs = [m for m in request.state["messages"] if isinstance(m, HumanMessage)]
last_query = user_msgs[-1].text if user_msgs else ""
1.2 dynamic_prompt 模型调用单轮没问题,加了工具或多轮导致API 调用 变慢、烧钱
解决:检索结果缓存,或改用 Agentic 架构按需检索
| 原代码 | 修复后 | |
|---|---|---|
| retriever | 在 prompt_with_context 内部,每次调用都新建 |
提到模块级,只建一次 |
| 检索逻辑 | retriever.invoke(last_query) 裸调用,每次模型调用都打一次 embedding API |
抽成 retrieve_docs(query) + @lru_cache(maxsize=128),同一 query 只检索一次 |
| 空 query | 直接拿空串去检索(embedding 一个空字符串) | 兜底返回"(无检索结果)" |
| 检索打印 | 每次都打印(缓存命中时会误导) | 移进 retrieve_docs,只在真实检索时打印------顺便成了缓存命中观察器 |
虚拟环境激活:
CMD:
.venv\Scripts\activate.bat;PowerShell:
Activate.ps1;Git Bash:source .venv\Scripts\activate
二.RAG检索优化
2.1 查询优化

这三种都是检索前优化,2_Step/Agentic 决定"检索怎么进流程",query_Optimize 决定"检索词质量"
2.1.1 temperature=0.7高温下复现困难、JSON 解析概率性失败
python
#原代码
temperature=0.7
改写/拆解要的是稳定,高温下同一问题每次改写结果不同,Multi_hop 的拆解数量和 JSON 格式都飘
python
#修改后
model = ChatOpenAI(..., temperature=0.7) # 最终回答,保留多样性
rewrite_model = ChatOpenAI(..., temperature=0) # 改写/检索,要稳定
rewrite_middleware = QueryRewriteMiddleware(vectorstore, rewrite_model) # 用低温的
rewrite_agent = create_agent(model=model, ...) # 回答用 0.7 的
2.1.2 文档未去重导致后续拼上下文时重复内容膨胀
原代码的两层问题
第一层:N 个子问题 × k=3,同一文档会被多个子问题召回 → 拼上下文时重复膨胀,token 浪费、模型还可能误以为该文档特别重要。
第二层( setdefault bug):
python
retrieved_docs.setdefault(sub_query, docs)
setdefault的语义是"键已存在就不动"。
| 原代码 | 修复后 | |
|---|---|---|
| 数据结构 | dict[sub_query, docs]------按子问题去重 |
dict[doc_id, (doc, [命中的子问题])]------按文档去重 |
| 去重粒度 | 只能处理"两个子问题字符串完全相同" | 处理"两个不同子问题召回了同一篇文档"(这才是真实场景) |
| 去重 key | sub_query 字符串 | d.id(ku.py 里设的 doc_X),无则用内容前缀兜底 |
| 附加信息 | 只有 docs | 每文档记录被几个子问题命中(多跳相关度信号) |
| 输出 | 按子问题分组打印,重复文档混在里头 | 打印合并前后数量对比 + 去重后按排名打印 |
如果 LLM 拆出来的两个子问题字符串恰好相同 ,第二次检索的 docs 会被整个丢弃(不是合并)。这种情况下你既丢了文档又不知道丢了------比重复更阴险。
python
#修改后
for sub_query in sub_queries:
for d in retriever.invoke(sub_query):
key = d.id or d.page_content[:32]
if key not in retrieved_docs:
retrieved_docs[key] = (d, [sub_query]) # 首次见到该文档
else:
retrieved_docs[key][1].append(sub_query) # 已见过,只追加命中来源
2.2 检索优化

Dense 靠"意思像",
Sparse 靠"字面对";
WS 是"两路分数加权投票",
RRF 是"两路排名投票"。
RRF 更省心(不用管分数方向和量纲),WS 更灵活(能调权重偏向某一路)。
2.2.1 检索结果对不上新文档,且不报错
python
#原代码
def create_bm25_index(metadata_corpus, index_path=SAMPLE_PATH, k1=1.5, b=0.75):
if os.path.exists(index_path): # ← 只看路径在不在
print(f"索引文件已存在,正在加载:{index_path}")
retriever = bm25s.BM25.load(index_path, load_corpus=True)
else:
print(f"未找到索引文件,正在创建新索引...")
corpus_tokens = [jieba.lcut(doc["content"]) for doc in metadata_corpus]
retriever = bm25s.BM25(k1=k1, b=b, corpus=metadata_corpus)
retriever.index(corpus_tokens)
retriever.save(index_path)
print(f"索引已保存至: {index_path}")
return retriever # ← 无论哪条路,都静默返回,不报错
| 维度 | 说明 |
|---|---|
| 检测逻辑 | os.path.exists 只判断路径存不存在,不看内容对不对 |
| 触发场景 | 你改了 sample.md(增/删/改文档),但没手动删 my_index.bm25/ 目录 |
| 后果 | 旧索引照样加载成功,BM25 检索到的是改之前的文档,结果对不上新语料且不报任何错误 |
| 隐蔽性 | 不报错 = 你根本不知道结果过期了,debug 时方向完全跑偏 |
python
#修改后
import hashlib # ← 新增
def corpus_hash(corpus: List[Dict]) -> str:
"""生成语料库内容哈希指纹"""
content = json.dumps(corpus, ensure_ascii=False, sort_keys=True)
return hashlib.md5(content.encode()).hexdigest()
def create_bm25_index(metadata_corpus, index_path=SAMPLE_PATH, k1=1.5, b=0.75):
current_hash = corpus_hash(metadata_corpus) # ← 当前语料的指纹
hash_path = str(index_path) + ".hash" # ← 指纹文件路径
# 三重检查:索引文件 + hash 文件 + hash 匹配
if os.path.exists(index_path) and os.path.exists(hash_path):
with open(hash_path, "r", encoding="utf-8") as f:
stored_hash = f.read().strip()
if stored_hash == current_hash:
# ✅ 索引存在 + 语料没变 → 直接复用
print(f"索引文件已存在且语料未变更,正在加载: {index_path}")
return bm25s.BM25.load(index_path, load_corpus=True)
else:
# ⚠️ 语料变了 → hash 不匹配,重建
print(f"语料已变更(hash 不匹配),重建索引...")
# 重建索引 + 保存新 hash
corpus_tokens = [jieba.lcut(doc["content"]) for doc in metadata_corpus]
retriever = bm25s.BM25(k1=k1, b=b, corpus=metadata_corpus)
retriever.index(corpus_tokens)
retriever.save(index_path)
with open(hash_path, "w", encoding="utf-8") as f:
f.write(current_hash) # ← 写入当前语料的指纹
print(f"索引已保存至: {index_path}")
return retriever
os.path.exists一个条件 → 索引文件 + hash 文件 + hash 匹配 三个条件全满足才复用,否则一律重建。
2.2.2 归一化失真 + 类型不一致
python
#原代码
# 向量分不归一化
ranked_results = weighted_sum_fusion([vector_rs, bm25_rs], [0.5, 0.5], [False, True])
# raw_docs 混存 Document 和 dict
for doc, score in vector_results:
raw_docs[doc.id] = doc # Document 对象
for doc, score in bm25_results:
raw_docs[doc["id"]] = doc # dict,不是 Document
normalized=[False, True]:向量相似度分范围约 01,BM25 分范围约 030+。不归一化向量分,0.5 × 0.8 + 0.5 × 25 = 12.9,向量分几乎被 BM25 淹没,加权毫无意义。
raw_docs 混存 Document 对象和 dict,最终 print(raw_docs[doc_id]) 输出格式不一致。
python
#修改后
# 两个检索器都先归一化到 [0,1] 再加权
ranked_results = weighted_sum_fusion([vector_rs, bm25_rs], [0.5, 0.5], [True, True])
# BM25 返回的 dict 统一转 Document
for doc, score in bm25_results:
doc_id = doc["id"]
if doc_id not in raw_docs:
raw_docs[doc_id] = Document(id=doc_id, page_content=doc["content"])
bm25_rs[doc_id] = score
2.3 重排序

MMR 解决"结果太像",Cross-Encoder 解决"排得不对"。两者可以叠加------先用 MMR 保证多样性,再用 Cross-Encoder 精排。
2.3.1 Cross_Encoder.py 测试 query "你好"不触发工具调用
python
#原代码
res = rag_agent.stream(
{"messages":[{"role":"user","content":"你好"}]},
stream_mode="messages"
)
system_prompt 说"如果用户问了你不确定的问题,或者涉及教资专业知识的问题,你必须使用
search_knowledge_base工具"。
python
#修改后
res = rag_agent.stream(
{"messages":[{"role":"user","content":"给教师的建议是谁写的?"}]},
stream_mode="messages"
)
三.高级RAG Agent汇总
查询改写 + 多路召回 + RRF融合 + Cross-Encoder 组合成一个完整的高级RAG Agent。
| 阶段 | 做什么 | 关键组件 |
|---|---|---|
| ① 并行检索 | Dense + BM25 同时跑,各取 k=3 | vectorstore.similarity_search + bm25_search |
| ② 融合 | RRF 按排名合并两路结果 | reciprocal_rank_fusion(k=60) |
| ③ 精排 | Cross-Encoder 逐对 (query, doc) 打分,过滤 + 取 top_k | bge-reranker-v2-m3 |
| ④ Agent | LLM 拿到工具返回的文档内容,生成最终回答 | create_agent + @tool |
LangChain 生态做编排,DashScope 做云端 LLM + Embedding,本地 sentence-transformers + bm25s + jieba 做检索与重排,三层模型分工明确------便宜的在云端、重的在本地、纯算法的自己写。