【agent篇】RAG Agent避坑指南(附高级RAG Agent汇总)

一.架构(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 做检索与重排,三层模型分工明确------便宜的在云端、重的在本地、纯算法的自己写。

相关推荐
Littlehero_1211 小时前
QT自定义控件之水泥厂组态(源码开源)
开发语言·qt
薛定猫AI1 小时前
【技术干货】多AI团队大模型协作编排与编程项目评测架构解析
python·机器学习·ai·大模型·技术实战
外收内放1 小时前
数据分析(图表信息详解)
python·学习·数据分析·matplotlib
辛苦才能1 小时前
C++ 哈希表深度解析:删除为什么必须打标记,负载因子为什么卡 0.7
开发语言·数据结构·c++·算法
气质、小青年!1 小时前
进程1学习
linux·学习
sg_knight1 小时前
Kimi Work 科研加速器:文献综述和论文润色的完整方法
llm·agent·ai编程·kimi·月之暗面·work·kimi work
小粉粉hhh1 小时前
大模型RAG和Agent智能体(一)——初了解
gpt·langchain·aigc
miller-tsunami2 小时前
STL简介&string相关知识点
开发语言·c++·string
JasmineX-12 小时前
数据结构(笔记)——顺序栈
c语言·数据结构·笔记