写在前面
前面六篇,我们的 Agent 要么靠自己的"记忆力"回答问题,要么靠调工具去查接口。但真实业务里有一类问题很常见------公司的退货政策是什么?金卡会员有什么权益? 这些知识既不在大模型的训练数据里,也不适合写成一个个 Function Tool。
这时候就该 RAG 出场了。
RAG(Retrieval-Augmented Generation) ,翻译过来就是"检索增强生成"。说白了就一句话:先帮大模型查资料,再让它基于查到的内容回答。跟你考试的时候翻书再答题是一个道理。
这篇我们来看看,在 MAF 里怎么用几行代码把 RAG 跑起来。
一、先搞懂 RAG 到底在干什么
别被术语唬住。RAG 的流程其实非常简单:
text
用户提问:"签收 5 天了还能退货吗?"
│
▼
① 把问题变成向量(一串数字)
│
▼
② 去向量库里找最相似的文档片段
│
▼
③ 找到相关段落:"签收后 7 天内可申请退货退款......"
│
▼
④ 把问题 + 检索到的段落一起喂给大模型
│
▼
⑤ 大模型基于检索内容回答,并引用来源
这里面有两个关键角色:
Embedding 模型 :负责把文字变成向量。你可以理解为"翻译官"------把人类语言翻译成数学空间里的坐标。语义相近的文字,在这个空间里就挨得近。Demo 里用的是阿里百炼的 text-embedding-v3,输出 1024 维向量。
向量数据库 :存储文档向量、做相似度检索的仓库。Demo 用的是 InMemoryVectorStore(内存版,重启就丢),生产环境一般换成分布式向量数据库。
二、Demo 场景
我们的 Demo 模拟了一个电商客服场景:公司有三篇内部政策文档(退货退款、配送运费、会员权益),用户用自然语言提问,Agent 从知识库里检索相关内容后回答,并标注引用来源。
三篇文档很短,直接看就能理解:
| 文档 | 内容摘要 |
|---|---|
| 退货退款政策 | 签收 7 天内可退,退款 3 个工作日到账 |
| 配送运费说明 | 满 99 包邮,大件送货上门可预约 |
| 会员权益说明 | 银卡 98 折,金卡 95 折 + 免邮券 + 专属客服 |
运行后 Agent 会依次回答三个问题,比如"签收 5 天了还能退货吗"------知识库命中退货政策,Agent 据此回答"可以,7 天内都行"。
三、关键代码解析
整个 RAG Demo 涉及两个文件,我们一块一块来看。
3.1 知识库:CompanyKnowledge.cs
这个文件做三件事:定义向量库的表结构、灌入示例文档、提供检索接口。
第一步:定义"表结构"
csharp
var definition = new VectorStoreCollectionDefinition
{
Properties =
[
new VectorStoreKeyProperty(nameof(KnowledgeRecord.Id), typeof(string)),
new VectorStoreDataProperty(nameof(KnowledgeRecord.SourceName), typeof(string)),
new VectorStoreDataProperty(nameof(KnowledgeRecord.SourceLink), typeof(string)),
new VectorStoreDataProperty(nameof(KnowledgeRecord.Text), typeof(string)),
new VectorStoreVectorProperty(nameof(KnowledgeRecord.Embedding), typeof(string), dimensions),
]
};
你可以把向量库想象成一张数据库表。这里有主键 Id、几个普通字段(文档名、链接、正文),以及一个向量字段 Embedding。dimensions 是向量的维度,和 Embedding 模型对应------text-embedding-v3 输出 1024 维,这里就传 1024。
第二步:灌入文档
csharp
public async Task SeedAsync(CancellationToken cancellationToken = default)
{
await _collection.EnsureCollectionExistsAsync(cancellationToken);
foreach ((string id, string name, string link, string text) in SampleDocuments())
{
await _collection.UpsertAsync(new KnowledgeRecord
{
Id = id,
SourceName = name,
SourceLink = link,
Text = text,
// 填文本即可;VectorStore 会用 EmbeddingGenerator 自动转向量
Embedding = text,
}, cancellationToken);
}
}
注意一个细节:Embedding 字段我们填的是原始文本,不是向量数字。这是因为 InMemoryVectorStore 发现你配了 EmbeddingGenerator 后,会自动帮你把文本调 Embedding 模型转成向量。你只管塞文本就行。
生产环境里,这一步通常换成"读取 PDF/Word/网页 → 分块 → 批量写入向量库"的流程,后面会展开讲。
第三步:检索
csharp
public async Task<List<KnowledgeHit>> SearchAsync(
string query, int topK = 2, CancellationToken cancellationToken = default)
{
var hits = new List<KnowledgeHit>();
await foreach (VectorSearchResult<KnowledgeRecord> result in _collection.SearchAsync(
query, topK, cancellationToken: cancellationToken))
{
hits.Add(new KnowledgeHit(
result.Record.SourceName,
result.Record.SourceLink,
result.Record.Text,
result.Score));
}
return hits;
}
SearchAsync 接收用户的提问,在向量库里找 topK 条最相似的文档。返回的 Score 是相似度分数,越接近 1 越相关。底层做的事情是:把 query 也转成向量,然后算它和库里每条记录的余弦相似度。
3.2 把 RAG 接入 Agent:
知识库建好了,怎么让 Agent 用上它?MAF 提供了一个叫 TextSearchProvider 的组件,它就是 RAG 和 Agent 之间的桥梁。
第一步:创建向量库 + Embedding
csharp
OpenAIClient openAiClient = ChatClientFactory.CreateOpenAiClient(llm);
var vectorStore = new InMemoryVectorStore(new()
{
EmbeddingGenerator = openAiClient
.GetEmbeddingClient(llm.EmbeddingModel)
.AsIEmbeddingGenerator(llm.EmbeddingDimensions),
});
这里通过百炼的 OpenAI 兼容接口创建了一个 Embedding 客户端。InMemoryVectorStore 拿到它之后,就知道怎么把文本变成向量了。
第二步:配置 TextSearchProvider
csharp
var textSearchOptions = new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
RecentMessageMemoryLimit = 0,
CitationsPrompt = "在回答末尾用「引用:」列出文档名称和链接。",
};
SearchTime 控制检索时机,有两种模式:
BeforeAIInvoke(默认):每次调大模型之前,自动用用户的问题去检索。简单粗暴,但很可靠。OnDemandFunctionCalling:让大模型自己决定要不要检索。模型觉得需要查资料时,会主动调一个"搜索工具"。更灵活,但对模型能力要求更高。
CitationsPrompt 告诉模型"回答完要列出引用来源",这样用户能看到答案出自哪篇文档。
第三步:挂到 Agent 上
csharp
AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
Name = "ContosoSupport",
ChatOptions = new ChatOptions
{
Instructions = SystemInstructions,
},
AIContextProviders =
[
new TextSearchProvider(
(query, ct) => SearchKnowledgeAsync(knowledge, query, ct),
textSearchOptions),
],
});
关键在 AIContextProviders 这个参数。TextSearchProvider 实现了 AIContextProvider 接口,它的作用是:在 Agent 调大模型之前,往上下文里"注入"检索到的内容。
你只需要提供一个搜索回调------"给你一个问题,你去知识库查,返回结果"。剩下的事情(什么时候查、怎么把结果塞进 prompt),TextSearchProvider 全帮你搞定。
第四步:搜索回调的实现
csharp
private static async Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchKnowledgeAsync(
CompanyKnowledge knowledge, string query, CancellationToken cancellationToken)
{
List<KnowledgeHit> hits = await knowledge.SearchAsync(query, topK: 2, cancellationToken);
Console.WriteLine($" [检索] 「{TrimQuery(query)}」→ 命中 {hits.Count} 条");
foreach (KnowledgeHit hit in hits)
{
Console.WriteLine($" · {hit.SourceName} (score={hit.Score:F3})");
}
return hits.Select(hit => new TextSearchProvider.TextSearchResult
{
SourceName = hit.SourceName,
SourceLink = hit.SourceLink,
Text = hit.Text,
});
}
这个方法做的事情很简单:调 CompanyKnowledge.SearchAsync 查知识库,然后把结果转成 MAF 要求的 TextSearchResult 格式。顺带在控制台打印检索日志,方便调试。
3.3 整体流程串起来
把所有部件拼在一起,一次 RAG 问答的完整链路是这样的:
text
用户:"签收 5 天了还能退货吗?"
│
▼
TextSearchProvider 拦截,拿问题去搜知识库
│
▼
CompanyKnowledge.SearchAsync → InMemoryVectorStore.SearchAsync
│ query 被 Embedding 模型转向量 → 余弦相似度匹配 → 返回 top 2
│
▼
命中:「Contoso 商城退货退款政策」(score=0.87)
│
▼
TextSearchProvider 把检索结果注入上下文
│
▼
大模型收到:System Instructions + 检索到的政策原文 + 用户问题
│
▼
大模型回答:"签收 7 天内可以退货退款,您的情况在期限内......"
引用:Contoso 商城退货退款政策 https://docs.contoso.com/policies/returns
四、生产环境怎么用
这里用的是内存向量库 + 三篇短文档,跑着玩够了。但真正上生产,你还需要考虑下面这些问题。
4.1 文档分块(Chunking)
Demo 里每篇文档就几行字,整个塞进向量库没问题。但真实文档动辄几千字------一篇产品手册、一份合同、一个 FAQ 列表,你不可能把整篇文档当成一条记录。
为什么要分块? 两个原因。第一,Embedding 模型有输入长度限制(通常 512~8192 个 token),超长文本塞不进去。第二,即使能塞进去,一大段文字转成向量后,语义会被"稀释"------检索效果变差。
怎么分? 常见策略有这几种:
固定长度切分:按字符数或 token 数切,比如每 500 个 token 一块,相邻块之间重叠 50~100 个 token(overlap)。重叠是为了保证语义连贯------一句话被从中间切断的话,前后两块都抓不住完整意思。这是最简单也最常用的方式。
csharp
// 伪代码:固定长度 + 重叠切分
public static List<string> ChunkByTokens(string text, int chunkSize = 500, int overlap = 50)
{
var chunks = new List<string>();
int start = 0;
while (start < text.Length)
{
int end = Math.Min(start + chunkSize, text.Length);
chunks.Add(text[start..end]);
start = end - overlap; // 回退 overlap 个字符
}
return chunks;
}
按段落/标题切分 :如果文档有清晰的结构(Markdown 标题、HTML 的 <h2> 标签、PDF 的章节),可以按结构自然切分。这样每一块的语义更完整。
语义切分:用 Embedding 模型计算相邻句子的相似度,在相似度骤降的地方切开。效果好但成本高,适合对检索质量要求极高的场景。
分块之后,每条记录除了 Text 和 Embedding,通常还会带上 DocumentId(属于哪篇原文档)和 ChunkIndex(第几块),方便回溯来源。
4.2 向量数据库选型
Demo 用的 InMemoryVectorStore 重启数据就没了。生产环境你需要一个持久化的向量数据库。Semantic Kernel 生态里已经有多款连接器可选:
| 向量数据库 | 特点 | 适用场景 |
|---|---|---|
| Azure AI Search | 微软全托管,支持混合检索 | Azure 生态用户 |
| Qdrant | 开源,性能优秀,Rust 实现 | 中小规模,自建部署 |
| Milvus | 开源,支持十亿级向量 | 大规模生产环境 |
| Pinecone | 全托管 SaaS | 快速上线,不想运维 |
| Redis Stack | 在 Redis 上加了向量搜索 | 已有 Redis 基础设施的团队 |
| Weaviate | 开源,内置模块化向量化 | 需要灵活 schema 的场景 |
好消息是,Semantic Kernel 的 VectorStore 抽象层做了统一接口。 Demo 里写的 CompanyKnowledge 类,换向量库只需要把 InMemoryVectorStore 换成对应的连接器(比如 QdrantVectorStore),其他代码基本不用动。
csharp
// 生产环境示例:换成 Qdrant
var vectorStore = new QdrantVectorStore("http://qdrant-server:6334");
// 其余 CompanyKnowledge 代码不变
4.3 Embedding 模型选择
Embedding 模型直接决定了检索质量。几个主流选择:
国内 :阿里百炼 text-embedding-v3(Demo 用的就是这个,1024 维,中文效果好)、智谱 embedding-3、BGE 系列(开源可本地部署)。
国外 :OpenAI text-embedding-3-small(便宜够用)、text-embedding-3-large(精度更高)、Cohere embed-v4。
选模型时注意:向量维度要和向量库配置对齐;不同模型的向量维度不同,不能混用同一个集合;中文场景优先选中文语料训练充分的模型。
4.4 检索策略优化
生产环境的检索通常不会像 Demo 这么简单,常见的优化手段包括:
混合检索(Hybrid Search):同时用向量检索和关键词检索(BM25),然后融合排序。向量检索擅长理解语义("退货"能匹配"退款"),关键词检索擅长精确匹配(订单号、产品型号)。两者互补效果最好。
重排序(Reranking):先用向量检索快速捞出 top-20 条候选,再用一个 Cross-Encoder 重排序模型精排,取 top-5 喂给大模型。多一步排序,但准确率提升明显。
查询改写(Query Rewriting):用户的问题有时候很口语化或者很模糊,可以先让大模型把问题改写成更适合检索的形式。比如用户说"我买的东西不想要了",改写成"退货退款政策 时效 条件",检索效果会好很多。
多路召回:同时从多个知识库检索------产品文档、FAQ、历史工单------然后合并去重。适合知识来源分散的企业场景。
4.5 一个典型的生产架构
把上面这些拼在一起,生产环境的 RAG 长这样:
text
文档入库流程(离线):
PDF/Word/网页 → 文档解析 → 分块(Chunking) → Embedding → 写入向量数据库
在线检索流程(实时):
用户提问 → 查询改写 → 混合检索(向量+关键词) → 重排序 → top-K 片段
→ 拼装 Prompt(System Instructions + 检索结果 + 用户问题)
→ 大模型生成回答 → 返回给用户(附引用来源)
MAF 的 TextSearchProvider 帮你处理的是"在线检索流程"中和 Agent 对接的部分------什么时候检索、怎么把结果注入上下文。至于文档解析、分块、向量库选型、检索策略优化,这些需要你自己搭建。
六、小结
| 概念 | Demo 中的实现 | 生产建议 |
|---|---|---|
| 向量库 | InMemoryVectorStore |
换 Qdrant / Milvus / Azure AI Search |
| Embedding | text-embedding-v3 (1024 维) |
按语言和数据量选型 |
| 文档分块 | 整篇存储(Demo 文档短) | 固定长度 + overlap,或按结构切分 |
| RAG 接入 | TextSearchProvider + AIContextProviders |
同,这是 MAF 推荐方式 |
| 检索模式 | BeforeAIInvoke / OnDemand |
生产推荐 BeforeAIInvoke,稳定可靠 |
| 引用溯源 | SourceName + SourceLink |
必须保留,用户需要验证答案 |
RAG 的本质就是"开卷考试"------与其让大模型凭记忆回答(可能编造),不如先帮它查资料,再让它基于查到的内容作答。MAF 把这件事简化成了 TextSearchProvider + 一个搜索回调,你只需要关心"去哪里查",框架帮你搞定"什么时候查"和"怎么喂给模型"。