[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"article-39":3},{"id":4,"title":5,"title_en":6,"abstract":7,"abstract_en":8,"content":9,"content_en":10,"category":11,"banner_id":12,"banner_path":13,"tags":14,"is_recommend":17,"prev_article":18,"next_article":22,"created_at":26},39,"当 Go 遇见 Gemini：构建高可靠 AI 向量化流水线的实战","When Go Meets Gemini: Building a High-Reliability AI Vectorization Pipeline in Practice","\n# 你有没有想过，当你在公司知识库里问“今年的年假政策是什么”，AI 为什么能准确找到答案？\n\n它","Have you ever wondered how AI accurately finds the answer when you ask \"What is this year's annual leave policy?\" in your company's knowledge base?","\n# 你有没有想过，当你在公司知识库里问“今年的年假政策是什么”，AI 为什么能准确找到答案？\n\n它背后经历了一套怎样的流程？今天我们就用大白话聊聊这件事。\n\n---\n\n## 一、为什么选 Go，而不是 Python？\n\n先回答一个大家都会问的问题：AI 领域明明 Python 最流行，为什么偏要用 Go？\n\n### 1.1 打个比方就明白了\n如果把写代码比作做饭：\n* **Python** 就像一个工具齐全的大厨房，适合研究新菜式（训练模型、做实验）。\n* **Go** 就像一个快餐连锁店的中央厨房，适合稳定、大量、快速地出餐（处理用户请求）。\n\nRAG（检索增强生成）知识库系统本质上属于后者。它的工作流程是：**收到用户提问 → 调 AI 接口 → 等结果 → 返回给用户**。这个过程中 90% 的时间其实是在等待网络回应，而不是在算数学题。Go 恰好最擅长处理“等待”这件事。\n\n### 1.2 并发能力：Go 就像有无数个帮手\n想象一下你去奶茶店排队：\n* **Python** 就像一个店员，一次只能服务一个人（这就是 GIL 全局锁）。来了 100 个客人，后面 99 个只能干等。\n* **Go** 就像有几千个店员，每个客人都能立刻被接待。而且这些“店员”（Goroutine）只占很少的内存资源，一台普通服务器就能同时处理上万个请求。\n\n### 1.3 部署：一个文件搞定\nPython 项目部署时经常遇到：本机能跑，服务器跑不了；环境版本不兼容；装依赖装到一半网络超时。而 Go 编译完就生成一个单一的二进制文件，传到服务器直接运行——省心程度堪比“拎包入住”。\n\n### 1.4 Go 的不足，恰好用不上\n有人说 Go 的 AI 生态不如 Python——说得对。但 RAG 系统不需要在本地训练模型，它只是调用 AI。你需要的是管理用户连接、读写数据库、调度任务，这些正是 Go 的强项。**AI 负责“思考”，Go 负责“组织”**，二者各司其职。\n\n---\n\n## 二、第一个问题：文件格式五花八门怎么办？\n\n企业知识库里的文件千奇百怪：Word、PDF、TXT、Markdown、HTML……向量化的第一步是把这些文件变成纯文字。\n\n### 2.1 一个偷懒又聪明的办法\n如果每个格式都自己写代码解析，维护成本会很高。我们的做法是：找个“翻译官”。把文件上传到 Google Drive，利用它内置的转换功能统一转成 Google Docs 格式，再导出为纯文本。\n\n```go\n\u002F\u002F 判断文件类型：能转成文本的才走 AI 处理\nswitch ext {\ncase \".pdf\", \".docx\", \".doc\", \".txt\", \".html\", \".md\":\n    \u002F\u002F 上传时自动转成 Google Docs 格式\n    \u002F\u002F 云存储会帮我们处理格式转换\n    canConvert = true\n}\n\n\u002F\u002F 提取文本：不管原文件是 PDF 还是 Word，统一导出为纯文本\nfunc ExtractText(fileID string) (string, error) {\n    resp, err := service.Files.Export(fileID, \"text\u002Fplain\").Download()\n    if err != nil {\n        return \"\", err\n    }\n    defer resp.Body.Close()\n    \n    content, err := io.ReadAll(resp.Body)\n    if err != nil {\n        return \"\", err\n    }\n    return string(content), nil\n}\n```\n\n这样做的好处很直接：**代码只需要处理“纯文本”这一种格式**，其他所有格式兼容问题，统统交给 Google 等云端服务处理。\n\n### 2.2 顺便还能去个重\n上传时给文件计算一个“指纹”——业内叫 **SHA256 哈希值**。如果两个人传了同一份文件，指纹一样，直接跳过处理，省时省力。\n\n---\n\n## 三、第二个问题：长文档怎么切？\n\nAI 有“阅读上限”（上下文窗口限制）。你不能把整本《三体》一次性丢给它，得切成小块。\n\n### 3.1 为什么不能一刀切？\n想象一段话：\n> “根据公司规定，员工每年享有 15 天带薪年假，可分次使用。”\n\n如果恰好从“员工”和“每年”中间切开：\n* **第一块**：“根据公司规定，员工”\n* **第二块**：“每年享有 15 天带薪年假”\n\n两句话单独看语义都很奇怪。解决方案是**重叠切片**——切的时候互相重叠一部分：\n* **第一块**：`[根据公司规定，员工每年享有 15 天]`\n* **第二块**：`[每年享有 15 天带薪年假，可分次使用]`\n\n看到没？“每年享有 15 天”在两块里都出现了。这样无论从哪块检索，都不会丢失关键信息。代码实现也不复杂：\n\n```go\n\u002F\u002F ChunkText 重叠切片算法\n\u002F\u002F chunkSize = 每块字数，overlap = 重叠字数\nfunc ChunkText(text string, chunkSize int, overlap int) []string {\n    runes := []rune(text) \u002F\u002F 处理中文，按字符切分\n    var chunks []string\n    \n    for i := 0; i \u003C len(runes); {\n        end := i + chunkSize\n        if end > len(runes) {\n            end = len(runes)\n        }\n        chunks = append(chunks, string(runes[i:end]))\n        if end == len(runes) {\n            break\n        }\n        i = end - overlap \u002F\u002F 后退 overlap 个字，实现重叠\n    }\n    return chunks\n}\n```\n\n> [!IMPORTANT]\n> 注意要用 `[]rune` 而不是 `[]byte`——中文在 UTF-8 编码下占 3 个字节，如果按字节切会切出乱码。\n\n### 3.2 参数怎么定？\n通常建议：**每个片段 500-800 字，重叠 50-100 字**。这相当于每段 A4 纸大半页的内容，相邻段落重叠一两句话，既能保证语义连贯，又不会让信息太碎片化。\n\n---\n\n## 四、第三个问题：怎么让 AI “读懂”这些片段？\n\n文字切好了，但 AI 不理解文字，它只理解数字。这需要一个“翻译”过程——把每段文字转换成一个长长的数字列表，也就是**向量（Embedding）**。\n\n### 4.1 什么是向量？通俗版解释\n想象你在地图上描述一个人的位置，需要“经度、纬度”两个坐标。而描述一段话的意思，则需要更多的坐标——比如 **3072 个维度**。把这 3072 个数字排成一串，就是这段话的“语义坐标”。\n\n* **意思相近的话**：它们的 3072 个坐标值就越接近。\n* **意思相远的话**：它们的坐标值相差就越大。\n\n### 4.2 一次处理多个，少跑几趟\nGemini 提供了“批量翻译”功能——一次接口调用可以把几十段文字同时转成向量，省去反复调用网络接口的时间。\n\n```go\nfunc GetEmbeddings(texts []string) ([][]float32, error) {\n    \u002F\u002F 设置 10 秒超时\n    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n    defer cancel()\n\n    client, err := NewGeminiClient(ctx, apiKey)\n    if err != nil {\n        return nil, err\n    }\n    model := client.EmbeddingModel(\"gemini-embedding-001\")\n\n    batch := model.NewBatch()\n    for _, text := range texts {\n        batch.AddContent(genai.Text(text))\n    }\n\n    \u002F\u002F 一次调用嵌入所有文本\n    res, err := model.BatchEmbedContents(ctx, batch)\n    if err != nil {\n        return nil, err\n    }\n    \n    var embeddings [][]float32\n    for _, item := range res.Embeddings {\n        embeddings = append(embeddings, item.Values)\n    }\n    return embeddings, nil\n}\n```\n\n### 4.3 别忘了设个“闹钟”\n调用第三方 AI 接口不可能百分之百成功。设置一个 10 秒的超时时间——如果 10 秒内没有回应，果断放弃并重试，或者告诉用户“网络开了个小差，请重试”。这总比让用户看着加载动画无限等待要强。\n\n---\n\n## 五、第四个问题：向量存哪里？怎么找？\n\n向量需要存起来，等用户提问时再去检索。\n\n### 5.1 用 PostgreSQL 就够了\n市面上有专门的“向量数据库”（如 Milvus、Pinecone 等），但对于大多数企业，选择 **PostgreSQL + pgvector 插件**就足够了。原因有三：\n1. **少维护一个服务**：本来项目就要用关系型数据库，不需要再额外搭一套向量数据库。\n2. **保证数据一致性（事务）**：能保证“写入 50 段向量”和“更新文档状态为已处理”要么全部成功，要么全部回滚。\n3. **备份简单**：一条命令就能连同业务数据和向量数据一起备份。\n\n```go\n\u002F\u002F 事务保证：切片入库和状态更新要么都成功，要么都回滚\ndb.Transaction(func(tx *gorm.DB) error {\n    \u002F\u002F 批量写入切片\n    if err := tx.CreateInBatches(&chunks, 50).Error; err != nil {\n        return err\n    }\n    \u002F\u002F 更新文档状态\n    if err := tx.Model(&doc).Update(\"status\", \"done\").Error; err != nil {\n        return err\n    }\n    return nil\n})\n```\n\n### 5.2 怎么找最相似的？\n用户提问时，先把问题的文本也转成向量，然后在数据库里找与它“距离最近”的 3-5 个片段：\n\n```go\n\u002F\u002F SearchRelevantChunks 查找与问题向量最相似的切片\nfunc SearchRelevantChunks(query string, topK int) ([]ChunkModel, error) {\n    \u002F\u002F 先把问题转成向量\n    queryVectors, err := GetEmbeddings([]string{query})\n    if err != nil {\n        return nil, err\n    }\n    queryVector := queryVectors[0]\n\n    var chunks []ChunkModel\n    \u002F\u002F 使用余弦距离（\u003C=>）进行排序，值越小越相似\n    db.Order(\"embedding \u003C=> ?\", queryVector).Limit(topK).Find(&chunks)\n    return chunks, nil\n}\n```\n\n> [!TIP]\n> 检索找 3-5 个片段是有讲究的——太少可能漏掉关键信息，太多则会把无关杂音带入，甚至撑爆 AI 的上下文窗口。\n\n---\n\n## 六、第五个问题：怎么让 AI 学会“引用”？\n\n找完相关片段之后，不是直接丢给 AI 就完事了。如果让 AI 自由发挥，它可能会开始“一本正经地胡说八道”——这就是大家常说的 **AI 幻觉**。\n\n### 6.1 给 AI 设定“规矩”\n我们需要把找到的知识库片段，拼装成一份带有来源和规则的指令，也就是 **Prompt（提示词）**：\n\n```go\n\u002F\u002F 拼接上下文：每段资料都带上标题和链接\nvar contextText string\nfor i, chunk := range chunks {\n    contextText += fmt.Sprintf(\n        \"[资料 %d - 标题: %s | 链接: %s]\\n内容: %s\\n\\n\",\n        i+1, chunk.Title, chunk.URL, chunk.Text,\n    )\n}\n\n\u002F\u002F 构造完整的 Prompt\nprompt := fmt.Sprintf(`\n【参考资料】:\n%s\n【用户提问】: %s\n`, contextText, question)\n```\n\n组装完成后，AI 实际收到的指令长这样：\n\n```text\n【参考资料】:\n[资料 1 - 标题: 员工手册 | 链接: https:\u002F\u002Finternal.corp\u002Fdocs\u002F1]\n内容: 员工每年享有 15 天带薪年假...\n\n[资料 2 - 标题: 考勤制度 | 链接: https:\u002F\u002Finternal.corp\u002Fdocs\u002F2]\n内容: 请假需提前三天申请...\n\n【用户提问】: 公司年假政策是什么？\n```\n\n同时，我们在系统提示词中附带三条铁律：\n1. **只能基于参考资料回答**，绝对不能自己编造。\n2. **关键结论必须标注来源**（例如：“根据[员工手册]...”）。\n3. **找不到答案就直接说不知道**，不要强行解释。\n\n配合降低模型的温度值（Temperature，降低创造力），让 AI 老老实实照本宣科，这样幻觉率就会大幅降低。\n\n---\n\n## 七、第六个问题：怎么让用户不等太久？\n\nAI 生成完整的回答通常需要 5-20 秒。如果让用户盯着一个空白的加载图标干等，体验会非常糟糕。\n\n### 7.1 “边想边说”的流式输出\nGemini 支持流式输出（Streaming）——就像一个人一边思考一边说话，想到一部分先吐出几句话。后端每收到一小块文字，通过通道（Channel）或回调函数立刻推送到前端：\n\n```go\n\u002F\u002F AskStream 核心 RAG 问答流式输出函数\nfunc AskStream(ctx context.Context, question string, onChunk func(string)) error {\n    \u002F\u002F 1. 把问题转成向量并搜索最相关的 5 个切片\n    queryVec := GetEmbedding(question)\n    chunks := SearchTopK(queryVec, 5)\n    \n    \u002F\u002F 2. 拼接 Prompt\n    prompt := BuildPrompt(chunks, question)\n    \n    \u002F\u002F 3. 调用 Gemini 流式接口，逐块读取\n    iter := model.GenerateContentStream(ctx, prompt)\n    for {\n        resp, err := iter.Next()\n        if err == iterator.Done {\n            break \u002F\u002F 生成完毕\n        }\n        if err != nil {\n            return err\n        }\n        \n        text := extractText(resp)\n        if text != \"\" {\n            onChunk(text) \u002F\u002F 推送一小块字给前端\n        }\n    }\n    onChunk(\"[DONE]\") \u002F\u002F 告诉前端：回答完了\n    return nil\n}\n```\n\n这样，前端就能实现流畅的**打字机效果**：\n* 0.5 秒：`正在思考...`\n* 1.0 秒：`根据`\n* 2.0 秒：`根据 员工手册，`\n* 3.0 秒：`根据 员工手册，员工每年享有 15 天带薪年假...`\n\n用户的第一反应不再是“怎么这么慢”，而是“它已经在认真回答了”，感官速度体验会提升数倍。\n\n### 7.2 后台开个“小号”单独跑\n处理 AI 提问是一个耗时的 CPU \u002F 网络任务。在 Go 中，我们通常会启动一个独立的 **Goroutine（协程）** 去调用 AI 接口，避免阻塞主服务的事件循环，保证系统的整体吞吐量。\n\n---\n\n## 八、总结：做 AI 应用和调 AI 模型是两回事\n\n把 AI 能力真正落地成稳定的产品，**工程落地能力往往比模型本身的能力更重要**。\n\n整个 RAG 流水线的稳定性与体验，是由一个个具体的工程细节堆叠起来的：\n\n| 遇到的工程难题 | 对应的解决方案 |\n| :--- | :--- |\n| **文件格式太多，解析成本高** | 借助云存储中转转换，统一导出为纯文本 |\n| **文档过长，超出 AI 阅读上限** | 采用重叠切片算法，保留边缘语义 |\n| **AI 无法直接读取字符语义** | 用 Embedding 转化为多维向量坐标 |\n| **海量向量存取与业务状态同步** | 用 PostgreSQL + pgvector，单事务保证一致性 |\n| **AI 容易产生幻觉、胡说八道** | 严格限定 Prompt 上下文范围，强制标注来源 |\n| **生成等待时间长，用户易流失** | 采用流式（Stream）输出，打字机式逐步呈现 |\n\n* **稳定性**，来自对网络超时、异常重试的精细处理。\n* **效率**，来自对批量处理和并发的合理运用。\n* **可信度**，来自对 AI 的精确指令约束和溯源要求。\n\n**最好的技术，是用最稳的工程，去承载最聪明的 AI。**\n","# Have you ever wondered how AI accurately finds the answer when you ask \"What is this year's annual leave policy?\" in your company's knowledge base?\n\nBehind this question lies a complete workflow. Today we will discuss it in plain English.\n\n---\n\n## I. Why choose Go instead of Python?\n\nLet's first answer a question that everyone asks: Python is clearly the most popular language in the AI field, so why use Go?\n\n### 1.1 An analogy makes it clear\nIf writing code is like cooking:\n* **Python** is like a fully equipped large kitchen, suitable for researching new dishes (training models, doing experiments).\n* **Go** is like the central kitchen of a fast-food chain, suitable for stable, high-volume, and fast delivery of dishes (handling user requests).\n\nA RAG (Retrieval-Augmented Generation) knowledge base system essentially belongs to the latter. Its workflow is: **Receive user query → Call AI API → Wait for result → Return to user**. During this process, 90% of the time is spent waiting for network responses, not doing math calculations. Go happens to excel at handling this \"waiting\" phase.\n\n### 1.2 Concurrency capability: Go has countless helpers\nImagine queuing up at a bubble tea shop:\n* **Python** is like having one shop assistant who can only serve one customer at a time (due to the Global Interpreter Lock, or GIL). If 100 customers arrive, the other 99 have to wait.\n* **Go** is like having thousands of shop assistants; every customer can be served immediately. Moreover, these \"assistants\" (Goroutines) consume very little memory, allowing a standard server to handle tens of thousands of requests concurrently.\n\n### 1.3 Deployment: One file does it all\nWhen deploying Python projects, you often encounter situations where it runs on your local machine but fails on the server due to environment incompatibilities or network timeouts during dependency installations. In contrast, Go compiles into a single binary file. You can simply upload it to the server and run it—offering a deployment experience as worry-free as \"moving in with just your bags.\"\n\n### 1.4 Go's weaknesses happen to be irrelevant here\nSome say Go's AI ecosystem is inferior to Python's—and they are right. But RAG systems do not require training models locally; they simply call AI APIs. What you need is connection management, database read\u002Fwrites, and task scheduling, which are precisely Go's strengths. **AI is responsible for \"thinking\" and Go is responsible for \"organizing\"**; they perform their respective duties.\n\n---\n\n## II. The first problem: What if there are all kinds of file formats?\n\nFiles in a corporate knowledge base are extremely diverse: Word, PDF, TXT, Markdown, HTML, and so on. The first step of vectorization is to convert these files into plain text.\n\n### 2.1 A lazy yet smart trick\nIf we write parser code for each format, the maintenance cost will be very high. Our approach is to find a \"translator.\" We upload the file to Google Drive, use its built-in conversion feature to convert it to Google Docs format, and then export it as plain text.\n\n```go\n\u002F\u002F Check file type: only proceed to AI processing if it can be converted to text\nswitch ext {\ncase \".pdf\", \".docx\", \".doc\", \".txt\", \".html\", \".md\":\n    \u002F\u002F Automatically convert to Google Docs format during upload\n    \u002F\u002F Cloud storage will handle the format conversion for us\n    canConvert = true\n}\n\n\u002F\u002F Extract text: regardless of whether the original file is PDF or Word, export it unified as plain text\nfunc ExtractText(fileID string) (string, error) {\n    resp, err := service.Files.Export(fileID, \"text\u002Fplain\").Download()\n    if err != nil {\n        return \"\", err\n    }\n    defer resp.Body.Close()\n    \n    content, err := io.ReadAll(resp.Body)\n    if err != nil {\n        return \"\", err\n    }\n    return string(content), nil\n}\n```\n\nThe benefit is straightforward: **our code only needs to handle \"plain text\"**, leaving all format compatibility issues to cloud services like Google.\n\n### 2.2 Deduplication along the way\nWe calculate a \"fingerprint\"—known as the **SHA256 hash**—for the file during upload. If two people upload the same file, the fingerprint will be identical, allowing us to skip processing directly, saving time and effort.\n\n---\n\n## III. The second problem: How to chunk long documents?\n\nAI has a \"reading limit\" (context window limit). You cannot throw the entire *Three-Body Problem* novel to it at once; you must cut it into small chunks.\n\n### 3.1 Why can't we just make a clean cut?\nImagine this sentence:\n> \"According to company regulations, employees are entitled to 15 days of paid annual leave each year, which can be used in installments.\"\n\nIf we happen to cut it right between \"employees\" and \"are entitled\":\n* **First chunk**: \"According to company regulations, employees\"\n* **Second chunk**: \"are entitled to 15 days of paid annual leave each year, which can be used in installments.\"\n\nLooking at either chunk alone makes the semantics very weird. The solution is **overlapping chunking**—letting the chunks overlap with each other:\n* **First chunk**: `[According to company regulations, employees are entitled to 15 days]`\n* **Second chunk**: `[are entitled to 15 days of paid annual leave each year, which can be used in installments]`\n\nSee? \"are entitled to 15 days\" appears in both chunks. This way, no matter which chunk is retrieved, key semantic information is not lost. The code implementation is also straightforward:\n\n```go\n\u002F\u002F ChunkText overlapping chunking algorithm\n\u002F\u002F chunkSize = word count per chunk, overlap = overlapping word count\nfunc ChunkText(text string, chunkSize int, overlap int) []string {\n    runes := []rune(text) \u002F\u002F Process Chinese correctly by slicing by characters\n    var chunks []string\n    \n    for i := 0; i \u003C len(runes); {\n        end := i + chunkSize\n        if end > len(runes) {\n            end = len(runes)\n        }\n        chunks = append(chunks, string(runes[i:end]))\n        if end == len(runes) {\n            break\n        }\n        i = end - overlap \u002F\u002F Go back overlap characters to implement overlap\n    }\n    return chunks\n}\n```\n\n> [!IMPORTANT]\n> Make sure to use `[]rune` instead of `[]byte`—Chinese characters occupy 3 bytes in UTF-8 encoding. Slicing by bytes will result in garbled characters.\n\n### 3.2 How to determine parameters?\nUsually, we recommend: **500–800 words per chunk, with 50–100 words overlap**. This is equivalent to about half to two-thirds of an A4 page, with adjacent chunks overlapping by one or two sentences, ensuring semantic coherence without fragmenting the information too much.\n\n---\n\n## 四、 The third problem: How to make AI \"read and understand\" these chunks?\n\nAlthough the text is sliced, AI doesn't understand words; it only understands numbers. This requires a \"translation\" process—converting each text chunk into a long list of numbers, which is a **vector (Embedding)**.\n\n### 4.1 What is a vector? A layman's explanation\nImagine describing a person's location on a map requires two coordinates: \"longitude and latitude.\" Describing the meaning of a sentence requires more coordinates—for example, **3,072 dimensions**. Lining up these 3,072 numbers creates the \"semantic coordinates\" of the sentence.\n\n* **Sentences with close meanings** will have closer 3,072-dimensional coordinates.\n* **Sentences with distant meanings** will have larger coordinate differences.\n\n### 4.2 Batch processing to save trips\nGemini provides a \"batch embedding\" feature—allowing one API call to convert dozens of text chunks into vectors simultaneously, saving time spent on repeated network requests.\n\n```go\nfunc GetEmbeddings(texts []string) ([][]float32, error) {\n    \u002F\u002F Set 10-second timeout\n    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n    defer cancel()\n\n    client, err := NewGeminiClient(ctx, apiKey)\n    if err != nil {\n        return nil, err\n    }\n    model := client.EmbeddingModel(\"gemini-embedding-001\")\n\n    batch := model.NewBatch()\n    for _, text := range texts {\n        batch.AddContent(genai.Text(text))\n    }\n\n    \u002F\u002F Embed all texts in one call\n    res, err := model.BatchEmbedContents(ctx, batch)\n    if err != nil {\n        return nil, err\n    }\n    \n    var embeddings [][]float32\n    for _, item := range res.Embeddings {\n        embeddings = append(embeddings, item.Values)\n    }\n    return embeddings, nil\n}\n```\n\n### 4.3 Don't forget to set an \"alarm clock\"\nCalling third-party AI APIs is not 100% guaranteed to succeed. Set a 10-second timeout—if there is no response within 10 seconds, abort and retry, or tell the user \"Network error, please try again.\" This is far better than letting the user wait indefinitely staring at a loading animation.\n\n---\n\n## V. The fourth problem: Where to store vectors? How to find them?\n\nVectors need to be stored so they can be retrieved when the user asks a question.\n\n### 5.1 PostgreSQL is enough\nThere are specialized \"vector databases\" on the market (such as Milvus, Pinecone, etc.), but for most enterprises, using **PostgreSQL with the pgvector extension** is sufficient. The reasons are threefold:\n1. **One less service to maintain**: The project already needs a relational database, so there is no need to set up a separate vector database.\n2. **Ensure data consistency (Transactions)**: It guarantees that \"writing 50 vector chunks\" and \"updating document status to processed\" either both succeed or both roll back.\n3. **Simple backup**: All business data and vector data can be backed up together with a single command.\n\n```go\n\u002F\u002F Transaction assurance: chunk insertion and status update either both succeed or roll back\ndb.Transaction(func(tx *gorm.DB) error {\n    \u002F\u002F Batch write chunks\n    if err := tx.CreateInBatches(&chunks, 50).Error; err != nil {\n        return err\n    }\n    \u002F\u002F Update document status\n    if err := tx.Model(&doc).Update(\"status\", \"done\").Error; err != nil {\n        return err\n    }\n    return nil\n})\n```\n\n### 5.2 How to find the most similar chunks?\nWhen a user asks a question, we first convert the question's text into a vector, and then search the database for the 3–5 chunks \"closest\" to it:\n\n```go\n\u002F\u002F SearchRelevantChunks finds the chunks most similar to the query vector\nfunc SearchRelevantChunks(query string, topK int) ([]ChunkModel, error) {\n    \u002F\u002F Convert query to vector first\n    queryVectors, err := GetEmbeddings([]string{query})\n    if err != nil {\n        return nil, err\n    }\n    queryVector := queryVectors[0]\n\n    var chunks []ChunkModel\n    \u002F\u002F Order by Cosine Distance (\u003C=>), smaller values mean more similar\n    db.Order(\"embedding \u003C=> ?\", queryVector).Limit(topK).Find(&chunks)\n    return chunks, nil\n}\n```\n\n> [!TIP]\n> Retrieving 3–5 chunks is a deliberate choice—too few might miss critical information, while too many will introduce irrelevant noise and may even exceed the AI's context window.\n\n---\n\n## VI. The fifth problem: How to teach AI to \"cite\"?\n\nAfter finding the relevant chunks, you cannot just dump them to the AI. If you let the AI express freely, it might start \"hallucinating with a straight face\"—this is what people commonly refer to as **AI Hallucination**.\n\n### 6.1 Set \"rules\" for the AI\nWe need to assemble the retrieved knowledge base chunks into a prompt with sources and rules:\n\n```go\n\u002F\u002F Concatenate context: attach title and URL to each chunk\nvar contextText string\nfor i, chunk := range chunks {\n    contextText += fmt.Sprintf(\n        \"[Source %d - Title: %s | Link: %s]\\nContent: %s\\n\\n\",\n        i+1, chunk.Title, chunk.URL, chunk.Text,\n    )\n}\n\n\u002F\u002F Build complete Prompt\nprompt := fmt.Sprintf(`\n【Reference Materials】:\n%s\n【User Query】: %s\n`, contextText, question)\n```\n\nAfter assembly, the prompt received by the AI looks like this:\n\n```text\n【Reference Materials】:\n[Source 1 - Title: Employee Handbook | Link: https:\u002F\u002Finternal.corp\u002Fdocs\u002F1]\nContent: Employees are entitled to 15 days of paid annual leave each year...\n\n[Source 2 - Title: Attendance Policy | Link: https:\u002F\u002Finternal.corp\u002Fdocs\u002F2]\nContent: Leave requests must be submitted three days in advance...\n\n【User Query】: What is the company's annual leave policy?\n```\n\nAt the same time, we attach three strict rules in the system prompt:\n1. **Answer only based on the reference materials**, absolutely do not make things up.\n2. **Crucial conclusions must cite sources** (e.g., \"According to the [Employee Handbook]...\").\n3. **If you cannot find the answer, state that you don't know**; do not explain forcefully.\n\nBy pairing this with a lower model temperature setting (reducing the AI's creativity), the AI will strictly stick to the materials, drastically reducing the hallucination rate.\n\n---\n\n## VII. The sixth problem: How to prevent users from waiting too long?\n\nAI generation typically takes 5–20 seconds for a complete answer. Letting the user stare at a blank loading icon for so long provides a terrible experience.\n\n### 7.1 \"Talk while thinking\" streaming output\nGemini supports streaming output—like a person speaking while thinking, outputting words as they are generated. Whenever the backend receives a small piece of text, it immediately pushes it to the frontend via a channel or a callback function:\n\n```go\n\u002F\u002F AskStream core RAG streaming query function\nfunc AskStream(ctx context.Context, question string, onChunk func(string)) error {\n    \u002F\u002F 1. Convert query to vector and search for the top 5 most relevant chunks\n    queryVec := GetEmbedding(question)\n    chunks := SearchTopK(queryVec, 5)\n    \n    \u002F\u002F 2. Build Prompt\n    prompt := BuildPrompt(chunks, question)\n    \n    \u002F\u002F 3. Call Gemini streaming API, read chunk by chunk\n    iter := model.GenerateContentStream(ctx, prompt)\n    for {\n        resp, err := iter.Next()\n        if err == iterator.Done {\n            break \u002F\u002F Generation completed\n        }\n        if err != nil {\n            return err\n        }\n        \n        text := extractText(resp)\n        if text != \"\" {\n            onChunk(text) \u002F\u002F Push a small chunk of text to the frontend\n        }\n    }\n    onChunk(\"[DONE]\") \u002F\u002F Tell the frontend: finished\n    return nil\n}\n```\n\nThis way, the frontend can render a smooth **typewriter effect**:\n* 0.5s: `Thinking...`\n* 1.0s: `According`\n* 2.0s: `According to the Employee Handbook,`\n* 3.0s: `According to the Employee Handbook, employees are entitled to 15 days of paid annual leave each year...`\n\nThe user's first reaction is no longer \"Why is it so slow?\", but \"It's already writing the answer,\" significantly improving perceived speed.\n\n### 7.2 Run it in the background with a separate Goroutine\nProcessing AI requests is a time-consuming CPU and network task. In Go, we usually spin up a separate **Goroutine** to call the AI API, avoiding blocking the main service's event loop and ensuring the overall throughput of the system.\n\n---\n\n## VIII. Summary: Building AI applications is different from tuning AI models\n\nTo successfully turn AI capabilities into a stable product, **engineering implementation capabilities are often more critical than the model itself**.\n\nThe stability and experience of the entire RAG pipeline are built upon concrete engineering details:\n\n| Engineering Challenges | Corresponding Solutions |\n| :--- | :--- |\n| **Too many file formats, high parsing cost** | Leverage cloud storage conversion, unified export to plain text |\n| **Documents are too long for AI's reading limit** | Apply overlapping chunking to preserve edge semantics |\n| **AI cannot read text semantics directly** | Use Embedding to convert text into vector coordinates |\n| **Massive vector storage and business state sync** | Use PostgreSQL + pgvector, single transaction to ensure consistency |\n| **AI is prone to hallucination and making things up** | Restrict prompt context strictly and mandate source citations |\n| **Long generation wait times cause user churn** | Use streaming output to display answers in a typewriter fashion |\n\n* **Stability** comes from fine-grained handling of network timeouts and exception retries.\n* **Efficiency** comes from the reasonable utilization of batch processing and concurrency.\n* **Credibility** comes from strict instruction constraints on the AI and traceability requirements.\n\n**The best technology is using the most stable engineering to carry the smartest AI.**\n","AI",28,"https:\u002F\u002Fblog4-1316398321.cos.ap-nanjing.myqcloud.com\u002Fblog5\u002F20260621004623__佐佐木-动漫.png",[15,16],"GO","Agent",true,{"id":19,"title":20,"title_en":21},38," 拒绝缓存击穿！用 Go 的 SingleFlight 护航你的 Redis 缓存"," Cache breakdown denied! Protect your Redis cache with Go's SingleFlight",{"id":23,"title":24,"title_en":25},40,"避坑指南：IM 系统中如何优雅处理“多端登录”与连接竞争？","Guide to pitch-avoidance: How to gracefully handle \"multi-terminal login\" and connection competition in the IM system?","2026-06-20T23:25:15.156115+08:00"]