← 返回
AI技术

用 Go 和 Gemini File Search 实现低成本 RAG:无需向量数据库,两次调用,一个托管存储

✍️ zhirenhun 📅 2026/9/22 👁 258 阅读 ⏱ 34 分钟
用 Go 和 Gemini File Search 实现低成本 RAG:无需向量数据库,两次调用,一个托管存储

你好,我是 Maneshwar,我正在构建 LiveReview —— 一个面向业务关键系统的 blast-radius 感知的 AI 代码审查工具。 给我们点星 以帮助开发者发现项目,试用一下,并分享你的反馈,以帮助改进产品。

我所在的每个团队都有同样的书架。

有九本工程师们推崇的书籍,几篇在每第三次设计评审中会被链接的长篇文章,以及大约六十篇内部博客文章和事后复盘,没有人会重新阅读。

大约有 140 万个 token 的「我们已经学过这一次」。

我希望有一个工具,你可以粘贴设计提案、ADR、事后复盘草稿,它会返回:这里是实际提出的内容,这里是其底层模式,以及这里是书架上我们或更聪明的人之前遇到这种确切形状的三个位置。

有依据。带引用。不是「LLM 读了你的文档然后对它有感觉」。

显而易见的做法是构建一个向量数据库、一个嵌入流水线、一个分块器、一个重排器,再加上一个周末。

我没有使用那些组件,而是用 Go 在 Gemini 的免费层级上构建了它,其检索部分运行成本为零。

这篇文章讲述了如何做到这一点,以及我在过程中遇到的四个问题。

不依赖 R 的 RAG

Gemini 有一个称为 文件搜索 的功能。

你创建一个「store」,将文档上传进去,Google 会对它们进行分块、嵌入和索引。

然后,你将该 store 作为工具附加到普通的 generateContent 调用上,模型会在回答过程中自行搜索它,并将其使用的块作为 groundingMetadata 返回给你。

没有 pgvector。没有 Pinecone 账单。没有需要挑选然后后悔的嵌入模型。

定价是让我坐直的部分。

存储免费。查询时的嵌入免费。你只需在索引时付费一次,费用参考 嵌入价格,随后模型拉取的块将在你本来就要进行的调用中作为普通上下文 token 计费。

在免费层级上,store 的上限是 1 GB。我整个书架——每本书和每篇帖子转换为 Markdown 后——只有 5.3 MB。

图示:整个流程。首先,71 份 Markdown 文件经过 SHA256 差异比较进入摄取步骤,上传至 Gemini File Search 商店。每个问题时,设计提案会送到调用 1,在不使用工具的情况下进行分析并输出概括以及检索查询;接着调用 2 在附带 File Search 工具的情况下起草并返回依据片段;随后的确定性检查最多重试两次,生成包含来源和引用法律的审查 JSON。

因此,架构异常简短。

一个 Go 二进制文件。通过 modernc.org/sqlite 使用 SQLite,因此无需 cgo。我不使用 SDK 为 Gemini 构建了一个 REST 客户端,因为我想原样记录完整的请求和响应体,而 SDK 倾向于隐藏这些细节。

每个问题需要两次模型调用。只有一个托管存储。这就是全部内容。

步骤零:在不丢失空格的情况下将书籍转换为 Markdown

在使用任何巧妙部分之前,语料库必须以文本形式存在,这让我花了一个晚上。

我首先尝试的工具是 markitdown。它给我提供了标题(某种程度上),并且还给我这个:

Theubiquityoffrustrating,unhelpfulsoftwareinterfaceshasmotivateddecadesofresearch

PDF 里的单词全都粘成了一团。嵌入模型不认识 "Theubiquityoffrustrating" 这种东西,检索环节同样不认识。

pdftotext 修好了间距问题,却把所有标题都丢掉了,于是一本 300 页的书变成了一整条毫无层次的长文。

最终胜出的是 pymupdf4llm。它会根据字号大小判断哪些内容是标题,保留粗体和斜体,而且在同一个 PDF 上,它的文本提取正确处理了间距。

uv run --with pymupdf4llm python -c "
import pymupdf4llm, pathlib
pathlib.Path('out.md').write_text(pymupdf4llm.to_markdown('in.pdf'))
"

一篇文章里竟有四十四个真实标题,而零个。

