一个网页AI聊天,图像识别项目,使用到的技术栈有,Gin框架,GORM,rabbitmq,redis,eino框架,Vue框架等
项目地址:https://github.com/youngyangyang04/GopherAI
GopherAI第二版在第一版的基础上扩展了RAG服务,文件上传服务,MCP服务调用工具,TTS语言识别功能,下面进行一一梳理
RAG(Retrieval-Augmented Generation)是一种将信息检索与大语言模型生成结合的 AI 技术,通过先检索相关知识再生成答案,提高生成内容的准确性和可溯源性。
RAG 技术由 检索(Retrieval)模块和生成(Generation)模块组成。用户输入问题后,系统先从知识库或外部文档中检索相关信息片段,再将这些片段与问题一起输入大语言模型(LLM),生成逻辑连贯、基于证据的答案。这种方式弥补了 LLM 知识固定、可能过时或不完整的局限,同时减少生成内容的幻觉现象。
本项目的RAG实现大体逻辑是:
用户上传文件 -> embedding向量化 -> 存入redis向量库
用户提出问题 -> embedding向量化 -> redis检索资料返回 -> 拼提示词 -> 最后喂给大模型
路由层
路由层没有新增接口
控制器层
路由层没有变化控制器层自然也没有
服务层
在使用了创建或获取AIHelper函数的服务层函数中,对传入的map参数新增了一个键值对username,因为后续调用RAG需要直到要检索的是哪一个用户的RAG文件(另一个apiKey键值对从始至终就没用上,不知道留着干什么)
//2:获取AIHelper并通过其管理消息
manager := aihelper.GetGlobalManager()
config := map[string]interface{}{
"apiKey": "your-api-key", // TODO: 从配置中获取
"username": userName, // 用于 RAG 模型获取用户文档
}
helper, err := manager.GetOrCreateAIHelper(userName, createdSession.ID, modelType, config)
AIHelper的变化
首先AIHelper以及AIHelperManager的实现完全没有任何变化,主要新增了RAG实现和MCP实现,在common/aihelper/model.go文件,并在common/aihelper/factory.go进行了注册
(之前只有OpenAI和Ollama实现)
RAG模型回复实现
首先是定义了RAG模型结构体
// =================== RAG 实现 ===================
type AliRAGModel struct {
llm model.ToolCallingChatModel
username string // 用于获取用户的文档
}
然后是新建RAG模型的函数,没啥变化,还是调用Eino的函数然后封装到结构体
func NewAliRAGModel(ctx context.Context, username string) (*AliRAGModel, error) {
key := os.Getenv("OPENAI_API_KEY")
conf := config.GetConfig()
modelName := conf.RagModelConfig.RagChatModelName
baseURL := conf.RagModelConfig.RagBaseUrl
llm, err := openai.NewChatModel(ctx, &openai.ChatModelConfig{
BaseURL: baseURL,
Model: modelName,
APIKey: key,
})
if err != nil {
return nil, fmt.Errorf("create ali rag model failed: %v", err)
}
return &AliRAGModel{
llm: llm,
username: username,
}, nil
}
来看使用RAG的生成回复函数,首先创建了RAG查询器解析用户上传的RAG文件(如果用户没有上传就直接回复原始问题并返回),然后获取用户的最后一条消息,作为RAG的查询,检索文档进行RAG查询,构建出包含检索结果的提示词
最后再使用该提示词调用大模型生成回复
// 带RAG的生成回复
func (o *AliRAGModel) GenerateResponse(ctx context.Context, messages []*schema.Message) (*schema.Message, error) {
// 1. 创建 RAG 查询器
ragQuery, err := rag.NewRAGQuery(ctx, o.username)
if err != nil {
log.Printf("Failed to create RAG query (user may not have uploaded file): %v", err)
// 如果用户没有上传文件,直接使用原始问题
resp, err := o.llm.Generate(ctx, messages)
if err != nil {
return nil, fmt.Errorf("ali rag generate failed: %v", err)
}
return resp, nil
}
// 2. 获取用户最后一条消息作为查询
if len(messages) == 0 {
return nil, fmt.Errorf("no messages provided")
}
lastMessage := messages[len(messages)-1]
query := lastMessage.Content
// 3. 检索相关文档
docs, err := ragQuery.RetrieveDocuments(ctx, query)
if err != nil {
log.Printf("Failed to retrieve documents: %v", err)
// 检索失败,使用原始问题
resp, err := o.llm.Generate(ctx, messages)
if err != nil {
return nil, fmt.Errorf("ali rag generate failed: %v", err)
}
return resp, nil
}
// 4. 构建包含检索结果的提示词
ragPrompt := rag.BuildRAGPrompt(query, docs)
// 5. 替换最后一条消息为 RAG 提示词
ragMessages := make([]*schema.Message, len(messages))
copy(ragMessages, messages)
ragMessages[len(ragMessages)-1] = &schema.Message{
Role: schema.User,
Content: ragPrompt,
}
// 6. 调用 LLM 生成回答
resp, err := o.llm.Generate(ctx, ragMessages)
if err != nil {
return nil, fmt.Errorf("ali rag generate failed: %v", err)
}
return resp, nil
}
其他流式传输等服务函数流程也类似,不过多赘述
RAG实现
定义了两个结构体:RAGIndexer和RAGQuery,分别负责建立知识库和查询知识库,都有一个embedder成员用于生成文本的向量,indexer结构体还有一个redisindexer成员用于将向量存储到redis,query结构体还有一个retriever接口待实现用于检索向量知识库
type RAGIndexer struct {
embedding embedding.Embedder // 向量生成器(文本->向量)
indexer *redisIndexer.Indexer // redis存储器
}
type RAGQuery struct {
embedding embedding.Embedder // 向量生成器(文本->向量)
retriever retriever.Retriever // 向量检索接口(待实现)
}
首先是RAGIndexer结构体的构建函数,流程大概是,首先配置并创建向量生成器Embedding(Eino提供),然后配置并创建redis索引器,最后返回封装好的结构体
// 构建RAG索引器
func NewRAGIndexer(filename, embeddingModel string) (*RAGIndexer, error) {
// 用于控制整个初始化流程(超时 / 取消等),这里先用默认背景即可
ctx := context.Background()
// 从环境变量中读取调用向量模型所需的 API Key
apiKey := os.Getenv("OPENAI_API_KEY")
// 向量的维度大小(等于向量模型输出的数字个数)
// Redis 在创建向量索引时必须提前知道这个值
dimension := config.GetConfig().RagModelConfig.RagDimension
// ===============================
// 1. 配置并创建“向量生成器”(Embedding)
embedConfig := &embeddingArk.EmbeddingConfig{
BaseURL: config.GetConfig().RagModelConfig.RagBaseUrl, // 向量模型服务地址
APIKey: apiKey, // 鉴权信息
Model: embeddingModel, // 使用哪个向量模型
}
// 创建向量生成器实例
embedder, err := embeddingArk.NewEmbedder(ctx, embedConfig)
if err != nil {
return nil, fmt.Errorf("failed to create embedder: %w", err)
}
// ===============================
// 2. 初始化 Redis 中的向量索引结构
if err := redisPkg.InitRedisIndex(ctx, filename, dimension); err != nil {
return nil, fmt.Errorf("failed to init redis index: %w", err)
}
// 获取 Redis 客户端,用于后续数据写入
rdb := redisPkg.Rdb
// ===============================
// 3. 配置索引器(定义:文档如何被存进 Redis)
indexerConfig := &redisIndexer.IndexerConfig{
Client: rdb, // Redis 客户端
KeyPrefix: redis.GenerateIndexNamePrefix(filename), // 不同知识库使用不同前缀,避免冲突
BatchSize: 10, // 批量处理文档,提高写入效率
// 定义:一段文档(Document)在 Redis 中该如何存储
DocumentToHashes: func(ctx context.Context, doc *schema.Document) (*redisIndexer.Hashes, error) {
// 从文档的元数据中取出来源信息(例如文件名、URL)
source := ""
if s, ok := doc.MetaData["source"].(string); ok {
source = s
}
// 构造 Redis 中实际存储的数据结构(Hash)
return &redisIndexer.Hashes{
// Redis Key,一般由“知识库名 + 文档块 ID”组成
Key: fmt.Sprintf("%s:%s", filename, doc.ID),
// Redis Hash 中的字段
Field2Value: map[string]redisIndexer.FieldValue{
// content:原始文本内容
// EmbedKey 表示:该字段需要先做向量化,
// 生成的向量会存入名为 "vector" 的字段中
"content": {Value: doc.Content, EmbedKey: "vector"},
// metadata:一些辅助信息,不参与向量计算
"metadata": {Value: source},
},
}, nil
},
}
// 将“向量生成器”交给redis索引器
// 这样索引器在写入文本时,可以自动完成向量计算
indexerConfig.Embedding = embedder
// ===============================
// 4. 创建最终可用的索引器实例
idx, err := redisIndexer.NewIndexer(ctx, indexerConfig)
if err != nil {
return nil, fmt.Errorf("failed to create indexer: %w", err)
}
// 返回一个封装好的 RAGIndexer,
// 后续只需要调用它,就可以把文档加入知识库
return &RAGIndexer{
embedding: embedder,
indexer: idx,
}, nil
}
然后是RAGIndexer的方法读取文件内容并创建向量索引,将文件内容转为Eino框架提供的Document格式,最后使用redisindexer成员的Store方法对文档进行向量化储存
// IndexFile 读取文件内容并创建向量索引
func (r *RAGIndexer) IndexFile(ctx context.Context, filePath string) error {
// 读取文件内容
content, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("failed to read file: %w", err)
}
// 将文件内容转换为文档
// TODO: 这里可以根据需要进行文本切块,目前简单处理为一个文档
doc := &schema.Document{
ID: "doc_1", // 可以使用 UUID 或其他唯一标识
Content: string(content),
MetaData: map[string]any{
"source": filePath,
},
}
// 使用 indexer 存储文档(会自动进行向量化)
_, err = r.indexer.Store(ctx, []*schema.Document{doc})
if err != nil {
return fmt.Errorf("failed to store document: %w", err)
}
return nil
}
删除指定文件的知识库索引
// DeleteIndex 删除指定文件的知识库索引(静态方法,不依赖实例)
func DeleteIndex(ctx context.Context, filename string) error {
if err := redisPkg.DeleteRedisIndex(ctx, filename); err != nil {
return fmt.Errorf("failed to delete redis index: %w", err)
}
return nil
}
来看RAGQuery结构体的创建函数,配置并创建了embedding模型,配置并创建了retriever
// NewRAGQuery 创建 RAG 查询器(用于向量检索和问答)
func NewRAGQuery(ctx context.Context, username string) (*RAGQuery, error) {
cfg := config.GetConfig()
apiKey := os.Getenv("OPENAI_API_KEY")
// 创建 embedding 模型,用于向量化用户的问题
embedConfig := &embeddingArk.EmbeddingConfig{
BaseURL: cfg.RagModelConfig.RagBaseUrl,
APIKey: apiKey,
Model: cfg.RagModelConfig.RagEmbeddingModel,
}
embedder, err := embeddingArk.NewEmbedder(ctx, embedConfig)
if err != nil {
return nil, fmt.Errorf("failed to create embedder: %w", err)
}
// 获取用户上传的文件名(假设每个用户只有一个文件)
// 这里需要从用户目录读取文件名
userDir := fmt.Sprintf("uploads/%s", username)
files, err := os.ReadDir(userDir)
if err != nil || len(files) == 0 {
return nil, fmt.Errorf("no uploaded file found for user %s", username)
}
var filename string
for _, f := range files {
if !f.IsDir() {
filename = f.Name()
break
}
}
if filename == "" {
return nil, fmt.Errorf("no valid file found for user %s", username)
}
// 配置并创建 retriever
rdb := redisPkg.Rdb
indexName := redis.GenerateIndexName(filename) // redis索引名称
retrieverConfig := &redisRetriever.RetrieverConfig{
Client: rdb, // redis客户端
Index: indexName, // 索引名称
Dialect: 2, // redissearch查询语法版本
ReturnFields: []string{"content", "metadata", "distance"}, // 从redis返回的东西
TopK: 5, // 返回5个最相似的文档
VectorField: "vector", // 在vector字段作向量搜索
// 把redis返回数据转成document
DocumentConverter: func(ctx context.Context, doc redisCli.Document) (*schema.Document, error) {
resp := &schema.Document{
ID: doc.ID,
Content: "",
MetaData: map[string]any{},
}
for field, val := range doc.Fields {
if field == "content" {
resp.Content = val
} else {
resp.MetaData[field] = val
}
}
return resp, nil
},
}
retrieverConfig.Embedding = embedder // 自动执行
rtr, err := redisRetriever.NewRetriever(ctx, retrieverConfig)
if err != nil {
return nil, fmt.Errorf("failed to create retriever: %w", err)
}
return &RAGQuery{
embedding: embedder,
retriever: rtr,
}, nil
}
RAGQuery结构体的方法RetrieveDocuments检索文档,调用retriever成员的Retrieve方法
// RetrieveDocuments 检索相关文档
func (r *RAGQuery) RetrieveDocuments(ctx context.Context, query string) ([]*schema.Document, error) {
docs, err := r.retriever.Retrieve(ctx, query)
if err != nil {
return nil, fmt.Errorf("failed to retrieve documents: %w", err)
}
return docs, nil
}
构建包含检索RAG文档提示词的函数
// BuildRAGPrompt 构建包含检索文档的提示词
func BuildRAGPrompt(query string, docs []*schema.Document) string {
if len(docs) == 0 {
return query
}
contextText := ""
for i, doc := range docs {
contextText += fmt.Sprintf("[文档 %d]: %s\n\n", i+1, doc.Content)
}
prompt := fmt.Sprintf(`基于以下参考文档回答用户的问题。如果文档中没有相关信息,请说明无法找到相关信息。
参考文档:
%s
用户问题:%s
请提供准确、完整的回答:`, contextText, query)
return prompt
}
Redis部分
首先是两个函数,一个用于生成知识库的索引名称,一个用于生成知识库里文档的前缀
// 索引名称
func GenerateIndexName(filename string) string {
indexName := fmt.Sprintf(config.DefaultRedisKeyConfig.IndexName, filename)
return indexName
}
// key名称
func GenerateIndexNamePrefix(filename string) string {
prefix := fmt.Sprintf(config.DefaultRedisKeyConfig.IndexNamePrefix, filename)
return prefix
}
为RAG文件创建索引的函数以及删除索引的函数
// InitRedisIndex 初始化 Redis 索引,支持按文件名区分
func InitRedisIndex(ctx context.Context, filename string, dimension int) error {
indexName := GenerateIndexName(filename)
// 检查索引是否存在
_, err := Rdb.Do(ctx, "FT.INFO", indexName).Result()
if err == nil {
fmt.Println("索引已存在,跳过创建")
return nil
}
// 如果索引不存在,创建新索引
if !strings.Contains(err.Error(), "Unknown index name") {
return fmt.Errorf("检查索引失败: %w", err)
}
fmt.Println("正在创建 Redis 索引...")
prefix := GenerateIndexNamePrefix(filename)
// 创建索引
createArgs := []interface{}{
"FT.CREATE", indexName,
"ON", "HASH",
"PREFIX", "1", prefix,
"SCHEMA",
"content", "TEXT",
"metadata", "TEXT",
"vector", "VECTOR", "FLAT",
"6",
"TYPE", "FLOAT32",
"DIM", dimension,
"DISTANCE_METRIC", "COSINE",
}
if err := Rdb.Do(ctx, createArgs...).Err(); err != nil {
return fmt.Errorf("创建索引失败: %w", err)
}
fmt.Println("索引创建成功!")
return nil
}
// DeleteRedisIndex 删除 Redis 索引,支持按文件名区分
func DeleteRedisIndex(ctx context.Context, filename string) error {
indexName := GenerateIndexName(filename)
// 删除索引
if err := Rdb.Do(ctx, "FT.DROPINDEX", indexName).Err(); err != nil {
return fmt.Errorf("删除索引失败: %w", err)
}
fmt.Println("索引删除成功!")
return nil
} 


