上个月给内部知识库做RAG问答系统时,我看了一圈向量存储的选型,最后反而把目光停在了一个之前没怎么重视的选项上:Firestore。团队的业务数据早就在这个文档数据库里躺着,用户权限、文档归属、品类标签全都以字段形式存在集合里。如果为了检索再单独搭一套向量库,就得做数据同步管道、处理双写一致性,还要想法子把权限过滤条件搬过去。而Firestore本身推出原生向量搜索之后,至少在中小规模的RAG场景里,它有本事把“存储”和“召回”合并成一步。
这篇文章就把我这次完整落地的方案、踩过的坑和一些代码片段整理出来,给同样在RAG场景里评估Firestore的人一个参考。不是要和专业向量库分个高下,而是认真聊聊什么情况下它能用、怎么用、以及用的时候最容易在哪里翻车。
1. 为什么RAG项目会去用Firestore当向量库
1.1 先泼一盆冷水:它不是来跟专业向量库抢饭碗的
很多人一听到“向量检索”,脑子里蹦出来的就是Milvus、Pinecone、Weaviate、pgvector这一串名字。这些产品在百万级、千万级向量规模下确实能打出漂亮的延迟和召回指标,但问题在于,它们往往和业务主存储是两套系统。两套系统意味着什么呢?意味着你要写 ETL 任务把业务数据同步过去,意味着你要处理两边字段映射不一致,意味着当你检索结果里需要带上“用户可见范围”这类实时权限信息时,查询逻辑会变得非常扭捏。
Firestore 的定位完全不同。它不是一套独立部署的向量库,而是在原有文档数据库之上增加了向量字段、向量索引和最近邻查询能力。说白了,你原来用一个集合存用户资料、存订单、存文章,现在还能在同一张文档里塞进一个 embedding 数组,然后对它们做相似度搜索。对于本来就已经在用 Firestore 存业务数据的团队,这意味着RAG链路里根本不需要出现第二条存储系统。
1.2 什么时候我才愿意选它
我这次的需求是几十万篇内部文档的知识库问答,单篇文档切成若干个片段之后,评估下来大概六十万到一百万条向量记录。这个规模在 Firestore 的能力范围内是能扛住的。考虑到我的延迟要求是几百毫秒到秒级,不是那种面向 C 端接口的毫秒级响应,所以它完全可以接受。
适合选它的场景我总结成三类:
- 业务数据和权限元数据已经存在 Firestore 里,且改动成本高。比如用户维度、文档目录、团队标签都依赖 Firestore 的实时快照和事务能力。
- 向量规模在百万级以下,不需要专业向量库的分布式扩展能力。
- 希望 RAG 检索结果的过滤条件能直接复用 Firestore 原生查询。比如按
category字段先过滤、再找最近邻,不用在应用层做二次裁剪。
不适合的场景也很明显:向量规模到了上千万条,召回延迟要到几十毫秒以内,或者要频繁调整索引算法参数来压指标的,就别指望它了。Firestore 向量搜索做的是平台托管的事,索引参数基本不开放给用户调,它给的是开箱即用的服务,不是给你折腾算法的手册页。
1.3 一张表看清楚能力边界
| 能力项 | Firestore 原生向量搜索 | 专业向量库(如 Milvus、Pinecone) |
|---|---|---|
| 存储与业务字段同库 | 支持,文档里直接放向量 | 不支持,需要外部同步 |
| 相似度函数 | 余弦、欧氏、点积 | 更丰富的算法与度量类型 |
| 过滤条件 | 支持 where 预过滤后搜索 | 支持 attribute 过滤 |
| 索引参数调优 | 基本不开放 | 可调底层索引参数 |
| 规模上限(实际体验) | 百万级以下较稳 | 千万到亿级 |
| 运维成本 | 零运维 | 自建需部署与监控 |
| 与 LLM 生态集成 | 有 LangChain 等社区包 | 各家成熟 SDK |
看完这张表,你大概能判断自己属于哪一边。我属于“业务数据就在 Firestore、不太想多维护一个组件”的类型,所以自然站到了表格左侧。
2. Firestore向量检索的底层逻辑:从普通字段到最近邻搜索
2.1 向量字段到底和普通字段差在哪
普通字段查询,比如where("status", "==", "published"),本质是精确匹配或范围匹配。Firestore 内部靠倒排索引、B-tree 一类结构快速定位满足条件的文档。但向量字段没法走这条路。两个向量之间的“相似度”是一个连续值,不存在“等于某个值”这种精确条件,只能计算当前查询向量和文档库中每个向量之间的距离,然后挑出距离最小的那批作为结果。
这就带来了一个关键转变:你必须让 Firestore 为向量字段创建专门的向量索引,而不是像普通字段那样简单标记一下。没有这个索引,查询直接报错;有了索引,服务端才能快速做近似最近邻搜索,避免对每个文档做全量暴力计算。
2.2 距离度量怎么选:绝大多数时候用余弦
Firestore 向量搜索支持三种相似度函数:COSINE_DISTANCE、EUCLIDEAN_DISTANCE、DOT_PRODUCT。我查了官方文档,也在代码里逐个试过,简单说说适用场景。
- COSINE_DISTANCE:值越小越相近,范围在 0 到 2 之间。它只看向量方向不关心长度,适合文本 Embedding 模型。绝大多数 RAG 场景下,主流 Embedding 模型产出的向量都建议用余弦,这也是我最常用的选择。
- DOT_PRODUCT:点积会受向量长度影响,适合已经做过归一化的向量。如果 Embedding 模型本身输出的是归一化向量,点积和余弦在排序上等价,但点积的计算效率通常更高。
- EUCLIDEAN_DISTANCE:欧氏距离对向量长度敏感,适合高维空间里直观的“绝对距离”语义,但在文本语义检索里,向量长度的差异没有意义,所以我一般不太用它。
实际工程里有个很实用的小判断:如果你的 Embedding 模型文档里说了“推荐使用 cosine similarity”,那就直接用COSINE_DISTANCE;如果模型明确说明输出向量已归一化,那可以换成DOT_PRODUCT省点计算。至于EUCLIDEAN_DISTANCE,除了极少数特殊业务,RAG 场景基本用不上。
2.3 索引构建:为什么维度必须提前声明
创建向量索引时需要写明白两个信息:向量字段路径和向量维度。维度就是你每条向量里 float 元素的个数,比如某个 Embedding 模型输出 768 维,那数组长度就是 768,查出来的文档里向量数组长度必须严格等于 768,多一个少一个都会写入失败或查询异常。
这个维度概念可以类比成给一栋楼提前定好每层的房间数量。索引建立的时候,服务端会根据维度规划内部结构;如果后续换了个 Embedding 模型,输出维度从 768 变成 1024,老索引就用不上了,得重新建一个对应 1024 维的索引。
这里要特别提醒:索引不是建完立刻生效的。我实测下来,创建索引后控制台会显示“创建中”状态,短则几十秒,长则几分钟,取决于数据量和平台负载。在索引状态变为“已启用”之前发起向量查询,会直接报错。这个坑后面我会单独讲。
3. 从零跑通一个Firestore向量存储示例:集合、索引与代码
3.1 准备环境和客户端
我用的 Python 版本是 3.10,主要依赖是官方 SDK。
pip install google-cloud-firestore认证方面,把服务账号的 JSON 密钥路径设置到环境变量里,这是官方推荐的方式:
export GOOGLE_APPLICATION_CREDENTIALS="/path/to/service-account-key.json"初始化客户端的代码很简单:
from google.cloud import firestore db = firestore.Client(project="my-rag-project")如果没有指定项目,SDK 会从环境变量里去找。建议把项目 ID 显式传进去,避免多个项目同时使用时连错库。
3.2 设计集合结构与索引配置
我把知识文档切分后的片段统一存在chunks集合里,每条文档结构如下:
| 字段 | 类型 | 说明 |
|---|---|---|
id | string | 文档 ID,我直接用片段唯一标识 |
content | string | 切分后的文本片段,最终会喂给大模型 |
embedding | array | 文本片段的向量表示,维度由模型决定 |
metadata | map | 来源、标题、分类、权限标签等过滤字段 |
在控制台创建向量索引时,我踩过不少弯路,最终稳定的操作路径是:进入某云平台 Firestore 服务的索引管理页,点击创建索引,集合 ID 填chunks,选择字段embedding,类型选向量,维度填模型对应的维度(我用的模型是 768 维)。不同平台控制台版本界面可能略有差异,但入口一般在“索引”管理页里,逻辑是相通的。
创建索引的操作在界面里相当直观,但等待它从“创建中”变成“已启用”需要一点耐心。索引生效前,任何向量查询操作都会失败,千万别在这时候跑去怀疑代码写错了。
3.3 写入向量数据的代码
写入时我用的是批量写方式。Firestore 一个 batch 最多支持 500 次写操作,超过就得分批。下面这段读取了一个文本片段列表并批量写入:
from google.cloud import firestore db = firestore.Client(project="my-rag-project") def add_chunk_batch(chunks, batch_size=400): """ chunks: list[dict], 每个元素包含 content、embedding、metadata """ for i in range(0, len(chunks), batch_size): batch = db.batch() group = chunks[i:i + batch_size] for item in group: doc_ref = db.collection("chunks").document(item["id"]) batch.set(doc_ref, { "content": item["content"], "embedding": item["embedding"], "metadata": item.get("metadata", {}), }) batch.commit()这里我故意把每批数量控制在 400 而不是 500,是给网络波动留余量。如果某一批失败,重试的成本也更低。
写入时还有两个经验:
- 向量数组里所有元素必须都是 float,传入整数列表会导致写入报类型错误。
- 嵌入字段别和普通字段混在一个 map 里,保持最高层级的独立字段,后续更新字段时改动更小。
3.4 执行相似度查询
查询的代码比写入还简单,核心就一行find_nearest:
from google.cloud import firestore db = firestore.Client(project="my-rag-project") def search_similar(query_embedding, limit=5): collection = db.collection("chunks") query = collection.find_nearest( vector_field="embedding", query_vector=query_embedding, distance_measure=firestore.DistanceMeasure.COSINE_DISTANCE, limit=limit, ) docs = query.get() results = [] for doc in docs: data = doc.to_dict() results.append({ "id": doc.id, "content": data.get("content"), "metadata": data.get("metadata", {}), "distance": data.get("vector_distance", None), }) return resultsfind_nearest返回的文档顺序就是按距离升序排列的,最相似的在最前面。如果想在返回结果里直接看到距离值,可以额外传入distance_result_field="vector_distance",这样服务端会把计算出的距离写到结果文档的vector_distance字段里。我用这个参数做调试,能肉眼确认排序质量。
3.5 查询的阈值与数量限制
limit参数控制返回数量,但它不是唯一的约束。如果某次查询和知识库里的所有内容相似度都很低,强行取 5 条只会把无关内容也捞上来。这时候可以用max_distance参数收紧范围:
query = collection.find_nearest( vector_field="embedding", query_vector=query_embedding, distance_measure=firestore.DistanceMeasure.COSINE_DISTANCE, limit=5, max_distance=0.6, )max_distance的含义是“只返回距离小于该值的文档”。对于余弦距离,0 表示完全同向,1 表示正交,2 表示完全相反。我实际调参时发现,0.5 到 0.7 这个区间对大多数文本检索来说比较合适,但具体值严重依赖你的 Embedding 模型,不能照抄别人的阈值,必须拿自己的样本数据跑一遍再看分布。
4. 把向量查询真正接进RAG链路:召回、排序与LLM结合
4.1 从检索结果到 Prompt 之间要做什么
向量检索只是找到了“候选片段”,距离真正能给大模型用的上下文,中间还要做几件事。首先是排序去重。虽然find_nearest已经按距离升序排好了,但如果同一篇文档在切分时有重叠,可能出现多个高相似度片段内容重复,需要在拼进 Prompt 前按文档来源去重。我这里的做法是:先按metadata.source_doc分组,每组最多取两条,然后按距离从小到大重新拼接。
其次是长度控制。每个片段可能 300 到 800 个字符不等,5 条片段加起来可能已经接近上下文窗口的一半。我在拼接时设了一个总字符上限,比如 2000 字,超过就断尾。这个值可以根据你用的模型窗口大小调整,目的是保证 Prompt 里既有上下文,又给模型的输出留足空间。
拼接的模板大概是这样的:
请基于以下参考资料回答用户问题。如果参考资料中没有相关信息,请明确说明。 参考资料: [1] 距离: 0.21 | 来源: 产品手册.pdf <片段内容> [2] 距离: 0.32 | 来源: 售后常见问题.docx <片段内容> 用户问题:<question>把距离值也放进参考资料,看起来有点冗余,但实测对模型理解“哪条资料更相关”有帮助,尤其当多条资料相互矛盾时,模型会更倾向于采信距离更小的片段。
4.2 携带业务元数据的混合过滤
RAG 落地过程中,一个很容易被忽略的需求是“不是所有片段对所有用户都可见”。比如内部知识库里有机密文档、已归档项目、不同团队的数据,如果向量检索不管三七二十一全部召回,再把内容丢给大模型,那就出事故了。
Firestore 的好处这时就体现出来了:你可以在find_nearest之前叠加上普通的where过滤条件,先过滤出当前用户可见的文档集合,再做最近邻搜索。我用过下面这种写法,实测在几万条片段范围内过滤效果稳定:
query = ( db.collection("chunks") .where("metadata.access_level", "<=", current_user["level"]) .where("metadata.status", "==", "published") .find_nearest( vector_field="embedding", query_vector=query_embedding, distance_measure=firestore.DistanceMeasure.COSINE_DISTANCE, limit=5, ) )对比把过滤放在检索之前还是之后,我的经验是:过滤必须放在向量搜索之前。如果先搜出最相似的 100 条再在应用层过滤,极有可能出现“总体相似度很高,但权限内结果太少”的情况,而且结果不稳定。Firestore 这种先过滤再检索的语义,正好和业务需求对齐。
4.3 用 LangChain 社区包接入更高层的抽象
如果你不想手写检索逻辑,LangChain 社区包里也有现成的 Firestore 向量存储实现。我试过用langchain-google-community里的FirestoreVectorStore,它能省掉自己封装 Embedding 模型和拼 Prompt 的样板代码。
from langchain_google_community import FirestoreVectorStore from langchain_community.embeddings import HuggingFaceEmbeddings embedding = HuggingFaceEmbeddings(model_name="BAAI/bge-small-zh-v1.5") store = FirestoreVectorStore( collection=db.collection("chunks"), embedding=embedding, ) results = store.similarity_search("怎么申请报销", k=5)这个包适合快速原型验证,不过我发现它的抽象层比较厚,出了问题不好定位。生产环境里如果对检索逻辑有定制需求——比如加权限过滤、改拼接策略、控制特殊 Pagination——手写find_nearest反而是更稳的选择。我自己现在是手写为主,LangChain 只在快速验证时用。
4.4 写数据的时机与一致性策略
RAG 链路里,新增文档后要及时生成 Embedding 并写入 Firestore。我这段实践中,写入路径是:文档上传到对象存储 -> 触发一个云函数 -> 云函数把文档切分成片段 -> 调用 Embedding 服务生成向量 -> 把片段和向量写入chunks集合。
删除也简单,Firestore 没有“软删除”的强制要求,直接删文档就能保证查询时不会再出现它。更新则要谨慎:文本内容变了,旧的向量没变,会导致检索结果切不中更新后的语义。所以我在内容更新的业务逻辑里,强制要求重新生成向量并整体替换片段。
5. 实测翻车记录:索引未生效、维度不匹配、读取计费这些坑
5.1 “no matching index found”:多半不是代码问题而是索引没建好
这是我第一次跑find_nearest时遇到的报错。现象很简单:客户端抛出一个异常,提示找不到匹配的索引。我当时的排查链路是:
- 先检查代码里
find_nearest的参数有没有写错。看了半天,向量字段名、度量类型、limit 都没问题。 - 去控制台查看索引管理页,发现索引状态还是“创建中”。
- 等了大约两分钟,状态变成“已启用”,重新运行查询,一切正常。
后来我把经验总结成一句话:Firestore 的向量索引从创建到生效有明显延迟,而且是异步的。如果你刚建完索引就立刻跑查询,大概率会遇到这个报错。如果等了几分钟状态还是“创建中”,那可能需要检查是不是索引定义和查询字段不匹配,比如查询里用了embedding,但索引建到了vector字段上。
排查这类问题最直接的办法:在报错信息里找到提示的索引路径,去控制台比对“索引定义”和“查询路径”是否一致,不一致就把多的那个索引删掉重建。
5.2 Embedding 维度不匹配:换了模型忘了重建索引
有一次我把测试数据从 768 维的模型换成 1024 维的新模型,写入时还算顺利,但查询时开始频繁报错维度不匹配。排查后发现问题出在索引上:旧索引还是按 768 维建的,写入 1024 维向量时其实已经埋下隐患,只是某些写入路径没触发严格校验,查询时服务端认真比对才发现结构对不上。
这个坑的解决办法没有捷径:换 Embedding 模型时,必须重新创建对应维度的索引,并且确认旧索引已经删除。否则新旧索引同时存在,服务端可能选错索引,查询结果时好时坏,调试起来更痛苦。
这个经验也提醒我:把 Embedding 模型的名称和维度写进文档的 metadata 里,这样切模型后能快速排查哪些数据是老模型生成的,哪些是新模型生成的,避免混用。
5.3 读取计费:向量查询每次返回的都是整篇文档
Firestore 的计费模式按读操作次数计算,向量搜索的每一次find_nearest结果中的每篇文档都会记为一次小读操作。如果我把向量字段和正文放在同一篇文档里,每次查询返回 5 篇文档,即便我只用到 content 和 metadata,服务端读取的也是整篇文档。
这个问题在调试阶段不明显,一旦检索频率上来,账单会变得很扎眼。我在生产化之前的处理思路有两条:
- 用投影查询只取需要的字段。Firestore 支持
.select("content", "metadata"),可以减少返回的数据量,虽然底层读取成本不一定按字段优化,但传输和反序列化开销确实降下来了。 - 更极端一点,把“用于检索的片段信息”和“用于展示的完整内容”拆成两个集合。检索集合里只放短摘要、元数据和向量,找到文档 ID 后再去内容集合按 ID 读取完整段落。这样向量查询大幅降低数据量,内容读取还能单独计费、单独规划缓存。
考虑到 Firestore 对单请求返回的数据量有限制,上面第二种方式在存储超大正文时更合理。
5.4 阈值调不好:召回率和精确率打架怎么办
max_distance设置过高会拉进一堆无关内容,设置过低则导致很多真实相关问题搜不到。我第一版粗暴地把阈值设成 0.8,结果检索结果里出现大量毫无关系的内容;设成 0.3,又发现三个测试问题里有俩无法召回。
我的调参经验是:先不设阈值,只看结果的距离分布。挑一批有代表性的问题样本,跑查询后把每条结果的距离值打印出来,观察“真正相关”和“不相关”的距离边界。通常会出现一条清晰的分界线:相关结果距离都在 0.4 以内,不相关的基本在 0.6 以上。那阈值先设 0.5,再根据后续人工标注微调。
5.5 索引状态与延迟的一致性
Firestore 底层有一套全局一致的存储,但向量索引的构建和更新是异步的。实测中,刚写入的新文档,过了几百毫秒到几秒之后才可能出现在向量搜索结果里。这种“最终一致性”在离线知识库更新场景问题不大,但如果你的产品要求上传文档后立刻能被检索到,就需要在前端或调度侧做一点容忍。我当时给上传接口加了 2 秒的延迟再提示用户“上传成功并已索引”,同时后端在文档状态里标记indexed_at,用于后台补偿任务。
最后说一点个人体会
如果让我重新选一次,在这个规模下我大概率还是会用 Firestore。不是因为它跑分有多好看,而是因为它把 RAG 最烦人的“数据搬家”问题消解掉了:业务数据在这里,向量字段就长在这里,过滤条件也在这里。我不用维护两套存储,不用写同步任务,也不用担心权限模型对不上。
当然它也谈不上完美。百万级以上、毫秒级延迟这些场景,专业向量库还是要更顶一些的。但对我这种“业务数据本来就在 Firestore、规模中等、Filters 优先级很高”的项目来说,它足够顺手。希望这篇文章能帮你少走几个弯路,尤其是索引未生效、维度不匹配、读取计费那三个坑,真的遇到才知道有多折腾。