标题在这里比看起来更重要,因为 File Search 会按照空白字符在 token 预算内进行分块。以标题开头的分块本身就有意义;而在一大段文本中间开始的分块只是带有嵌入向量的噪声。

我把转换过程放在 make process-data 后面,这样团队就不必重新发现这一点。EPUB、PDF 以及我们博客仓库的同步,全部汇总到一个 post_processed_data/ 的 Markdown 树中。

商店属于 key,而不属于你

这是最初让我感到困扰的事情,而且它不在显眼的地方。

Matrix Morpheus 表情包:如果我告诉你 File Search 商店属于 API key,而不属于你的应用,你会怎么想?

File Search 商店位于创建它的 API key 所属的 Google Cloud 项目内部。

用 key A 创建一个商店,接着用不同项目的 key B 去搜索它,不会返回有帮助的错误,它根本就不存在。

当你拥有不止一个 key 时,这一点就变得重要了——在免费层级你肯定会有多个 key,因为每分钟的配额是真实存在的,大家普遍采用的办法是“轮换几个 key”。

你不能在不同的 key 之间轮换带有工具的调用。商店会把你锁定。

于是 key 被赋予了角色。

图示:按角色划分的 key。keys/store.txt 中 key 0 拥有 store 0(主),key 1 拥有 store 1(备用);调用 2 会先用 key 0 尝试 store 0,失败后转到 key 1 的 store 1,永不会用 key 1 访问 store 0。keys/rotate.txt 中的 key 2 到 key 5 在出现 429、401、403 和 5xx 时会为调用 1 以及无工具附加的语料库索引摘要进行轮换。

keys/store.txt 是一个简短的列表。其中的每个 key 都拥有自己商店中完整的语料库副本。所有摄取上传会分发到每个商店。搜索调用会先用 key 0 尝试 store 0,仅在出现配额或认证失败时才会回退到用 key 1 访问 store 1。

keys/rotate.txt 是一个很长的列表,用于任何未附加工具的调用:分析调用、语料库索引摘要。这些调用实际上并不关心哪个密钥响应,因此它们在遇到 429、401、403 或任何 5xx 错误时会轮换密钥。

在我弄清楚之前,我曾经犯过两个错误:

503 不是密钥问题。 当 Google 繁忙时,Gemini 会返回 503 "高需求",切换密钥除了消耗另一个密钥的配额外毫无用处。因此客户端会等待并重试同一个密钥几次,直到放弃。

密钥应放在 x-goog-api-key 请求头中,永不放在 URL 中。 将其放在查询字符串中,第一次传输错误会把你的密钥打印到你自己的日志里。想知道我是怎么知道的吗?

是的,拥有两个存储意味着一次性索引成本会发生两次。这是搜索调用在单个配额上不会中断的代价,而在 5 MB 的嵌入价格下,这只不过是喝杯咖啡的钱。

摄入是增量,而非完整上传

摄入步骤是把“仅上传文件夹”变成实际程序的地方。

每个文件都会得到一个 SHA-256。SQLite 中的清单为每个存储记录 path -> sha -> document id。然后:

flowchart TD
    A[walk data/**/*.md, sha256 each] --> B{path in manifest?}
    B -- no --> U[upload to store]
    B -- yes --> C{sha changed?}
    C -- no --> S[skip, unchanged]
    C -- yes --> D[delete old doc id] --> U
    U --> P[poll the indexing operation until done]
    P --> M[upsert manifest: path, sha, doc id]
    A --> R{manifest path missing on disk?}
    R -- yes --> X[delete doc from store, drop manifest row]
    R -- no --> S
    M --> I{set of path+sha changed?}
    S --> I
    X --> I
    I -- no --> K[corpus index unchanged]
    I -- yes --> G[summarize new files with flash-lite, one line each] --> J[store corpus_index]

    classDef decision fill:#f4d35e,stroke:#b8991f,color:#1a1a1a
    classDef start    fill:#e9ecef,stroke:#6c757d,color:#1a1a1a
    classDef net      fill:#5ee6c8,stroke:#1f9c86,color:#1a1a1a
    classDef local    fill:#6ea8ff,stroke:#2f5fc4,color:#1a1a1a
    classDef idx      fill:#9d8cff,stroke:#5b4bcc,color:#1a1a1a

    class B,C,R,I decision
    class A start
    class U,D,P,X net
    class S,M,K local
    class G,J idx

