系列目录:Phase 1-2 基础框架 → Phase 3 状态机+文件读取 → Phase 4-6 原生FC+Judge校验 → Phase 5 多工具并行+BashTool → Phase 6 流式输出 → Phase 7 Token预算与滑动窗口 → Phase 8 摘要压缩 → Phase 9 规划模式 → Phase 10 Web UI → Phase 11 RAG检索(本文)
上一篇链接:【Java/Go后端手撸原生Agent(第十篇):HTTP+SSE+Web UI------让Agent长出真正的用户界面】

前言
前10篇做完,我们的Agent已经具备:原生Function Calling、流式输出、多工具并行、BashTool、LLM-as-Judge证据链校验、Token预算管理、滑动窗口+摘要压缩、Plan-and-Execute规划、事件生成器+SSE+Web UI。在浏览器里它能正确调工具、流式展示思考、长对话不爆token、回答不全会被Judge打回、复杂任务先做计划。
但有一个核心问题一直没解决:Agent只能基于LLM自己的参数记忆回答问题。
问它"XXX公司的报销流程是什么"、"这个项目的部署架构是什么"------LLM训练语料里没这些信息,它要么硬编(hallucination),要么干巴巴地说"不知道"。
这就是RAG(Retrieval-Augmented Generation,检索增强生成)要解决的问题:给Agent外挂一个知识库,遇到知识盲区时主动查资料,而不是靠LLM"编"。
但本文要做的不是市面上教程常见的"老式RAG"(用户问什么→检索→拼prompt→LLM答),而是 Agentic RAG :把检索封装成一个工具 knowledge_search,让Agent自己判断要不要查、查几次、查到的内容够不够。这是从"流水线RAG"到"智能体RAG"的关键升级。
本文涉及6个新文件,并改动 main.py 3处。Agent核心 run_agent_stream() 一行不改------这正是Phase 10事件生成器解耦的收益。
一、问题本质:老式RAG的三大缺陷
市面上的RAG教程基本都是这个流水线:
用户问题 → embedding检索 top-k → 拼到 prompt → LLM 生成答案
看起来简单直接,但生产环境有三个致命问题:
缺陷1:简单问题也强制检索。用户问"你好"、"谢谢",流水线照样去检索,浪费一次embedding调用 + 浪费top-k个token塞进prompt。
缺陷2:无法多轮检索。用户问"TRAE有哪些模型?分别什么价位?"------一次检索很难同时召回"模型列表"和"价位表"两份文档。流水线RAG只能拼一次检索结果,回答必然残缺。
缺陷3:检索结果不可控。LLM拿到一堆chunk,没有"够不够"的判断机制。chunk不相关也得硬用,chunk够了也不知道停。
第一性原理 :检索应该是Agent的**"动作",而不是"前置流程"**。Agent有自己的判断力,它知道什么时候需要查资料、什么时候自己就能答、查完一份不够还能再查一份。把检索做成工具,让Agent像人一样"遇到盲区就翻文档,翻够了就回答"------这才是RAG的正确姿势。
这就是 Agentic RAG 的核心思想:RAG = 检索工具 + Agent的自主决策。
二、整体架构:从文件读取到语义检索
回顾Phase 3,我们做过一个 read_file 工具------Agent能按路径读文件。但这有三个局限:必须知道文件路径、只能整篇读、无法跨文档语义检索。
本文的升级路径:
| 维度 | Phase 3 read_file | Phase 11 knowledge_search |
|---|---|---|
| 检索方式 | 按路径定位 | 按语义相似度 |
| 粒度 | 整篇文件 | 切分后的chunk |
| 跨文档 | 不支持 | 支持 |
| 决策方 | 用户告诉Agent路径 | Agent自己判断要不要查 |
整体架构:
文档(raw)
→ Chunker 切分
→ Embedder 向量化
→ VectorStore 入库(持久化)
用户问题
→ Agent 决策(调用 knowledge_search?)
→ Embedder 向量化 query
→ VectorStore 粗排召回 top-k*5
→ Reranker 精排取 top-k
→ 格式化返回给 LLM
新增目录结构:
native-agent-demo/
├── rag/ # 新增:RAG 模块
│ ├── __init__.py
│ ├── chunker.py # 文档切分
│ ├── embeddings.py # 向量化
│ ├── vector_store.py # 手撸向量库
│ ├── reranker.py # 重排
│ └── ingest.py # 入库脚本
├── tools/
│ └── knowledge_search.py # 新增工具(继承 BaseTool)
├── docs/ # 新增:原始文档
├── data/ # 新增:向量库持久化
└── main.py # 改 3 处
下面按数据流逐个拆解。
三、文档切分:Chunker
3.1 为什么要切分
第一性原理:为什么不把整篇文档直接向量化?
- LLM上下文有限:一篇10万字文档塞不进prompt
- 中间遗忘问题:即使塞得进,长上下文有"lost in the middle"现象,LLM对中间内容注意力下降
- 检索粒度:整篇文档向量化会"稀释"语义------一篇讲5个主题的文档,向量是5个主题的混合,任何单主题query都匹配不准
所以必须切分。但切分又引入新问题:切多粗?切多细?
- 太粗:一个chunk包含多个主题,召回时噪声大
- 太细:一个chunk只有半句话,语义碎片化,LLM看不懂
3.2 切分策略权衡
三种主流策略:
- 固定字符窗口:简单粗暴,但会切断句子
- 按句子/段落:语义完整,但长度不可控
- Parent-Child:检索小块、返回所属大块(生产推荐)
本文用**"按段落 + 字符窗口 + overlap"**的混合策略,平衡简单与效果:
python
# rag/chunker.py
from dataclasses import dataclass
from typing import List
@dataclass
class Chunk:
text: str
metadata: dict # source, chunk_id, char_len
class Chunker:
def __init__(self, chunk_size: int = 500, overlap: int = 50, separator: str = "\n\n"):
self.chunk_size = chunk_size
self.overlap = overlap
self.separator = separator
def split(self, text: str, source: str = "unknown") -> List[Chunk]:
# 1. 先按段落粗切
paragraphs = [p.strip() for p in text.split(self.separator) if p.strip()]
# 2. 段落内按 chunk_size 滑窗切,保留 overlap
chunks: List[Chunk] = []
chunk_id = 0
for para in paragraphs:
if len(para) <= self.chunk_size:
chunks.append(Chunk(
text=para,
metadata={"source": source, "chunk_id": chunk_id, "char_len": len(para)},
))
chunk_id += 1
continue
start = 0
while start < len(para):
end = start + self.chunk_size
piece = para[start:end]
chunks.append(Chunk(
text=piece,
metadata={"source": source, "chunk_id": chunk_id, "char_len": len(piece)},
))
chunk_id += 1
start = end - self.overlap # overlap 保证跨块语义连续
return chunks
overlap 的作用:相邻chunk有50字符的重叠区,避免"关键信息正好被切断"的悲剧。
⚠️ 注意:overlap 不是越大越好。500字符chunk配50 overlap是10%,标准做法10%~20%。一开始图保险设了overlap=200,结果相邻chunk高度重复,检索top-k全是同一段的几个变体,浪费召回名额。
四、向量化:Embedder
python
# rag/embeddings.py
import numpy as np
from openai import OpenAI
class Embedder:
"""
为什么不手撸 embedding?
- embedding 模型是训练出来的(对比学习),自己训练成本极高且无意义
- 这是"用模型"不是"做模型",但向量检索本身可以手撸(见 vector_store.py)
"""
def __init__(self, model: str = "text-embedding-3-small", dim: int = 1536):
self.client = OpenAI()
self.model = model
self.dim = dim
def embed(self, texts: list[str]) -> np.ndarray:
"""批量向量化,返回 [N, dim] 的 numpy 数组"""
all_vecs = []
batch_size = 512 # OpenAI 单次最多 2048 条,512 比较稳
for i in range(0, len(texts), batch_size):
batch = texts[i:i + batch_size]
resp = self.client.embeddings.create(input=batch, model=self.model)
all_vecs.extend([d.embedding for d in resp.data])
vecs = np.array(all_vecs, dtype=np.float32)
# 关键:L2 归一化,后续余弦相似度退化为点积,省一次开方
norms = np.linalg.norm(vecs, axis=1, keepdims=True)
norms[norms == 0] = 1.0
return vecs / norms
def embed_query(self, query: str) -> np.ndarray:
"""单条查询向量化,返回 [dim] 向量"""
return self.embed([query])[0]
⚠️ 注意:归一化一定要在入库前做,且query也要归一化。如果写忘了归一化query,分数永远是0.x附近,排序还勉强对,但分数不可解释。归一化后余弦相似度=点积,分数在-1, 1之间,可解释性强。
选型建议:
- 中文为主:
BAAI/bge-large-zh-v1.5(开源,可本地部署) - 英文为主:
text-embedding-3-small(OpenAI,便宜效果好) - 隐私敏感:本地部署 BGE / m3e
五、手撸向量库:VectorStore
这是本文的硬核部分------不依赖任何向量数据库,用numpy手撸一个。
5.1 第一性原理:向量检索在做什么
给定query向量q,找数据库中与q最相似的k个向量。"相似" = 余弦相似度 = 归一化后点积。
暴力法:算q和所有N个向量的点积,排序取top-k。复杂度O(N×D)。
python
# rag/vector_store.py
import os
import pickle
import numpy as np
from rag.chunker import Chunk
class VectorStore:
"""
手撸向量库:numpy + 暴力余弦相似度。
为什么生产环境要换 ANN(近似最近邻)?
- 暴力法 O(N×D),10万条 1536 维要 1.5 亿次乘法,几十毫秒
- 100万条就到秒级,不可接受
- HNSW / IVF 把复杂度降到 O(log N)
但学习阶段强烈建议先用暴力法------你能 100% 看懂检索过程,没有黑盒。
"""
def __init__(self, dim: int):
self.dim = dim
self.vectors = np.zeros((0, dim), dtype=np.float32) # [N, dim]
self.chunks: list[Chunk] = []
def add(self, chunks: list[Chunk], vectors: np.ndarray):
"""入库:chunk 和它的向量必须一一对应"""
assert len(chunks) == vectors.shape[0]
assert vectors.shape[1] == self.dim
self.vectors = np.vstack([self.vectors, vectors])
self.chunks.extend(chunks)
def search(self, query_vec: np.ndarray, top_k: int = 5) -> list[tuple[Chunk, float]]:
"""检索:返回 [(chunk, score)] 列表,score 越高越相似"""
if len(self.chunks) == 0:
return []
# query_vec 也归一化,余弦相似度 = 点积
q = query_vec / (np.linalg.norm(query_vec) + 1e-8)
# 暴力点积:[N] 个分数
scores = self.vectors @ q # [N]
# top-k 索引
k = min(top_k, len(self.chunks))
# argpartition O(N) 比 argsort O(N log N) 快,先粗排取前 k
top_idx = np.argpartition(-scores, k - 1)[:k]
# 再在这 k 个里精排
top_idx = top_idx[np.argsort(-scores[top_idx])]
return [(self.chunks[i], float(scores[i])) for i in top_idx]
def save(self, path: str):
"""持久化:vectors 用 npy,chunks 用 pickle。path 不带后缀。"""
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
np.save(path + ".npy", self.vectors)
with open(path + ".pkl", "wb") as f:
pickle.dump(self.chunks, f)
def load(self, path: str):
self.vectors = np.load(path + ".npy")
with open(path + ".pkl", "rb") as f:
self.chunks = pickle.load(f)
self.dim = self.vectors.shape[1]
5.2 两个关键优化点
优化1:argpartition 替代 argsort 。argsort 是O(N log N)全排序,但我们只需要top-k,不需要知道第k+1名是谁。argpartition是O(N),先粗排找到前k个(这k个内部无序),再对这k个做argsort。N=10万、k=5时,这个优化能快几倍。
优化2:归一化前置 。入库时一次性归一化,后续每次检索只需做点积,不用重复算分母。余弦相似度公式 a ⋅ b ∣ a ∣ ∣ b ∣ \frac{a \cdot b}{|a||b|} ∣a∣∣b∣a⋅b 在归一化后简化为 a ⋅ b a \cdot b a⋅b,省掉一次开方。
想深入理解HNSW等ANN算法的,可以看我的前置文章 从跳表到HNSW:深度拆解向量ANN检索的分层设计与近似本质。理解暴力法是理解ANN的前提。
六、重排:Reranker
6.1 为什么embedding检索后还要 rerank
embedding是双塔模型:query和doc独立编码,相似度靠余弦。
- 优点:doc可以预计算,检索快
- 缺点:query和doc没有交互,细节匹配差。比如query问"价格",doc里写"售价500元",双塔模型可能匹配不上
Cross-encoder(reranker) 把 [query, doc] 拼起来过一次transformer,让两者在注意力层充分交互。精度高但慢,所以用在召回后的小集合上------从100个粗排候选里精挑5个。
这就是两阶段检索:粗排(embedding)+ 精排(reranker)。和搜索引擎的"召回+排序"是一回事。
6.2 两种实现
python
# rag/reranker.py
from typing import List, Tuple
from rag.chunker import Chunk
class Reranker:
"""
本实现提供两种:
1. LLM Reranker:用 LLM 打分(学习用,能跑就行)
2. 真实 Reranker:bge-reranker-v2-m3(生产推荐)
"""
def __init__(self, mode: str = "llm"):
self.mode = mode
if mode == "bge":
# pip install FlagEmbedding
from FlagEmbedding import FlagReranker
self.model = FlagReranker("BAAI/bge-reranker-v2-m3", use_fp16=True)
def rerank(self, query: str, candidates: List[Tuple[Chunk, float]],
top_k: int = 3) -> List[Tuple[Chunk, float]]:
if not candidates:
return []
if self.mode == "bge":
pairs = [[query, c.text] for c, _ in candidates]
scores = self.model.compute_score(pairs, normalize=True)
if isinstance(scores, float):
scores = [scores]
ranked = sorted(zip(candidates, scores), key=lambda x: -x[1])
return [(c, float(s)) for (c, _), s in ranked[:top_k]]
# LLM reranker:让 LLM 给 0-10 分
from openai import OpenAI
client = OpenAI()
scored = []
for chunk, _vec_score in candidates:
prompt = f"""请给以下文档片段对用户问题的相关性打分(0-10 整数)。
用户问题:{query}
文档片段:{chunk.text[:500]}
只输出一个数字,不要任何其他内容。"""
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
temperature=0,
)
try:
score = int(resp.choices[0].message.content.strip())
except ValueError:
score = 0
scored.append((chunk, float(score)))
scored.sort(key=lambda x: -x[1])
return scored[:top_k]
⚠️ 注意:LLM reranker慢且贵,每个候选要一次LLM调用。top-k=3、粗排召回15个候选,就是15次LLM调用,一轮检索几秒钟。生产一定要换bge-reranker,本地CPU都能跑,几十毫秒一个候选。学习阶段用LLM reranker是为了零依赖跑通,理解"两阶段检索"的本质。
七、入库脚本:ingest.py
python
# rag/ingest.py
import sys
from pathlib import Path
from rag.chunker import Chunker
from rag.embeddings import Embedder
from rag.vector_store import VectorStore
def ingest(docs_dir: str, store_path: str):
"""扫描 docs_dir 下所有 .txt/.md 文件,入库到 store_path(不带后缀)"""
embedder = Embedder()
chunker = Chunker(chunk_size=500, overlap=50)
store = VectorStore(dim=embedder.dim)
docs_path = Path(docs_dir)
files = list(docs_path.rglob("*.txt")) + list(docs_path.rglob("*.md"))
if not files:
print(f"未找到文档: {docs_dir}")
return
print(f"发现 {len(files)} 个文档,开始切块...")
all_chunks = []
for f in files:
text = f.read_text(encoding="utf-8")
chunks = chunker.split(text, source=str(f.relative_to(docs_path)))
all_chunks.extend(chunks)
print(f" {f.name}: {len(chunks)} 个 chunk")
print(f"共 {len(all_chunks)} 个 chunk,开始向量化...")
texts = [c.text for c in all_chunks]
vectors = embedder.embed(texts)
print("入库...")
store.add(all_chunks, vectors)
store.save(store_path)
print(f"完成。向量库: {store_path} ({len(all_chunks)} 条)")
if __name__ == "__main__":
# 用法: python -m rag.ingest ./docs ./data/store
ingest(sys.argv[1] if len(sys.argv) > 1 else "./docs",
sys.argv[2] if len(sys.argv) > 2 else "./data/store")
入库是一次性操作,文档变了再跑一次。生产环境可以做成增量入库(只处理新增/修改的文件),但学习阶段全量重建最简单。
八、工具封装:KnowledgeSearchTool
这是本文最关键的一步------把RAG封装成工具 。复用Phase 3的 BaseTool 抽象,和 calculator / bash / read_file 同等地位:
python
# tools/knowledge_search.py
from pathlib import Path
from pydantic import BaseModel, Field
from rag.embeddings import Embedder
from rag.reranker import Reranker
from rag.vector_store import VectorStore
from tools.base_tool import BaseTool
PROJECT_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_STORE_PATH = str(PROJECT_ROOT / "data" / "store")
class KnowledgeSearchArgs(BaseModel):
query: str = Field(description="检索查询词,应该是完整的问题或关键词组合,不要只填一个词")
top_k: int = Field(default=3, ge=1, le=10, description="返回的文档片段数量,默认3")
class KnowledgeSearchTool(BaseTool):
"""
Agentic RAG 的核心:把检索做成工具,让 LLM 自己决定何时调用。
"""
def __init__(self, store_path: str = DEFAULT_STORE_PATH,
embedder: Embedder | None = None,
reranker: Reranker | None = None):
self.embedder = embedder or Embedder()
self.reranker = reranker
self.store = VectorStore(dim=self.embedder.dim)
try:
self.store.load(store_path)
except FileNotFoundError:
pass # 库未初始化,store 为空,execute 时会返回提示
@property
def name(self) -> str:
return "knowledge_search"
@property
def desc(self) -> str:
return (
"在知识库中检索相关文档片段。当你需要查询项目文档、产品资料、历史案例、"
"内部规范等知识时使用。返回相关片段及其来源和相关度分数。"
)
@property
def args_schema(self) -> type[BaseModel]:
return KnowledgeSearchArgs
def run(self, args: KnowledgeSearchArgs) -> str:
if len(self.store.chunks) == 0:
return "知识库为空,请先运行 python -m rag.ingest ./docs ./data/store 入库文档。"
# 1. 向量召回(粗排,多召回一些给 rerank 用)
recall_k = args.top_k * 5 # 召回 5 倍
q_vec = self.embedder.embed_query(args.query)
candidates = self.store.search(q_vec, top_k=recall_k)
if not candidates:
return "未检索到相关内容。"
# 2. 重排(精排,从粗排结果里挑 top_k)
if self.reranker:
results = self.reranker.rerank(args.query, candidates, top_k=args.top_k)
else:
results = candidates[:args.top_k]
# 3. 格式化输出给 LLM 看
lines = []
for i, (chunk, score) in enumerate(results, 1):
lines.append(
f"【片段 {i}】(相关度 {score:.3f} | "
f"来源: {chunk.metadata.get('source', 'unknown')})"
)
lines.append(chunk.text)
lines.append("")
return "\n".join(lines) if lines else "未检索到相关内容。"
关键设计点 :desc 字段是Agentic RAG成败的关键。LLM根据这段描述决定何时调用------必须明确告诉它"什么时候该用、什么时候不该用"。我的描述里写了"项目文档、产品资料、历史案例、内部规范",LLM问"你好"时就不会调用,问"项目架构"时就会调用。
九、接入主循环:main.py 改 3 处
改动1:导入新模块
python
from tools.knowledge_search import KnowledgeSearchTool
from rag.embeddings import Embedder
from rag.reranker import Reranker
改动2:tool_list 加上新工具
python
# RAG 工具全局只初始化一次(embedding 模型加载 + 向量库读盘)
_embedder = Embedder()
_reranker = Reranker(mode="llm") # 学习用 LLM reranker,生产换 mode="bge"
_rag_tool = KnowledgeSearchTool(embedder=_embedder, reranker=_reranker)
tool_list: list[BaseTool] = [CalcTool(), FileReadTool(), BashTool(), _rag_tool]
tool_map = {t.name: t for t in tool_list}
改动3:PLANNER_SYSTEM_PROMPT 加上 knowledge_search 能力描述
text
你拥有以下工具能力:
- calculator:计算数学表达式
- read_file:读取工作区内的文件内容(支持offset/limit分段读取)
- bash:执行shell命令(ls/grep/cat/wc/find等),用于文件系统操作、代码搜索、运行命令等
- knowledge_search:在知识库中检索项目文档、产品资料等内部知识(参数:query检索词, top_k返回数量)
run_agent_stream() 一行不改! AgentEvent / app.py / web/index.html 一行都不改!
这就是Phase 10事件生成器解耦的收益------加一个工具,对Agent核心循环是透明的。前端会自动渲染新的工具卡片(折叠展开),打字机效果自动复用,草稿归档逻辑自动复用。
十、验证点
跑通后用这三组问题验证Agentic RAG的行为:
验证1:简单问题不检索 。问"你好"、"1+1等于几"------Agent应该直接回答,不调用 knowledge_search。如果调了,说明 desc 写得不够清楚。
验证2:知识问题主动检索 。问"XXX公司的历史?"------Agent应该主动调用 knowledge_search,拿到chunk后基于内容回答,而不是凭LLM参数记忆编造。
验证3:多轮检索 。问"XXX公司有哪些模型?分别什么价位?"------Agent应该能调2次 knowledge_search(一次查模型,一次查价位),这是老式流水线RAG做不到的。Agent甚至可以第一次检索后自己判断"信息不够,需要再查一次"。
验证4:reranker效果对比 。暂时把 _reranker = None 跑一组问题,再开reranker跑同一组,对比top-1命中率。能明显看到reranker把粗排第3、4位的相关chunk提到第1位。
十一、Agentic RAG 的本质
回顾本文,我们做的事情可以浓缩成一句话:把检索从"流水线前置流程"降级为"Agent的主动动作"。
这个降级带来的三个收益:
- 按需检索:简单问题不浪费检索调用
- 多轮检索:一次不够就再查,复杂问题可分解
- 可组合:检索结果和其他工具(bash、calculator)的结果可以混合推理
这其实就是 从"RAG系统"到"会查资料的Agent"的范式转变。RAG不是一个独立的系统,而是Agent的一个能力------和"会算数"、"会执行命令"一样,是Agent技能树上的一个节点。
后续 长期记忆 会把 memory_save / memory_recall 也做成工具,让Agent自主管理跨会话记忆------和本文的 knowledge_search 是同一设计哲学:把"能力"工具化,让Agent自己决定何时用。
完整代码
本文完整代码已同步到仓库 day_10 分支:
🔗 Gitee 仓库
跑通步骤:
bash
# 1. 装依赖
pip install openai numpy
# 2. 准备文档
mkdir docs
echo "自行输入XXX公司的相关历史" > docs/trae_intro.md
# 3. 入库
python -m rag.ingest ./docs ./data/store
# 4. 启动
python -m uvicorn app:app --host 0.0.0.0 --port 8000
# 5. 浏览器打开 http://localhost:8000 或者直接在main中调试
进阶
- 混合检索 :在
knowledge_search.py里加一路 BM25 检索(用rank_bm25库),和向量结果用 RRF 融合 - Parent-Child chunking:检索小块、返回所属大块,解决"chunk 太短缺上下文"问题
- Self-RAG:Agent 检索完自己判断"这些片段够不够回答",不够再查一次(已经有 Judge,扩展一下)
专栏:后端转Agent开发杂记 | 作者:小马小牛 | 持续更新中