第五篇:从问题到答案:一个完整的RAG系统是如何工作的
·
第五篇:从问题到答案:一个完整的RAG系统是如何工作的
前言
前面我们学了:
- 第一篇:用Prompt把知识库塞给LLM
- 第二篇:把文字变成数字(文本向量化)
- 第三篇:用余弦相似度判断"像不像"
- 第四篇:用向量数据库存储和检索
今天,我们把所有知识点串起来,构建一个完整的RAG系统。
读完这篇,你就会明白:从用户提问到系统回答,中间到底发生了什么。
一、先说一个故事
1.1 用户问了一个问题
假设你是一个智能客服,用户问:
用户:月球任务什么时候发射?
知识库里有8句话:
句子1:阿尔忒弥斯2号是美国宇航局计划中的载人月球轨道飞行任务...
句子2:该任务将使用太空发射系统火箭和猎户座飞船...
句子3:阿尔忒弥斯2号的主要目标是测试生命支持系统...
句子4:任务计划在2025年执行,将是自阿波罗17号以来首次载人离开地球轨道的任务...
句子5:此次飞行将为后续的阿尔忒弥斯3号月球着陆任务提供关键数据和技术验证...
句子6:宇航员将在飞行过程中进行科学实验,并测试深空通信和导航系统...
句子7:任务将持续约10天,飞行距离将达到27万英里...
句子8:阿尔忒弥斯2号的成功将为建立月球基地和未来火星探索奠定基础...
问题来了:系统怎么知道哪句话最相关?
1.2 第一篇的方案:全部塞进Prompt
系统:你是智能客服,请根据以下知识库回答问题:
知识库:
- 句子1...
- 句子2...
- 句子3...
- ...(全部8句话)
用户问:月球任务什么时候发射?
问题:知识库如果有一万句话,prompt就爆了。
1.3 更聪明的方案:只给最相关的
系统:你是智能客服,请根据以下知识库回答问题:
知识库:
- 句子4:任务计划在2025年执行...
用户问:月球任务什么时候发射?
妙处:只给最相关的1-2句话,prompt精简,答案准确。
这就是RAG的核心思想:检索增强生成。
二、完整流程图