未更改的文件会被跳过。已更改的文件会先删除旧文档,然后重新上传。从磁盘消失的文件会从存储中被删除。

每次上传都是一个返回长时间运行操作的 multipart POST,在你信任文档 ID 之前,你需要轮询直到 done。四个上传并行运行。串行情况下,一个包含 71 个文件的语料库大约需要十分钟。并行情况下,只需几分钟。

分块大小是按上传设置的,我选择了每块 400 个 token,重叠 60 个。文档中的例子是 200 和 20,对一本书来说感觉就像通过信箱读信。

meta := map[string]any{
    "displayName": displayName,
    "chunkingConfig": map[string]any{
        "whiteSpaceConfig": map[string]int{
            "maxTokensPerChunk": 400,
            "maxOverlapTokens":  60,
        },
    },
}
// multipart/related: part 1 is this JSON, part 2 is the markdown bytes
// POST /upload/v1beta/{store}:uploadToFileSearchStore
// with X-Goog-Upload-Protocol: multipart

流程图中的最后一个框是语料库索引,虽然很小,但它是使下一节得以工作的关键。

对于每个文件,使用最便宜的可用模型(gemini-3.5-flash-lite)生成一句话摘要,以文件的 SHA 作为键,确保只生成一次。所有行会被合并成一个块,格式为 “path, summary”,然后粘贴到分析提示中。

这样,决定搜索内容的模型就知道书架上实际上有什么。它会指向实际存在的来源,而不是猜测。

按模式搜索,而非按帖子

如果我不得不把其他一切都丢掉,这篇文章中的这个想法是我会保留的。

嵌入式搜索能找到听起来相似的内容。

将写有“我们应该在所有代理中统一使用一种 MCP 传输方式”的提案直接粘贴到检索中,你会得到所有包含 “MCP”、“agents” 和 “standardize” 这三个词的文本块。这些正是你最近关于 MCP 和代理的博客文章。

这不是先例。这是一面镜子。

图示:以两种方式搜索相同的提案。用原始文本作为查询时,会得到关于 MCP 和 代理的博客文章以及没有先例的主题相关文本块;用概括作为查询时,会得到浏览器战争、Rickover 关于标准化的论述以及韩非关于统一法律的论述——这些都是同一模式的不同表现。

你真正想要的是去掉名词后情形的轮廓。

“碎片化、不兼容的实现,然后是一个开放标准,随后是大规模采用。”

用这个在书架上搜索,浏览器战争会重现;Rickover 关于核海军标准化的论述会重现;一部 2300 年前的法家关于统一法律的论文也会重现。

同样的模式,不同的外衣。这是有三十年阅读经验的审阅者所带来的,也是如果你让模型用原始文本进行搜索时它所无法做到的。

因此,管道永远不会允许这种情况发生。

调用 1 获取法典、语料库索引和提案,且不附带任何工具。它返回 JSON:动机、实际发生的事情、参与者、概括,以及由此概括推导出的两到三个检索查询。

调用 2 拿到的是调用 1 的分析结果、指向该存储库的 File Search 工具,以及“先用这些查询词搜索、再着手起草”的指令。

原始文档就在调用 2 的上下文里,草稿因此可以直接引用它。但搜索词出自调用 1,而这些词针对的是那个模式,并非具体对象。

{
  "systemInstruction": { "parts": [{ "text": "...the lawbook..." }] },
  "contents": [{ "role": "user", "parts": [{ "text": "PROPOSAL:\n...\n\nANALYSIS FROM CALL 1:\n{...}\n\nSearch the corpus with the retrieval queries above, then reply with the JSON contract." }] }],
  "tools": [{ "fileSearch": { "fileSearchStoreNames": ["fileSearchStores/abc123"] } }],
  "generationConfig": { "responseMimeType": "application/json", "temperature": 0.7 }
}

响应携带 candidates[0].groundingMetadata.groundingChunks,每个包含文件的显示名称和检索到的文本。这些会完整地显示给用户,位于草稿旁边。如果审查所依赖的"authority"不在此列表中,则表明它不是来自书架,检查会捕捉到这一点。

每条指令都是法律,每条回复都引用其法律

此东西中没有任何地方有自由文本系统提示。

提示是一个 AgentLaws 法律手册:一个 markdown 文件夹,其中模型看到的每条指令都是一个编号的法律,按章节分组,在 git 中进行版本控制,并在启动时编译一次。

