[Go]GopherAI项目学习记录:RAG功能拓展

一个网页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
}
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