利用RAG技术创建智能客服机器人
2026年4月18日 · 935 字 · 5 分钟
如何利用RAG(检索增强生成)技术,结合向量数据库和LLM,构建一个智能客服机器人 RAG(Retrieval-Augmented Generation,检索增强生成)是一种将信息检索与生成式AI结合的技术架构。它解决了大语言模型(LLM)的两个核心问题: RAG通过先检索相关文档,再将文档作为上下文提供给LLM,让模型基于真实知识回答问题。 使用Ollama本地部署的Embedding模型: 文档索引是将知识文档向量化并存入向量数据库的过程: 代码中对标题和内容采用不同权重: 这样标题的权重更高,因为标题通常是文档的核心主题,更重要。 根据用户问题检索相关文档: 将检索到的文档作为上下文,让LLM生成回答: 使用Markdown标题切分,保持文档语义完整性: 对标题和内容采用不同权重,突出核心主题: 设置合理的相似度阈值,过滤不相关结果: 使用流式输出提升用户体验,边生成边显示:什么是RAG
工作原理
用户问题: "美国的医疗和社会保障支出是多少?"
↓
1. 向量检索:在知识库中找到相关文档
↓
2. 构建提示词:
"资料如下:美国的社会保障...医疗保险...
问题如下:美国的医疗和社会保障支出是多少?"
↓
3. LLM生成:基于资料回答问题
↓
输出答案:"社会保障占6.2%,医疗保险占1.45%,共7.65%"
系统架构
┌─────────────────────────────────────────────────────────────┐
│ 智能客服机器人架构 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ 知识文档 │───▶│ 文档处理 │───▶│ 向量化 │ │
│ │ (qa.md) │ │ (切分) │ │ (Embedding)│ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ Qdrant │ │
│ │ 向量数据库 │ │
│ └─────────────┘ │
│ │ │
│ ┌─────────────┐ │ │
│ │ 用户问题 │ │ │
│ └─────────────┘ │ │
│ │ │ │
│ ▼ │ │
│ ┌─────────────┐ │ │
│ │ 问题向量化 │ │ │
│ └─────────────┘ │ │
│ │ │ │
│ ▼ ▼ │
│ ┌───────────────────────────────────────────────┐ │
│ │ 语义相似检索 │ │
│ │ 找到与问题最相似的文档片段 │ │
│ └───────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────┐ │
│ │ 构建Prompt │ │
│ │ 资料: [检索到的文档内容] │ │
│ │ 问题: [用户的问题] │ │
│ └───────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ LLM │ │
│ │ (回答生成) │ │
│ └─────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ 最终答案 │ │
│ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
代码实现
依赖安装
go get github.com/qdrant/go-client/qdrant
go get github.com/cloudwego/eino-ext/components/embedding/ollama
go get github.com/cloudwego/eino-ext/components/retriever/qdrant
go get github.com/cloudwego/eino-ext/components/model/openai
go get github.com/cloudwego/eino/adk
数据结构定义
package rag
import (
"context"
"github.com/cloudwego/eino-ext/components/embedding/ollama"
"github.com/qdrant/go-client/qdrant"
)
// Doc 文档结构
type Doc struct {
ID string
Title string
Content string
Vector []float64
}
var embeddder *ollama.Embedder
var collectionName = "qa"
var qdrantClientRag *qdrant.Client
var dataBase = make(map[string]*Doc, 300) // 模拟数据库存储
初始化组件
连接Qdrant向量数据库
func NewQdrantClientRag(ctx context.Context) error {
var err error
qdrantClientRag, err = qdrant.NewClient(&qdrant.Config{
Host: "127.0.0.1",
Port: 6334,
})
if err != nil {
return err
}
return nil
}
初始化Embedding模型
func NewEmbedderRag(ctx context.Context) error {
config := config.NewConfig("../config/config.json")
var err error
embeddder, err = ollama.NewEmbedder(ctx, &ollama.EmbeddingConfig{
BaseURL: config.EmbeddingAPIURL,
Model: config.EmbeddingModel,
})
if err != nil {
return err
}
return nil
}
文档索引流程
func IndexDocuments(ctx context.Context, filePath string) error {
// 1. 加载文档
txtParser := parser.TextParser{}
fileLoader, _ := file.NewFileLoader(ctx, &file.FileLoaderConfig{
UseNameAsID: true,
Parser: txtParser,
})
docs, _ := fileLoader.Load(ctx, document.Source{URI: filePath})
// 2. 按标题切分文档
spiltDoc, _ := markdown.NewHeaderSplitter(ctx, &markdown.HeaderConfig{
Headers: map[string]string{"##": "secondHeader"},
TrimHeaders: false,
})
transformedDocs, _ := spiltDoc.Transform(ctx, docs)
// 3. 向量化并存储
points := make([]*qdrant.PointStruct, 0)
for _, doc := range transformedDocs {
content := strings.Split(doc.Content, "\n")
if len(content) == 0 {
continue
}
title := content[0]
content = content[1:]
// 只处理二级标题
if !strings.HasPrefix(title, "##") {
continue
}
title = title[3:]
// 文档向量化:标题权重2,内容权重1
embeddings, _ := embeddder.EmbedStrings(ctx,
[]string{title, title, content[0]})
// 多向量加权平均
average := make([]float64, len(embeddings[0]))
for _, embedding := range embeddings {
for j, v := range embedding {
average[j] += v
}
}
for j := range average {
average[j] /= float64(len(embeddings))
}
// 向量归一化
sum := 0.
for _, v := range average {
sum += v * v
}
mo := math.Sqrt(sum)
for j, v := range average {
average[j] = v / mo
}
// 存储到内存数据库
id := uuid.New().String()
dataBase[id] = &Doc{
ID: id,
Title: title,
Content: content[0],
Vector: average,
}
// 准备Qdrant存储结构
points = append(points, &qdrant.PointStruct{
Id: &qdrant.PointId{
PointIdOptions: &qdrant.PointId_Uuid{Uuid: id}
},
Vectors: &qdrant.Vectors{
VectorsOptions: &qdrant.Vectors_Vector{
Vector: &qdrant.Vector{
Vector: &qdrant.Vector_Dense{
Dense: &qdrant.DenseVector{
Data: ToFloat32(average)
}
}
}
}
},
})
}
// 4. 创建集合并写入数据
qdrantClientRag.CreateCollection(ctx, &qdrant.CreateCollection{
CollectionName: collectionName,
VectorsConfig: &qdrant.VectorsConfig{
Config: &qdrant.VectorsConfig_Params{
Params: &qdrant.VectorParams{
Size: 768,
Distance: qdrant.Distance_Cosine,
},
},
},
})
qdrantClientRag.Upsert(ctx, &qdrant.UpsertPoints{
CollectionName: collectionName,
Points: points,
})
return nil
}
加权向量化说明
embeddings, _ := embeddder.EmbedStrings(ctx,
[]string{title, title, content[0]}) // title出现2次,content出现1次
语义检索
func RetrieveDocument(ctx context.Context, query string, limit int) []*Doc {
ScoreThreshold := 0.5 // 相似度阈值
client, _ := qdrant_retriever.NewRetriever(ctx, &qdrant_retriever.Config{
Client: qdrantClientRag,
Collection: collectionName,
Embedding: embeddder,
ScoreThreshold: &ScoreThreshold,
TopK: limit, // 返回最相似的N个文档
})
res, _ := client.Retrieve(ctx, query)
docs := make([]*Doc, 0)
for _, doc := range res {
id := doc.ID
doc := dataBase[id]
docs = append(docs, &Doc{
Title: doc.Title,
Content: doc.Content,
})
}
return docs
}
智能回答生成
func ChatBot(message schema.Message, ctx context.Context,
callBack func(s string)) {
config := config.NewConfig("../config/config.json")
model, _ := openai.NewChatModel(ctx, &openai.ChatModelConfig{
BaseURL: config.Url,
APIKey: config.APIKey,
Model: config.Model,
})
// 创建Agent
a, _ := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
Model: model,
Name: "customer_service",
Description: "客服机器人",
Instruction: "请根据我提供的资料,回答用户的问题。",
})
// 流式输出
runner := adk.NewRunner(ctx, adk.RunnerConfig{
Agent: a,
EnableStreaming: true,
})
iter := runner.Run(ctx, []*schema.Message{&message})
for {
e, ok := iter.Next()
if !ok {
break
}
if e.Err != nil {
break
}
if e.Output.MessageOutput.IsStreaming {
e.Output.MessageOutput.MessageStream.SetAutomaticClose()
for {
streamMsg, err := e.Output.MessageOutput.MessageStream.Recv()
if errors.Is(err, io.EOF) {
break
}
callBack(streamMsg.Content)
}
}
}
}
完整示例
测试代码
func TestIndexDocuments(t *testing.T) {
ctx := context.Background()
// 1. 初始化组件
err := NewEmbedderRag(ctx)
if err != nil {
t.Fatal(err)
}
err = NewQdrantClientRag(ctx)
if err != nil {
t.Fatal(err)
}
// 2. 索引文档
err = IndexDocuments(ctx, "./data/qa.md")
if err != nil {
t.Fatal(err)
}
// 3. 检索相关文档
res := RetrieveDocument(ctx,
"在美国,一个企业雇一个员工,医疗和社会保障方面的支出有多少?", 4)
// 4. 构建Prompt
qurry := "资料如下:"
for k, doc := range res {
jsonStr, _ := json.Marshal(doc)
qurry += fmt.Sprintf("%d. ", k+1)
qurry += string(jsonStr)
}
qurry += "问题如下:在美国,一个企业雇一个员工," +
"医疗和社会保障方面的支出有多少?"
// 5. 生成回答
ChatBot(*schema.UserMessage(qurry), ctx, func(s string) {
fmt.Printf("%s", s)
})
}
运行测试
go test -v -run TestIndexDocuments ./rag/
运行结果
=== RUN TestIndexDocuments
operation_id:1 status:Acknowledged
根据提供的资料,美国企业在雇佣员工时,医疗和社会保障方面的支出主要包括以下两部分:
- **社会保障(Social Security)**:占雇员工资的6.2%。
- **医疗保险(Medicare)**:占雇员工资的1.45%。
因此,医疗和社会保障方面的总支出为**6.2% + 1.45% = 7.65%**。
### 说明:
- 该比例基于资料中的一般成本估计
- 其他成本(联邦失业率0.60%、国家失业率2.7%等)不属于"医疗和社会保障"范畴
- 产假和陪产假通常是法定的无薪假,不会直接增加企业成本
--- PASS: TestIndexDocuments (11.37s)
PASS
关键技术点
1. 文档切分策略
markdown.NewHeaderSplitter(ctx, &markdown.HeaderConfig{
Headers: map[string]string{"##": "secondHeader"},
TrimHeaders: false,
})
2. 向量加权
最终向量 = (标题向量 × 2 + 内容向量) / 3
3. 相似度阈值
ScoreThreshold: 0.5 // 只返回相似度大于0.5的文档
4. 流式输出
EnableStreaming: true