模型必须返回 applied_laws,即它依赖的编号,而管道会将每个编号解析回法律手册中的 file:line。

星球大战检查站官员模因:applied_laws 2.4.1 解析为 analysis/generalization.md:14,这是一条较旧的法律,先生,但它仍然能够解析

无法解析的数字会导致运行失败。这听起来很挑剔,直到模型第一次在只有六章的法律手册中自信地引用法律 7.2.3 为止。

实际的好处在于,“为什么它会这样做”有一个文件和行号的答案,“让它停止这样做”相当于对 markdown 文件发起拉取请求,而不是在 Go 字符串中进行考古。

输出契约是提示中的最后一部分,因为那是模型最看重的地方。关于语气和结构的法律放在前面,JSON 架构放在最后。

相信,但运行检查

模型还会在其自身回复中填充一个 checks 块,作为自我审计。我不信任它,但它作为第二信号很有用。

在读取之前,一小堆用纯 Go 编写的确定性检查会在每个草稿上运行:长度限制,禁止 markdown,最多一个问题,禁止“参见点 N”引用,每个引用的法律都得到解析。

flowchart TD
    A[call 2: search + draft, JSON mode] --> B{reply parses as JSON?}
    B -- no --> F[resend once without JSON mode, extract first object] --> C
    B -- yes --> C[resolve every cited law number to file:line]
    C --> D{all citations resolve?}
    D -- no --> R
    D -- yes --> E[deterministic checks: length, no markdown, one question max, distinct openers]
    E --> G{checks pass?}
    G -- no --> R{attempts left?}
    R -- yes --> H[append the failures to the user message] --> A
    R -- no --> S[ship the draft with failures listed]
    G -- yes --> V[call 3: reviewer pass, verdict + objections]
    V --> W{verdict?}
    W -- ship --> O[return JSON with sources and applied laws]
    W -- revise, rounds left --> H
    W -- revise, no rounds --> S

    classDef decision fill:#f4d35e,stroke:#b8991f,color:#1a1a1a
    classDef llm      fill:#5ee6c8,stroke:#1f9c86,color:#1a1a1a
    classDef local    fill:#6ea8ff,stroke:#2f5fc4,color:#1a1a1a
    classDef bad      fill:#ff9a5c,stroke:#c4602a,color:#1a1a1a
    classDef good     fill:#9d8cff,stroke:#5b4bcc,color:#1a1a1a

    class B,D,G,R,W decision
    class A,F,V llm
    class C,E,H local
    class S bad
    class O good

失败会以纯列表的形式追加到用户消息中,“之前的尝试未通过这些检查,请修复”,然后再次执行调用 2。进行两次重试后,草稿仍会被发送,并在其中列出失败项,因为带有警告的草稿比放弃的加载器更有用。

其中有一个框值得单独一句话:JSON 模式加上工具并不能保证。responseMimeType: application/json 与 File Search 配合使用大多数时候有效,但偶尔会返回包含 JSON 对象的散文。解决办法很简单:如果回复无法解析,则在不使用 JSON 模式的情况下重新发送一次,并从文本中提取第一个 {...}。两次尝试都会被记录。

导致我拆分数据库的错误

该工具被五个人使用,每个人都在自己的 SQLite 文件上运行它,因此历史记录是按人隔离的。

第一个版本把所有内容放在同一个文件中:会话、步骤和摄入清单。

Bad Luck Brian 表情包:为队友创建一个全新的 SQLite 数据库,重新上传所有 71 个文件并在存储中复制每份文档

发现了吗?

新队友创建了一个新数据库。新数据库,清单为空。清单为空意味着磁盘上的每个文件都被视为‘新’文件,因此所有 71 个文件会被再次上传。而且由于清单为空,摄入过程不知道旧文档的 ID,因而没有任何内容会被删除。

现在存储中每条记录都有两份。重复五次后,搜索质量会悄然下降,因为每次查询都会返回同一块内容五次,从而挤掉次佳结果。

图示:三个按人员划分的会话数据库,alice.db、bob.db、you.db,各自保存会话和步骤,全部读取一个共享的 corpus.db,其中保存每个键对应的库名称、路径到 SHA256 到文档 ID 的清单以及语料库索引。corpus.db 镜像了 Google 文件搜索中的存储,包含 71 份文档,按 400 个 token 分块,重叠 60。此次修复的 bug:manifest 原本保存在会话数据库中,导致新队员获得了空的 manifest,重新上传了全部 71 份文件,未删除任何内容,因而存储中充满了重复项。

