团队因多租户全局检索稀释问题,将向量检索和图数据库替换为单一数据仓库方案。根因是「所有租户共享一个语料库导致Top50检索结果被全局热门内容垄断」,暴露了托管RAG服务的常见配置陷阱。
不是它们不好。而是我们算了一笔账:同一款产品,我们跑了三套检索底层:一套托管的向量 RAG 服务、一套托管的图数据库,外加应用自己的 Postgres。三个厂商、三套凭证、凌晨两点要排查的三种故障模式。其中两套还在收我们的钱——就为了嵌入同一份文本。
这就是一篇复盘文:为什么两个都要下掉、为什么一个数据仓库能同时取代两者、以及我们付出最多时间才摸清楚的四个坑。如果你在 BigQuery 上搭 RAG,第 4–7 节能帮你省下一周。
先交代真实进度:图数据库迁移已经建好了。RAG 那半边的决策我们已经承诺先测再定,预先写好了衡量标准。会在文末把那些标准亮出来——包括最终可能把整套东西全删了这个选项。
托管 RAG 的诱人之处在于:分块(chunking)、嵌入(embedding)、建索引、检索,一套 API 全搞定。代价则是:检索出了问题,你只拿到症状,不掌握 machinery。
我们出问题时的样子是这样的。一个 ingest 了约 500 份文档的工作空间,翻来覆去只返回其中一到三份,不管问什么。不是答案质量差——是答案被饿瘦了,来源就那么几份,剩下 497 份躺在那里无人问津。
根本原因是一种架构,你现在就应当去自己的技术栈里查一查:
所有租户共用一个语料库。检出全租户范围内的全局 top-50。然后在客户端再按当前租户过滤。
这是后置过滤(post-filtering)。top-50 是从所有人的数据里抽的奖,你的租户拿到几张票就中几张——在共享语料库的情况下,中奖数寥寥无几。灌更多文档进去不仅没用,还会更糟,因为等于往一个你已经输掉的抽奖池里再加票。
显而易见的修复——服务端元数据过滤——在我们的配置下是一扇关死的门:元数据检索只在 beta API 表面可用,而且明确不支持 serverless 模式。这不是我们自己的疏忽——是白纸黑字写着的限制。
下一个修复方案——传入明确的文档 ID 来限定搜索范围——又撞上了更日常的问题。serverless 向量后端把请求 payload 上限卡在约 10 KB,光查询的 embedding 就占了约 9.6 KB。剩下空间只够传大约十五个 ID。十五个,对五百个。这就是你最终要维护约 150 行 ID 分批、自适应请求拆分、还有一个只在返回零结果时才触发的全有或全无兜底逻辑的原因——所以"五百份里的三份"在自己的代码看来是成功。
再加上一个区域配额,大约每分钟 25 个请求,import、list、delete 共用,局面就清晰了:我们不是在调优一套检索系统。我们是在给一套检索系统搭脚手架。
直说损失了什么:原生重排序(reranking)、语料库生命周期管理、以及把生成(generation)包在一套调用里。第一个是真实损失,得自己重建。第 8 节再说。
这个没那么戏剧化,但更有参考价值,因为图数据库当时跑得好好的。
没有故障。没有性能悬崖。就是一套付费的第三方服务,跑在云项目外面,有自己的凭证、自己的可用性、自己的账单。合并才是全部动机——我宁愿把这一点说清楚,也不愿意事后编一个技术上的不满。
但审计过程中确实发现了一个值得推广的问题。我们的图模型是这样的:
(:Resource)-[:HAS_CHUNK]->(:Chunk {text, embedding})
(:Resource)-[:MENTIONS]->(:Entity {name, type})
(:Entity)-[:RELATED {type}]->(:Entity)
而读取路径实际做的事是这样的:向量检索 chunk,拼回 resource,捞一层实体关系。就这样。
没有变长路径模式。没有 *1..5。没有路径查找、没有中心性分析、没有未知深度的遍历。就是一次向量检索加两轮连接。
所以问题不再是"我们怎么迁移图数据库",而是变成了:
如果你的 Cypher 里根本没有变长模式,你买图数据库图的是什么?
图数据库的价格体现在未知深度的遍历查询上——这类查询里,免索引邻接(index-free adjacency)才是真正胜过 join 的地方。跳一跳不是这类查询。跳一跳就是一次 join,所有数据库都能做 join。
如果你生产环境里有知识图谱,去 grep 一下你的查询里两个节点模式之间有没有 *。这个答案不论是什么都有参考价值。如果找到了,就留着图数据库——这篇文章不适用。如果没有,那篇文章就不适用了。
一旦两件事都提上日程,第三个事实就再也无法忽视:我们在完全相同的一份文本上跑着两套完整的 chunk → embed → search 栈。
摄取服务爬了一个页面,写入对象存储,然后导入托管语料库,语料库内部做分块和嵌入。然后应用从对象存储把同一份文本再读出来,用自己的 splitter 再分一遍,再嵌入一遍。同一个源头,两套分块,两套 embedding,两套账单,两个会悄悄漂移、给同一问题不同答案的东西。
两家厂商都没错。错的是它们之间的接缝。
把两者都压到一个 BigQuery 数据集里,得到一份文本语料库、一套分块、一套 embedding 模型、一个查询表面——而且结构上不可能饿着:
-- pre-filter, then top-k
SELECT ... FROM VECTOR_SEARCH(
(SELECT chunk_id, text, embedding
FROM v_chunk JOIN v_chunk_embedding USING (chunk_id)
WHERE workspace_id = @ws), -- <<< this runs FIRST
'embedding',
(SELECT @qvec AS embedding),
top_k => @probe,
distance_type => 'COSINE')
租户谓词在搜索子查询内部。最近的 k 个是在租户内部计算出来的,不是之后再过滤下来的。第 1 节里的饥饿故障模式在这里不是被缓解了——它根本不可能出现。
让这个方案可行的成本模型:在数据仓库里,查询成本按扫描字节数计。一个 5000 chunk 的工作空间在 768 维下扫描约 30 MB——约每次查询 $0.0002,而且几千行以下暴力向量搜索本来就有竞争力。
也要提一下我们拒绝的替代方案,因为只列赢家优点不是决策复盘:Postgres + pgvector 凭实力是更好的选择。数据库已经部署好了、已经在各处配好了凭证、MERGE 和 ON CONFLICT 一一对应、HNSW 任意行数都可用、延迟在毫秒级而不是 BigQuery 的 0.5–2 秒底价。我们选了另一边,是因为仓库侧可以批量做 embedding 和分析数据同址。这是真实的权衡,不是碾压局——如果延迟是你的硬约束,你可能应该选 pgvector。
这是文章里最危险的事,所以放在最前面。
新一代 embedding 模型已经废弃了 task_type 参数——后端会静默忽略它。你传 RETRIEVAL_DOCUMENT,没有报错,没有警告,就是拿到没有任何任务优化的原始文本 embedding,检索质量悄悄变差,没有任何东西可以调试。
日志干干净净但质量下降,是最糟糕的故障形态。崩溃一下午就能修。
现在任务类型要写在文本里,作为前缀,而且要刻意保持不对称:
# one shared constant — two call sites that must never drift
DOC_PREFIX = "task: retrieval_document | "
QUERY_PREFIX = "task: retrieval_query | "
-- ingest
SELECT * FROM AI.GENERATE_EMBEDDING(
MODEL `ds.embed_model`,
(SELECT chunk_id, 'task: retrieval_document | ' || text AS content FROM v_chunk ...),
STRUCT(768 AS output_dimensionality));
现在有两条规则通过单元测试强制执行,因为生产环境不会提醒你:
Ingest 和 query 必须使用同一个模型、同一个维度。打破它不会得到异常,只得到毫无意义的邻居。
Ingest 和 query 必须使用不同的任务前缀。这种不对称是任务类型的全部意义;对称会悄悄损耗质量。
测试没有任何代码路径传 TASK_TYPE。这三条就保护了你免受一个没有任何其他检测器的 bug 侵害。
embedding 模型 4 月在 ML 平台 GA。截至 7 月底文档更新时,仓库的 remote-model 端点列表仍然用 -preview 后缀命名它。
这是两个独立的表面,仓库侧有延迟。端点字符串错误不会降级——会在 CREATE MODEL 层面直接失败,至少够诚实——但如果你确信模型已经 GA 因而假设端点字符串匹配,会烧掉一个下午。
在任何基于它构建之前先做这个:
CREATE OR REPLACE MODEL `ds.embed_model`
REMOTE WITH CONNECTION `proj.region.conn`
OPTIONS (ENDPOINT = 'model-name'); -- rejected? try 'model-name-preview'
然后把能用的那个放进配置,之后再不推断。同族还有一个坑:全局可用的模型可以展开为与你的数据集 region 冲突的多 region 路径。这看起来像 IAM 错误,但实际不是。
这是文章里迁移性最强的想法,适用于任何往仓库做即发即忘后台写入的人。
都知道旧的按日 DML 配额已经没了。接替它的是这个:
我们的摄取是跨多租户展开的后台任务。一个大批次加一个并发的 reconcile 扫描很容易在同一个表上堆超过 20 个 MERGE——这些不会排队等,会直接报错。最近写入的行也不一定可以可靠地修改。
所以我们把可变 DML 全部移除了。纯追加写入,加一个去重视图:
CREATE OR REPLACE VIEW v_chunk AS
SELECT * FROM chunk
QUALIFY ROW_NUMBER() OVER (PARTITION BY chunk_id ORDER BY ingested_at DESC) = 1;
重新摄取会追加一个更新的 generation。视图只返回最新的。每个读者都得到 MERGE 语义但零可变 DML——upsert 逻辑不是移进了应用,而是移进了读取路径,仓库做这个很乐意。
这个模式悄悄买了两样东西:
用加载任务代替 DML。它们免费、没有流式 buffer 语义、几秒延迟在异步路径上无关紧要。
一个免费的工作队列。Embedding 通过 INSERT … SELECT 填充,插入的是还没有 embedding 行的 chunk。Embedding 调用失败的行直接缺席,所以下一次扫描重试它们。NOT EXISTS 连接就是重试的记账。没有其他重试记账。
一条血的教训:加载任务跨表不是事务性的,所以把 commit-marker 表放在最后写。如果"此文档已摄取"那行先落了,而后面某个加载失败了,那这个文档就永久标记为完成但零 chunk——永不重试、永不检索、对你的缺口指标完全不可见。把它放在最后写,部分失败不留标记,扫描会重试,系统会收敛。失败尝试产生的孤儿行无害;重试的更新 generation 会通过同一个视图覆盖它们。
默认输出是 3072 维。我们用 768。这不是我们不情愿接受的质量妥协——这是整个设计中最大的成本杠杆。
在仓库里,向量主导了行大小:
3072 维 → 每 chunk 约 24 KB → 5000 chunk 的租户每次查询扫描约 120 MB
768 维 → 每 chunk 约 6 KB → 同样租户每次查询扫描约 30 MB
每一条查询,永远 4 倍差距。而现代 embedding 模型都用 Matryoshka Representation Learning 训练,所以截断的输出自动归一化——768 是一个推荐尺寸,不是 hack。你在热路径上用一点点质量换了四倍吞吐量/成本。
它的两个搭档:
按租户键做 clustering。每个读取都是租户范围的;不做 clustering 的话,每次查询都要扫描表里所有向量。这是只扫一个租户的数据和扫所有人数据的差别,一行 DDL。
故意跳过向量索引。这听起来是显而易见的优化,但在这里是个坑:向量索引需要物化表,强迫你走回按租户后置过滤——正是第 1 节里饥饿模式的翻版,在新数据库里忠实地重新实现。几千行以下每租户的暴力搜索既快、又便宜,而且更重要的是天然正确。
还要注意:两套独立的 embedding 栈不需要互相认同。来自不同存储的向量从不作为几何量进行比较——结果是以文本形式合并的。唯一重要的不变量是一个栈内部的自我一致性。
这里我就不声称赢了。
结束重复栈只有两种合理的终态,且互斥:
路径 A——保留托管 RAG 服务,共享其 embedding。把语料库指向一个 feature-store 后端,把 chunk 和 embedding 写进你自己控制的表。服务做一次分块和 embedding;图数据库读那些行,只叠加实体层。我们自己的 embedding 填充消失。重排序、接地生成和语料库生命周期全部原样保留。
路径 B——拿掉托管服务,只用仓库。一套栈,所以天然无重复。摄取服务去掉导入步骤,严格变简单。语料库生命周期代码删掉,不替换。接地生成变成三个调用点的 retrieve-then-generate。重排序得自己重建——在 top-N 候选项上用 LLM reranker,temperature 0,任何失败时退回按分数排序。
两者不可组合,因为 feature-store 选项就是托管服务。
我们在收集数据之前就把决策标准写下来了——这是唯一避免事后合理化的方式:
路径 B 可行,当命中率够高、每次命中的结果数明显优于旧路径、答案质量至少持平。
路径 A 胜出,当命中不稳定或质量下降——因为那说明重排序和托管检索在扛着我们没替换掉的东西。
两个都不行,当命中率低到图数据库不值得它的成本。这时诚实的做法是直接把图数据库删掉,这比两条迁移都便宜。
第三个选项是我强烈建议你也放在自己待办表上的一个。我们系统里每个调用点本来就都对降级方案有优雅降级——这意味着"关掉开关并删掉 800 行代码"一直是可选项,先测量会比两条迁移都便宜。我们是建完之后才测量的。你应该在建之前做。
五句话版本
后置过滤会让多租户检索饿死。要在 top-k 之前过滤,不要之后。
一跳就是一次 join。付图数据库的钱之前先 grep 变长模式。
被静默忽略的废弃参数比报错的参数更糟糕。
纯追加 + 去重视图给你 MERGE 语义但零可变 DML。
在数据到来之前就把决策标准写下来。否则你会发现数据恰好同意你早已建好的东西。
如果你在生产环境里跑过基于仓库的 RAG——尤其是交互路径上的延迟底价——我真诚地想听你怎么说。