在不使用独立向量数据库的情况下构建Slack RAG助手
无需独立向量数据库的Slack RAG助手:文档存储在同一个StarRocks表中,检索通过普通SQL实现,另有六节课讲解提示词与分块处理。
来源: Lessons We Learned Building a RAG Assistant Without a Separate Vector Database
StarRocks是一款快速的开源分析型数据库,拥有统一的执行引擎、灵活的部署方式,且在处理实际工作负载时表现优异。随着社区规模的扩大,所需的支持工作量也在增加:维护人员不断收到关于文档、GitHub问题、版本说明以及旧Slack讨论帖的相同询问。
Rocky正是为承担这些工作量而打造的Slack助手。它的任务很简单:将重复性的问答任务从维护人员手中分担过来,同时确保所有答案都基于项目的文档及相关资料。
有趣的是它的架构设计。Rocky运行在StarRocks数据库之上。文档片段、关键词检索、向量检索以及相似度计算等功能都存储在同一个OLAP表中,因此负责回答数据库相关问题的助手也运行在同一数据库上。整个系统仅包含约600行Python代码、一个表以及一个Gemini API密钥。
RAG的基础
在构建检索增强生成系统时,通常的第一步是引入专用的向量数据库。每增加一个组件就需要独立的部署、备份方案以及相应的一致性模型,对于小型社区机器人而言,这样的开销实在难以承受。
因此所有功能都保留在StarRocks中。文档片段及其768维的Gemini嵌入数据存储在普通的OLAP表中,采用PRIMARY KEY模型,检索则通过SQL查询实现。其原则是:如果你的分析数据库已经支持数组列和余弦相似度函数,就不需要额外的向量搜索数据系统。
这个表看起来与其他表并无不同:
CREATE TABLE docs (
id BIGINT NOT NULL,
path VARCHAR(512),
`index` INT,
`text` STRING,
vector ARRAY<FLOAT> -- 768-dim Gemini embedding
) ENGINE = OLAP
PRIMARY KEY(id)
DISTRIBUTED BY HASH(id) BUCKETS 1;
检索则是通过余弦相似度进行一次查询:
SELECT path, `index`, `text`,
approx_cosine_similarity([0.012, -0.034, ...], vector) AS similarity
FROM docs
ORDER BY similarity DESC
LIMIT 8;
没有 SDK,没有客户端库,也没有额外的系统。对 Rocky 来说,“向量数据库”只不过是它已经能够查询的又一张表格而已。
从 Slack 事件到实际答案
整个流程被刻意设计得极为简洁。在 Slack 中提及 @Rocky 即可触发该机器人,机器人会通过原生函数调用将推理任务交给 Gemini 3 Flash。该模型会决定是搜索文档还是查询谷歌,且每轮最多可进行十次工具调用。
- Slack 传递提及信息。 Python 版的 slack_bolt 库通过 Socket 模式接收事件,进而提取用户的查询内容以及对话上下文。
- Gemini负责推理并调用工具。该模型会收到包含严格诚实准则的系统提示,以及两个工具:
search_starrocks_doc,用于客户端向量检索;google_search,则通过Gemini内置的网页搜索在服务器端进行信息验证。 - 检索操作在StarRocks内部执行。搜索工具会使用gemini-embedding-001对查询语句进行嵌入处理,设置task_type为RETRIEVAL_QUERY,随后运行之前展示过的余弦相似度计算SQL语句。
- 模型负责生成答案。Gemini将检索到的内容整合成Markdown格式的回复,随后Rocky会将其转换为Slack支持的mrkdwn格式后再进行发布。
- 所有操作数据都会被上传至监控系统。每一次模型调用、工具使用情况以及令牌消耗量都会被记录为OpenTelemetry跟踪事件,其中
thread_ts用作会话标识符。
该机器人程序仅一个文件,约含600行Python代码。用于数据分块、嵌入生成和索引构建的配套工具仅增加了几千行代码,而存储、检索及相似度计算则全部由StarRocks处理。
为何这种技术组合适合轻量级RAG应用
通过三种选择,可在保持较小运营成本的同时生成有用的答案。
主键表结合Stream Load实现文档动态替换
该语料库并非流式处理任务。当文档发生变化时,整个docs/en/目录会被重新分块,嵌入值会被重新生成,随后通过Stream Load将整个表重新加载:
curl --location-trusted -u root: \
-H "format: json" \
-H "strip_outer_array: true" \
-T docs_with_embedding.json \
-XPUT http://host:8030/api/starsage/docs/_stream_load
一个HTTP请求可加载大约5,000行数据,每行包含一个768维的浮点数数组。在这种规模下,Stream Load非常适用:无需维护Broker,无需运行Flink管道,也无需额外的数据摄取基础设施。对于包含嵌入向量的数千到数万行数据,这是最简单的处理方式。
主键模型能够确保原子性替换。在数据重新加载过程中发起的查询会看到一致的快照,而不会出现数据部分更新的情况。这正是选择主键表而非重复键表的主要原因。这里并非涉及行级插入更新操作,因为实际工作量是完整重建数据。主键表结合Stream Load能让每次数据加载都具备原子性,这样Rocky要么返回旧数据集,要么返回完整的新数据集,绝不会混合两者。
将检索作为工具,而非上下文填充手段
文档不会预先加载到系统提示中。search_starrocks_doc被作为一个可调用工具提供,模型会自行决定何时以及多频繁地使用它。
实际上,像“嗨,Rocky”这样的消息根本不会触发任何检索,且几乎不产生成本。而像“3.5版本与4.0版本的GROUP_CONCAT功能有何差异”这类较为复杂的问题则会触发多次调用:模型会针对每个版本进行查询并交叉验证结果。每次对话中的检索次数上限为八次。Gemini的服务器端google_search负责处理文档之外的版本更新说明和GitHub问题,而Rocky则无需额外的网络爬取流程就能覆盖广泛的内容。
用于控制成本的嵌入缓存
社区中频繁出现相关问题。关于如何添加位图索引以及如何在 Kubernetes 上部署的疑问每周都会出现好几次。通过以查询文本和 task_type 作为键的本地 SQLite 缓存(因为 RETRIEVAL_QUERY 和 RETRIEVAL_DOCUMENT 会生成不同的向量),可以避免重复的嵌入调用,从而让高频查询无需额外成本。
在大型语言模型方面,Gemini 的缓存输入令牌费用为标准费用的 25%。借助嵌入缓存,每个答案的生成成本约为 0.001 美元,这对于需要长期运行的社区资助项目来说是可行的。
来自实际使用情况的六点经验
这些经验来自社区的真实使用场景,而非基准测试,且大多适用于聊天平台上的各类领域助手。
1. 调整提示词比优化检索更能减少幻觉现象
早期版本会编造红帽镜像名称、虚构版本说明的网址并生成虚假的版本号,导致社区成员点击后进入404页面。对于问答机器人而言,一个看似有根据的错误答案比毫无答案更糟糕。
解决办法并非提升信息检索能力,而是修改提示词:加入明确的诚实条款,要求模型不得编造事实;当无法准确回答时,应表明自己不确定,同时提供权威来源并建议向社区寻求帮助。效果立竿见影。虽然信息检索很重要,但通过优化提示词往往能更快、更经济地减少虚假信息生成。
2. 在处理检索之前先解决分块问题
StarRocks的文档中包含大量MDX导入语句、跨越不同分块的Markdown表格以及非常长的SQL DDL语句。直接将这些内容输入到嵌入模型中只会得到错误结果。因此,分块处理流程得到了大量投入:
- 按标题进行分层分割。内容块会在 Markdown 标题处断开,整个标题路径会被作为元数据保存,从而使模型了解该内容块在文档层次结构中的位置。
- 噪声过滤。MDX 导入行会被移除,超过 4,000 个字符的代码块也会被舍弃,因为这些内容无法提升答案质量,反而会稀释语义信号。
- 更简化的输出表格。原本以 MySQL 风格表格呈现的示例输出,现在会被简化为结构化描述,而非原始的 ASCII 网格。
进行内容块分割所花费的时间大约是集成向量搜索的十倍。如果召回效果不理想,在更换嵌入模型或调整索引设置之前,应先检查文本是如何被分割的。
3. RAG 是工具,而非完整流程
当检索是通过函数调用而非嵌入到每个请求中时,模型在不需要时可以跳过该步骤。问候语、后续澄清以及简单的事实性问题都不会涉及向量索引。结合客户端工具(search_starrocks_doc)和服务器端工具(google_search),这也是Gemini SDK从1.70版本起就完全支持的,这样即便没有自定义抓取工具,Rocky也能为语料库之外的内容提供备用方案。
4. 遵循平台的会话模型
Slack主题对话带有thread_ts标识符。Rocky直接将其用作会话ID,将历史记录保存在以该标识符命名的JSON文件中,并在每次请求中包含最近的六次对话内容:
contents = []
for turn in history[-MAX_CONTEXT_MESSAGES:]:
contents.append({"role": turn["role"], "parts": [{"text": turn["text"]}]})
contents.append({"role": "user", "parts": [{"text": query}]})
那大约只有二十行代码,远比运行会话数据库简单得多。
用户经常反馈说“Rocky没有记忆”。这是因为在Slack中,Rocky只有在被@提及时才会回复,这让它看起来像忘了事情。这是产品交互方面的问题,而非技术问题。
5. 尽早加入监控功能
系统上线后,团队亟需解答一些基础问题:Rocky本周处理了多少条请求?按主题划分,工具调用的集中区域在哪里?典型回复的生成成本是多少?哪些回复被退回、修改或需要进一步跟进?由于缺乏追踪机制,该系统如同一个黑箱,因此后来才加入了完整的OpenTelemetry追踪层。这个教训就是:必须在系统上线前就加入追踪功能。否则,令牌使用情况、成本以及工具调用模式都将无法被感知,成本优化、错误原因分析以及低质量问题识别等工作都会变得极为困难。
6. 大力使用缓存并精确追踪每一分钱
虽然每次调用嵌入 API 的成本看似很低,但社区中频繁出现的相关问题会迅速累积。SQLite 嵌入缓存加上 Gemini 的缓存令牌定价机制,使得每个答案的单元成本维持在约 0.001 美元左右。对于一个由社区维护的项目而言,这正是“能够永久持续运行”与“需要考虑预算问题”之间的区别。
现实的权衡
Rocky 是一款轻量级的 RAG 助手,而非用于生产环境的支持系统,因此有一些限制需要说明:
- 幻觉现象虽有所减少,但并未完全消除。提示词约束有助于降低错误率,但无法保证绝对正确。文档完善的主题表现最佳,而边缘情况以及不同版本的特殊问题仍可能导致错误。
- 答案较为冗长。与大多数 RAG 机器人一样,它会过度解释。目前正在计划添加一个选项,以便生成简短的总结式答案。
以上均为根据社区反馈制定的当前开发计划项。
Rocky 的功能展示
Rocky是一个规模较小的项目,仅有600行Python代码、一个表格以及一个API密钥,但它能实现更重要的功能。对于轻量级AI应用而言,具备数组列和相似度函数的分析型数据库可以替代独立的向量数据库。通过一个表格即可处理文档存储、关键词搜索和向量检索等功能,这样系统结构更为简洁,只需维护一个系统而非两个。
据该项目介绍,该机器人每天要处理大量的社区请求,用户平均给它的评分是4.2分(满分5分),而且每个回答的生成成本还不到十分之一美分。对于那些希望构建类似领域助手且需要简单基础设施的人而言,单表架构值得考虑,尤其是当项目中已经使用了StarRocks的情况下。
相关阅读
- Postgres 中用于健康助手的混合搜索:架构设计、分块处理、RRF 与评估方法 —— 在 Postgres 中构建健康助手的检索层:基于结构的分块处理、结合 RRF 的 pgvector 混合搜索、二进制量化、阈值设定及评估方式。
- Spring AI 方案:通过元数据按游戏名称筛选 RAG 结果 —— 该方案演示了如何为文档添加标题标签,利用大语言模型从问题中筛选出合适的内容,进而对向量搜索结果进行过滤。