动手搭建AI Agent:RAG基础教程
时间:2026-05-29 07:46
系列文章: 1 动手做一个AIAgent - 简易框架搭建 2 动手做一个AIAgent - LiteLLM 3 动手做一个AIAgent - 流式输出与视觉支持 4 动手做一个AIAgent - MCP 5 动手做一个AIAgent - SKILLS 6 动手做一个AIAgent -
系列文章:
1. 动手做一个AIAgent - 简易框架搭建
2. 动手做一个AIAgent - LiteLLM
3. 动手做一个AIAgent - 流式输出与视觉支持
4. 动手做一个AIAgent - MCP
5. 动手做一个AIAgent - SKILLS
6. 动手做一个AIAgent - RAG基础
LLM是基于某个时间点之前的数据集训练出来的,这意味着它无法得知这个时间点之后发生的事,也无法触及训练集(比如企业内部的文档)之外的信息。
当然,你可以把所有需要告知的文档一股脑塞进 prompt 里,再向它提问。但问题在于,文档本身可能很庞大。一方面,这样会大量消耗 token;另一方面,过多的冗余信息也会让 LLM 产生更多幻觉。还有研究表明,当 LLM 处理长上下文时,会出现只记得开头和结尾,却丢失中间内容的情况——也就是“Lost in the Middle”现象。
什么是RAG
所以,我们需要尽可能地只将与当前对话相关的信息提供给 LLM,最好别用无关信息干扰它的文本生成。换个思路来说,就是让 LLM 在需要的时候,自己去数据库里查询与当前对话相关的内容。这就是 RAG(Retrieval-Augmented Generation),中文叫做检索增强生成。
举个例子,用户问“AI是什么?”我们不能只搜索包含“AI”这两个字母的文档,那些语义相近的文档,比如“深度学习”、“人工智能”之类的最好也能一并检索出来。这意味着传统的数据库在这类场景下行不通,我们需要用到向量数据库。
可以把向量理解成坐标——将一份文本根据语义转换成一个多维空间里的坐标。语义相近的文本,转换出来的坐标也就相近。
这样一来,RAG 的简要流程就比较好理解了:
将文档按某种规则分好块,计算出每一块的向量(也就是坐标)保存到向量数据库。当用户发出请求时,把用户的问题也计算出向量,然后在向量数据库里查询出与问题向量最接近的几个文档分块,最后把这几块内容交给 LLM 去生成最终回答。
这里截取了部分阿里巴巴 Java 开发手册的内容,下面就以它为例,介绍如何使用 Python 实现整个 RAG 流程。
离线阶段
离线阶段对应知识库的构建(文档加载 → 分块 → 向量化 → 存储),它是检索阶段的前置准备。
分块
首先需要对文档进行分块,常见的做法有这几种:
- **固定大小分块**:按固定的字符数分割。
- **递归字符分块**:设定一个块的大小,先按段落分割,如果段落太长再分割出句子,如果句子还是太长再分割出单词。
- **按语义分块**:在文本主题发生变化的地方进行分割。
这里的文档是按小点列举出来的,所以可以把每一个小点分割成一个块:
```python
def load_rag_chunks(doc_path: Path) -> List[str]:
"""读取文档,按全角左方括号「【」切分为 RAG 分片。首段为第一个「【」之前的正文(如章节标题);其后每段以「【」开头,对应一条规约条目。"""
path = Path(doc_path)
text = path.read_text(encoding="utf-8")
parts = text.split("【")
chunks: List[str] = []
head = parts[0].strip()
if head:
chunks.append(head)
for body in parts[1:]:
chunk = ("【" + body).strip()
if chunk:
chunks.append(chunk)
return chunks
```
分出来的块是这样的(以横线展示分割):
```
1.1 命名风格
------------------
【强制】代码中的命名均不能以下划线或美元符号开始,也不能以下划线或美元符号结束。反例:_name / __name / O b j e c t / n a m e / n a m e O b j e c t / name_ / name O b j e c t /name/name / O b j e c t$
------------------
【强制】代码中的命名严禁使用拼音与英文混合的方式,更不允许直接使用中文的方式。说明:正确的英文拼写和语法可以让阅读者易于理解,避免歧义。注意,即使纯拼音命名方式也要避免采用。正例:alibaba / taobao / youku / hangzhou 等国际通用的名称,可视同英文。反例:DaZhePromotion [打折] / getPingfenByName() [评分] / int 某变量 = 3
------------------
【强制】类名使用 UpperCamelCase 风格,必须遵从驼峰形式,但以下情形例外:DO / BO /DTO / VO / AO正例:MarcoPolo / UserDO / XmlService / TcpUdpDeal / TaPromotion反例:macroPolo / UserDo / XMLService / TCPUDPDeal / TAPromotion
------------------
...
```
向量化
接下来,需要用 Embedding 模型去计算每个分块的向量。
```python
from sentence_transformers import SentenceTransformer
embedding_model = SentenceTransformer("BAAI/bge-small-zh-v1.5")
def embed_chunk(chunk: str) -> List[float]:
embedding = embedding_model.encode(chunk, normalize_embeddings=True)
return embedding.tolist()
embeddings = {chunk: embed_chunk(chunk) for chunk in chunks}
```
第一次执行上面的脚本时,会自动从 Hugging Face 下载 `BAAI/bge-small-zh-v1.5` 模型,会有一段等待时间,下载完成后就会复用缓存。Hugging Face 上也有比较详细的用法说明。
模型选择可以参考 Hugging Face 的 MTEB(Massive Text Embedding Benchmark)排行榜:
(此处为图片)
或者可以切换到 `Performance per Model Size` 气泡图,更直观地对比每个模型的性能、成本、文本长度等维度:
(此处为图片)
- 横轴:模型参数量(Number of Parameters),也就是 LLM 里常说的多少多少 B,参数量越大一般能力越强,但对内存、显存等硬件性能的要求也越高。
- 纵轴:得分(Mean Task),使用一系列测试集来测试模型得出的分数,分数越高代表这个模型的语义理解能力越强。
- 气泡大小:维度(Embedding Size),模型最终计算出的向量的维度,维度越高能保持的语义信息越多,但占用的资源也会越多。
- 气泡颜色:最大处理长度(Max Tokens),模型支持的文本长度上限,颜色越深能处理越长的文本。
保存到向量数据库
向量计算完成后,就可以将向量和它代表的文档关联起来,保存到向量数据库。
向量数据库的选择不少,但用于学习的话,还是推荐使用轻量级的开源数据库 Chroma:
```python
import chromadb
chromadb_client = chromadb.EphemeralClient()
chromadb_collection = chromadb_client.get_or_create_collection(name="default")
def sa ve_embeddings(embeddings: dict[str, List[float]]) -> None:
for chunk, embedding in embeddings.items():
chromadb_collection.add(
documents=[chunk],
embeddings=[embedding],
ids=[chunk]
)
sa ve_embeddings(embeddings)
```
这里的 `EphemeralClient` 是一种用于临时、内存中操作的客户端模式,数据仅存在于内存中,程序退出或会话终止后数据会自动丢失。如果需要保存下来下次继续使用,可以用 `PersistentClient`:
```python
chromadb.PersistentClient(path=str(cache_dir))
```
在线阶段
在线阶段对应的是用户提问后的实时检索与生成。虽然在文章开头我们简单把这个过程总结为:从向量数据库查询与用户问题最接近的几个文档分块,提供给 LLM 去生成最终回答,但实际这个阶段还有不少需要处理的细节。
Query 改写
用户的问题通常会存在模糊、口语化、不完整或歧义等问题。例如,用户可能在讨论 Java 里的枚举,然后问了一句“它的命名有什么样的规范?”。
这时就需要 LLM 根据上下文,把查询语句改写成“枚举类的命名有什么样的规范?”。
召回
得到了比较准确的查询语句之后,就需要去向量数据库里检索了,这个步骤叫做召回:
```python
def retrieve(query: str, top_k: int) -> List[str]:
query_embedding = embed_chunk(query)
results = chromadb_collection.query(
query_embeddings=[query_embedding],
n_results=top_k
)
return results['documents'][0]
retrieved_chunks = retrieve(query, 10)
```
由于向量数据库里会有海量的文档,召回的重点在于速度,需要尽量快地找到相近的文档。
在速度优先的情况下,就可能出现精度缺失,容易漏掉一些内容。所以通常会从向量数据库里召回比较多的文档,例如 20 份或者更多。这里的召回数量通常用 `Top-K` 参数表示。
重排
召回步骤得到了比较多的文档,而且它们的排序也可能有问题——最相关的文档反而被放到了中间位置。所以需要对召回的文档做更精细化的检查,将它们按相关性重新排序,这个步骤叫做重排:
```python
cross_encoder = CrossEncoder('cross-encoder/mmarco-mMiniLMv2-L12-H384-v1')
def rerank(query: str, retrieved_chunks: List[str], top_k: int) -> List[str]:
pairs = [(query, chunk) for chunk in retrieved_chunks]
scores = cross_encoder.predict(pairs)
scored_chunks = list(zip(retrieved_chunks, scores))
scored_chunks.sort(key=lambda x: x[1], reverse=True)
return [chunk for chunk, _ in scored_chunks][:top_k]
reranked_chunks = rerank(query, retrieved_chunks, 3)
```
召回是广撒网,重排就是对召回的数据精加工。重排部分也有专门的 rerank 模型,可以在排行榜里筛选 rerank 模型来做选择:
(此处为图片)
生成
这时候,可以在重排结果里选择相关性最高的 3 个或 5 个,连同用户的问题一起发给 LLM,来做最终结果的生成。可以给这个 RAG 搭一个简单的 MCP Server,导入到我们的 AI Agent 里面:
```json
{
"llm": {...},
"mcp": {
...
"coding-standards-rag-server": {
"command": "python3",
"args": ["./mcps/rag_demo/coding_standards_rag_server.py"]
},
...
}
}
```
然后就可以和它对话,用 RAG 查询编码规范了:
```
请输入(Ctrl+C 退出): java的枚举类和c语言的枚举有什么差别
.....................
...省略这部分内容...
====================
请输入(Ctrl+C 退出): 根据我们的编码规范,它的命名有什么要求
.....................
...思考中...
用户问的是关于枚举类命名的编码规范要求。我需要使用RAG检索编码规范的相关内容来回答这个问题。我应该搜索关于枚举命名的内容。
我来帮您查找编码规范中关于枚举命名的要求。
调用工具: MCP$coding-standards-rag-server$get_result, 参数: {'query': '枚举命名 enum 命名规范'}
工具调用结果: ["{...}"]
根据编码规范的查询结果,我找到了关于枚举命名的要求。让我整理一下关键信息:
1. 枚举类名建议带上 Enum 后缀
2. 枚举成员名称需要全大写,单词间用下划线隔开
从查询结果来看,这是完整的枚举命名规范要求,我可以直接向用户提供这些信息。
根据我们的编码规范,枚举类的命名有明确要求:
...中间部分省略...
...思考结束...
根据我们的编码规范,枚举类的命名有明确要求:
## 枚举命名规范
### 1. 枚举类名
- **建议**带上 `Enum` 后缀
- 示例:`ProcessStatusEnum`
### 2. 枚举成员名称
- 必须**全大写**
- 单词之间用**下划线**隔开
- 示例:`SUCCESS`、`UNKNOWN_REASON`
## 3. 完整示例
```java
public enum ProcessStatusEnum {
SUCCESS, // 成功
FAILED, // 失败
UNKNOWN_REASON // 未知原因
}
```
或者带有构造参数的枚举:
```java
public enum DayEnum {
MONDAY(1),
TUESDAY(2),
WEDNESDAY(3),
THURSDAY(4),
FRIDAY(5),
SATURDAY(6),
SUNDAY(7);
private int value;
DayEnum(int value) {
this.value = value;
}
}
====================
## 使用建议
规范还提到,如果变量值仅在一个范围内变化,且带有名称之外的扩展属性,应该定义为枚举类。例如上面的 `DayEnum` 中,数字就是扩展信息,表示星期几。这样命名可以提高代码的可读性和一致性,便于团队协作维护。
```
从上面可以看到,我们问了它两个问题:
1. Java 的枚举类和 C 语言的枚举有什么差别
2. 根据我们的编码规范,它的命名有什么要求
然后,从它调用 MCP 的日志可以看出,它自动联系上下文,把请求的 query 改写成了 `枚举命名 enum 命名规范`:
```
调用工具: MCP$coding-standards-rag-server$get_result, 参数: {'query': '枚举命名 enum 命名规范'}
```
来源:https://juejin.cn/post/7621798739880132627
本站内容用于信息整理与展示,如有侵权或内容问题请及时联系处理。