利用RAG技术创建智能客服机器人

2026年4月18日 · 935 字 · 5 分钟

如何利用RAG(检索增强生成)技术,结合向量数据库和LLM,构建一个智能客服机器人

什么是RAG

RAG(Retrieval-Augmented Generation,检索增强生成)是一种将信息检索与生成式AI结合的技术架构。它解决了大语言模型(LLM)的两个核心问题:

  • 知识时效性:LLM的知识截止于训练时间,无法回答最新信息
  • 幻觉问题:LLM可能会编造不存在的信息

RAG通过先检索相关文档,再将文档作为上下文提供给LLM,让模型基于真实知识回答问题。

工作原理

用户问题: "美国的医疗和社会保障支出是多少?"
    ↓
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模型

使用Ollama本地部署的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
}

智能回答生成

将检索到的文档作为上下文,让LLM生成回答:

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标题切分,保持文档语义完整性:

markdown.NewHeaderSplitter(ctx, &markdown.HeaderConfig{
	Headers:     map[string]string{"##": "secondHeader"},
	TrimHeaders: false,
})

2. 向量加权

对标题和内容采用不同权重,突出核心主题:

最终向量 = (标题向量 × 2 + 内容向量) / 3

3. 相似度阈值

设置合理的相似度阈值,过滤不相关结果:

ScoreThreshold: 0.5  // 只返回相似度大于0.5的文档

4. 流式输出

使用流式输出提升用户体验,边生成边显示:

EnableStreaming: true