修复只需一句话:manifest 是存储的属性,而不属于提出问题的人。

现在有两个数据库。corpus.db 保存库名称、manifest 和语料库索引,所有会话数据库都从它读取。alice.db 仅保存 Alice 的会话。

删除 corpus.db 将触发完整的重新摄入。删除 alice.db 对语料库没有任何影响。

免费层级实际上提供什么

由于标题中带有 “cheap”,以下是我在构建时的诚实数字。

模型。 仅使用 Flash。gemini-3.5-flash 用于实际调用,gemini-3.5-flash-lite 用于临时摘要。Pro 在免费密钥上会返回 429,伴随 limit: 0。付费密钥通过设置一个环境变量即可解锁,我尚未需要这样做。

速率限制。 你遇到的限制是每分钟的上限,而不是每日。当所有密钥都被限速时,摄入会以逐渐增加的等待时间进行回退,而 UI 会在每个密钥出现降级时直接显示,而不是隐藏在加载指示器后面。

存储。 每个存储空间 1 GB。我的 5.3 MB 语料库未被计入。

每个问题的成本。 两次 Flash 调用,有时伴随审核通道会有三次,大约输入 10k token,输出 2k token。已检索的块已包含在该计数中。在免费层级,成本为零。在付费密钥下,远低于一美分。

延迟。 端到端延迟在 30 到 90 秒之间,大部分时间用于第二次调用进行搜索和草稿。这对于“粘贴设计文档,获取评审”是可以接受的,但若用于聊天框则不合适。

陷阱。 在免费层级,Google 可能会对您上传的内容进行训练。我的书架是已出版的书和公开的博客文章,所以我不介意。如果您的语料库是您的客户合同,请在执行 make ingest 之前阅读 条款。

我会告诉你该窃取什么

如果您有一个书架,并且有一个形状像 "我们以前见过这个吗" 的问题,您不需要向量数据库来回答它。

您需要具有真实标题的 Markdown、一个用于差异比较而不是重新上传的托管存储、带有角色的密钥(因为存储将您限定在一个角色上),以及第一次模型调用——其唯一任务是将问题转换为其下方的模式。

其余部分是检查和日志,而检查正是您会感谢自己的部分。



您团队的注意力是有限的,而 AI 生成代码的洪流使得在不减慢您的情况下,保持生产环境的安全和可靠变得更加困难。

我正在构建 LiveReview,这是一个具有爆炸半径感知能力的 AI 代码审查工具,专为您的业务关键系统而设计。

而不是对每个差异给予同等强调,LiveReview 根据爆炸半径对每个更改进行评分——其影响通过调用图传播的范围——这样您就可以将注意力集中在真正重要的地方。

将代码审查工作集中在业务风险最高的地方——而不是将其平均分配到每个差异上。

⭐ 在 GitHub 上给它点个 Star:

LiveReview

gitleaks.yml osv-scanner.yml govulncheck.yml semgrep.yml 已启用 Dependabot mcp-testcases.yml

LiveReview:面向关键业务系统的爆炸半径感知 AI 代码评审

LiveReview 是一款 AI 代码评审工具,它会按爆炸半径(blast radius)为 diff 中的每个改动块(hunk)打分:改动沿调用图能波及多远、触及多少持久化状态、测试覆盖得有多充分。共享鉴权检查改 3 行,优先级可能排在 300 行界面微调之前。团队的注意力会首先集中到风险最高的代码上,而不是平均分摊到每一条 diff 上。

blast-radius-demo.mp4

LiveReview 的爆炸半径与评审优先级评分,直接呈现在 diff 查看器中。
















精确的数学计算,而非黑盒 爆炸半径,一目了然 构成评分的每一个因素

Blast Radius(爆炸半径)评分是如何运作的?(更技术性的解释)

目标是这样的:

  • 一个函数若被其他 40 个文件用到,还负责写数据库,那么哪怕只是 3 行的修复,评分也应该很高。
  • 单个文件里 300 行的 UI 改动,只要被……完全覆盖




点击下方链接,用你的代码库试用 LiveReview:

LiveReview 横幅

——

广告 广告
🧑‍💻

zhirenhun

一个热爱技术的程序员,喜欢分享前沿AI知识和开发经验。

go ai rag gemini