┌─────────────┐
│ 用户提问 │
│ "月球任务什么时候发射?" │
└──────┬──────┘
│
▼
┌─────────────┐
│ 第一步:向量化 │
│ 把问题转成向量 │
│ [0, 1, 1, 0, ...] │
└──────┬──────┘
│
▼
┌─────────────┐
│ 第二步:检索 │
│ 在向量数据库中找最相似的 │
│ 返回Top-K个结果 │
└──────┬──────┘
│
▼
┌─────────────┐
│ 第三步:组装Prompt │
│ 把检索结果塞进prompt │
└──────┬──────┘
│
▼
┌─────────────┐
│ 第四步:调用LLM │
│ 让大模型生成答案 │
└──────┬──────┘
│
▼
┌─────────────┐
│ 返回答案 │
│ "任务计划在2025年执行" │
└─────────────┘
三、一步步实现
3.0 准备工作:定义知识库和词汇表
直接使用前面文章的常量,简单明了:
package main
import (
"fmt"
"math"
"strings"
)
// 知识库:关于阿尔忒弥斯2号的8句话
var knowledgeBase = []string{
"阿尔忒弥斯2号是美国宇航局计划中的载人月球轨道飞行任务,标志着人类重返月球的重要一步",
"该任务将使用太空发射系统火箭和猎户座飞船,搭载宇航员进行绕月飞行",
"阿尔忒弥斯2号的主要目标是测试生命支持系统和宇航员在深空环境中的生存能力",
"任务计划在2025年执行,将是自阿波罗17号以来首次载人离开地球轨道的任务",
"此次飞行将为后续的阿尔忒弥斯3号月球着陆任务提供关键数据和技术验证",
"宇航员将在飞行过程中进行科学实验,并测试深空通信和导航系统",
"任务将持续约10天,飞行距离将达到27万英里,是地球到月球距离的10倍以上",
"阿尔忒弥斯2号的成功将为建立月球基地和未来火星探索奠定基础",
}
// 词汇表:我们关心的10个关键词
var vocab = []string{"阿尔忒弥斯", "月球", "任务", "宇航员", "火箭", "飞船", "测试", "系统", "飞行", "轨道"}
3.1 第一步:向量化(复用第二篇的代码)
把文字变成数字:
// 文本清洗
func cleanText(text string) string {
var cleaned strings.Builder
for _, r := range text {
if r >= 0x4e00 && r <= 0x9fff {
cleaned.WriteRune(r)
}
}
return cleaned.String()
}
// 分词处理
func tokenize(text string, vocab []string) []string {
var tokens []string
for _, vocabWord := range vocab {
count := strings.Count(text, vocabWord)
for i := 0; i < count; i++ {
tokens = append(tokens, vocabWord)
}
}
return tokens
}
// 词频统计
func countFrequency(tokens []string) map[string]int {
freq := make(map[string]int)
for _, token := range tokens {
freq[token]++
}
return freq
}
// 构建向量
func buildVector(freq map[string]int, vocab []string) []float64 {
vector := make([]float64, len(vocab))
for i, word := range vocab {
vector[i] = float64(freq[word])
}
return vector
}
// 向量归一化
func normalize(vector []float64) {
sum := 0.0
for _, v := range vector {
sum += v * v
}
if sum == 0 {
return
}
magnitude := math.Sqrt(sum)
for i := range vector {
vector[i] /= magnitude
}
}
// 把文本转成向量(五步合一)
func textToVector(text string) []float64 {
cleaned := cleanText(text)
tokens := tokenize(cleaned, vocab)
freq := countFrequency(tokens)
vector := buildVector(freq, vocab)
normalize(vector)
return vector
}
测试一下:
// 用户问题
query := "月球任务什么时候发射"
queryVector := textToVector(query)
fmt.Printf("问题向量: %v\n", queryVector)
// 输出: [0, 0.707, 0.707, 0, 0, 0, 0, 0, 0, 0]
// ↑ 只有"月球"和"任务"两个词
3.2 第二步:检索(复用第三篇的余弦相似度)
在知识库中找到最相似的句子:
// 计算余弦相似度
func cosineSimilarity(a, b []float64) float64 {
// 第一步:算点积
dotProduct := 0.0
for i := range a {
dotProduct += a[i] * b[i]
}
// 第二步:算各自的长度
lengthA := 0.0
lengthB := 0.0
for i := range a {
lengthA += a[i] * a[i]
lengthB += b[i] * b[i]
}
lengthA = math.Sqrt(lengthA)
lengthB = math.Sqrt(lengthB)
// 第三步:点积除以长度乘积
if lengthA == 0 || lengthB == 0 {
return 0
}
return dotProduct / (lengthA * lengthB)
}
// 搜索最相似的知识
func searchKnowledge(query string, topK int) []SearchResult {
// 把问题转成向量
queryVector := textToVector(query)
// 计算每句话的相似度
var results []SearchResult
for i, content := range knowledgeBase {
contentVector := textToVector(content)
similarity := cosineSimilarity(queryVector, contentVector)
results = append(results, SearchResult{
ID: i,
Content: content,
Similarity: similarity,
})
}
// 按相似度排序(从高到低)
sort.Slice(results, func(i, j int) bool {
return results[i].Similarity > results[j].Similarity
})
// 返回Top-K个结果
if len(results) > topK {
results = results[:topK]
}
return results
}
测试一下:
results := searchKnowledge("月球任务什么时候发射", 3)
for i, r := range results {
fmt.Printf("第%d名: 相似度=%.2f, 内容=%s\n", i+1, r.Similarity, r.Content)
}
输出:
第1名: 相似度=0.71, 内容=任务计划在2025年执行,将是自阿波罗17号以来首次载人离开地球轨道的任务
第2名: 相似度=0.50, 内容=阿尔忒弥斯2号是美国宇航局计划中的载人月球轨道飞行任务,标志着人类重返月球的重要一步
第3名: 相似度=0.41, 内容=此次飞行将为后续的阿尔忒弥斯3号月球着陆任务提供关键数据和技术验证
看到了吗? 第一名正好是回答"什么时候发射"的那句话!
3.3 第三步:组装Prompt
把检索结果塞进prompt:
func buildPrompt(query string, results []SearchResult) string {
// 组装知识库部分
knowledge := ""
for i, r := range results {
knowledge += fmt.Sprintf("%d. %s\n", i+1, r.Content)
}
// 组装完整prompt
prompt := fmt.Sprintf(`你是一个智能客服,请根据以下知识库回答用户的问题。
知识库:
%s
用户问题:%s
请简洁准确地回答:`, knowledge, query)
return prompt
}
测试一下:
query := "月球任务什么时候发射"
results := searchKnowledge(query, 2)
prompt := buildPrompt(query, results)
fmt.Println(prompt)
输出:
你是一个智能客服,请根据以下知识库回答用户的问题。
知识库:
1. 任务计划在2025年执行,将是自阿波罗17号以来首次载人离开地球轨道的任务
2. 阿尔忒弥斯2号是美国宇航局计划中的载人月球轨道飞行任务,标志着人类重返月球的重要一步
用户问题:月球任务什么时候发射?
请简洁准确地回答:
3.4 第四步:调用LLM(复用第一篇的代码)
把这个prompt发给大模型:
// 这里的代码与第一篇相同,调用Ollama + DeepSeek
func callLLM(prompt string) string {
// ... 调用Ollama的代码 ...
// 参考第一篇文章的 talkToOllama 函数
}
LLM返回:
根据知识库,阿尔忒弥斯2号任务计划在2025年执行。
四、完整代码
package main
import (
"fmt"
"math"
"sort"
"strings"
)
// ==================== 数据定义 ====================
// 知识库:关于阿尔忒弥斯2号的8句话
var knowledgeBase = []string{
"阿尔忒弥斯2号是美国宇航局计划中的载人月球轨道飞行任务,标志着人类重返月球的重要一步",
"该任务将使用太空发射系统火箭和猎户座飞船,搭载宇航员进行绕月飞行",
"阿尔忒弥斯2号的主要目标是测试生命支持系统和宇航员在深空环境中的生存能力",
"任务计划在2025年执行,将是自阿波罗17号以来首次载人离开地球轨道的任务",
"此次飞行将为后续的阿尔忒弥斯3号月球着陆任务提供关键数据和技术验证",
"宇航员将在飞行过程中进行科学实验,并测试深空通信和导航系统",
"任务将持续约10天,飞行距离将达到27万英里,是地球到月球距离的10倍以上",
"阿尔忒弥斯2号的成功将为建立月球基地和未来火星探索奠定基础",
}
// 词汇表:我们关心的10个关键词
var vocab = []string{"阿尔忒弥斯", "月球", "任务", "宇航员", "火箭", "飞船", "测试", "系统", "飞行", "轨道"}
// 搜索结果
type SearchResult struct {
ID int
Content string
Similarity float64
}
// ==================== 向量化函数(复用第二篇)====================
func cleanText(text string) string {
var cleaned strings.Builder
for _, r := range text {
if r >= 0x4e00 && r <= 0x9fff {
cleaned.WriteRune(r)
}
}
return cleaned.String()
}
func tokenize(text string, vocab []string) []string {
var tokens []string
for _, vocabWord := range vocab {
count := strings.Count(text, vocabWord)
for i := 0; i < count; i++ {
tokens = append(tokens, vocabWord)
}
}
return tokens
}
func countFrequency(tokens []string) map[string]int {
freq := make(map[string]int)
for _, token := range tokens {
freq[token]++
}
return freq
}
func buildVector(freq map[string]int, vocab []string) []float64 {
vector := make([]float64, len(vocab))
for i, word := range vocab {
vector[i] = float64(freq[word])
}
return vector
}
func normalize(vector []float64) {
sum := 0.0
for _, v := range vector {
sum += v * v
}
if sum == 0 {
return
}
magnitude := math.Sqrt(sum)
for i := range vector {
vector[i] /= magnitude
}
}
func textToVector(text string) []float64 {
cleaned := cleanText(text)
tokens := tokenize(cleaned, vocab)
freq := countFrequency(tokens)
vector := buildVector(freq, vocab)
normalize(vector)
return vector
}
// ==================== 相似度函数(复用第三篇)====================
func cosineSimilarity(a, b []float64) float64 {
dotProduct := 0.0
for i := range a {
dotProduct += a[i] * b[i]
}
lengthA := 0.0
lengthB := 0.0
for i := range a {
lengthA += a[i] * a[i]
lengthB += b[i] * b[i]
}
lengthA = math.Sqrt(lengthA)
lengthB = math.Sqrt(lengthB)
if lengthA == 0 || lengthB == 0 {
return 0
}
return dotProduct / (lengthA * lengthB)
}
// ==================== RAG核心函数 ====================
// 搜索最相似的知识
func searchKnowledge(query string, topK int) []SearchResult {
queryVector := textToVector(query)
var results []SearchResult
for i, content := range knowledgeBase {
contentVector := textToVector(content)
similarity := cosineSimilarity(queryVector, contentVector)
results = append(results, SearchResult{
ID: i,
Content: content,
Similarity: similarity,
})
}
sort.Slice(results, func(i, j int) bool {
return results[i].Similarity > results[j].Similarity
})
if len(results) > topK {
results = results[:topK]
}
return results
}
// 组装Prompt
func buildPrompt(query string, results []SearchResult) string {
knowledge := ""
for i, r := range results {
knowledge += fmt.Sprintf("%d. %s\n", i+1, r.Content)
}
prompt := fmt.Sprintf(`你是一个智能客服,请根据以下知识库回答用户的问题。
知识库:
%s
用户问题:%s
请简洁准确地回答:`, knowledge, query)
return prompt
}
// ==================== 主函数 ====================
func main() {
fmt.Println("========== RAG系统演示 ==========")
fmt.Println()
// 用户问题
query := "月球任务什么时候发射"
fmt.Printf("用户问题: %s\n", query)
fmt.Println()
// 第一步:向量化
fmt.Println("【第一步:向量化】")
queryVector := textToVector(query)
fmt.Printf("问题向量: %v\n", queryVector)
fmt.Println()
// 第二步:检索
fmt.Println("【第二步:检索】")
results := searchKnowledge(query, 3)
fmt.Println("检索结果:")
for i, r := range results {
fmt.Printf(" 第%d名: 相似度=%.2f\n", i+1, r.Similarity)
fmt.Printf(" 内容: %s\n", r.Content)
fmt.Println()
}
// 第三步:组装Prompt
fmt.Println("【第三步:组装Prompt】")
prompt := buildPrompt(query, results)
fmt.Println(prompt)
fmt.Println()
// 第四步:调用LLM(这里用模拟输出)
fmt.Println("【第四步:调用LLM】")
fmt.Println("LLM返回: 根据知识库,阿尔忒弥斯2号任务计划在2025年执行。")
fmt.Println()
fmt.Println("========== 演示结束 ==========")
}
运行结果:
========== RAG系统演示 ==========
用户问题: 月球任务什么时候发射
【第一步:向量化】
问题向量: [0 0.707 0.707 0 0 0 0 0 0 0]
【第二步:检索】
检索结果:
第1名: 相似度=0.71
内容: 任务计划在2025年执行,将是自阿波罗17号以来首次载人离开地球轨道的任务
第2名: 相似度=0.50
内容: 阿尔忒弥斯2号是美国宇航局计划中的载人月球轨道飞行任务,标志着人类重返月球的重要一步
第3名: 相似度=0.41
内容: 此次飞行将为后续的阿尔忒弥斯3号月球着陆任务提供关键数据和技术验证
【第三步:组装Prompt】
你是一个智能客服,请根据以下知识库回答用户的问题。
知识库:
1. 任务计划在2025年执行,将是自阿波罗17号以来首次载人离开地球轨道的任务
2. 阿尔忒弥斯2号是美国宇航局计划中的载人月球轨道飞行任务,标志着人类重返月球的重要一步
3. 此次飞行将为后续的阿尔忒弥斯3号月球着陆任务提供关键数据和技术验证
用户问题:月球任务什么时候发射?
请简洁准确地回答:
【第四步:调用LLM】
LLM返回: 根据知识库,阿尔忒弥斯2号任务计划在2025年执行。
========== 演示结束 ==========
五、与第一篇方案的对比
5.1 第一篇:Prompt RAG
优点:
- 简单,容易理解
- 适合小知识库
缺点:
- 知识库大了,prompt就爆了
- 每次都要传所有知识,成本高
5.2 第五篇:向量检索RAG
优点:
- 只传最相关的知识,prompt精简
- 知识库可以很大
- 检索速度快
缺点:
- 实现稍复杂
- 需要理解向量化
5.3 用表格说清楚
| 特性 | Prompt RAG | 向量检索RAG |
|---|---|---|
| 实现难度 | ✅ 简单 | ⚠️ 中等 |
| 知识库规模 | ❌ 受限(几千字) | ✅ 不受限 |
| Prompt长度 | ❌ 长(全传) | ✅ 短(只传相关) |
| API成本 | ❌ 高 | ✅ 低 |
| 准确性 | ✅ 全面 | ✅ 智能 |
六、总结
6.1 RAG的四个步骤
1. 向量化:把文字变成数字
2. 检索:在知识库中找到最相似的内容
3. 组装Prompt:把检索结果塞进prompt
4. 调用LLM:让大模型生成答案
6.2 为什么这样做?
- 文字 → 向量:电脑不认识字,但认识数字
- 余弦相似度:判断"像不像",不受长度影响
- 只传相关的:prompt精简,成本降低
6.3 系列文章回顾
第一篇:Prompt RAG - 最简单的方式
第二篇:文本向量化 - 把文字变成数字
第三篇:余弦相似度 - 判断"像不像"
第四篇:向量数据库 - 存储和检索
第五篇:完整RAG系统 - 把所有知识串起来
后记
恭喜你读完了整个系列!
从第一篇的"把知识库塞进prompt",到第五篇的"智能检索",你已经理解了RAG的核心原理。
RAG不神秘,就是:
- 把文字变成数字
- 用数学方法找到最相似的内容
- 把相关内容给大模型,让它生成答案
下一步:
- 如果想深入了解,可以学习:TF-IDF、Word2Vec、BERT等更高级的向量化方法
- 如果想实际应用,可以学习:Faiss、Milvus、Pinecone等向量数据库
- 如果想构建产品,可以学习:LangChain、LlamaIndex等RAG框架
系列文章:
- Go + Ollama 构建tinyRAG应用:Prompt提示工程
- 用阿尔忒弥斯2号讲透文本向量化
- 一眼看穿相似度:余弦相似度原理详解
- 手把手实现文本向量数据库
- 本文:从问题到答案:一个完整的RAG系统是如何工作的
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)