Mastra RAG 进阶:使用 Drizzle ORM 和 pgvector 实现文档版本管理与 Version-aware Retrieval 前言 在上一篇文章《从零构建 Mastra RAG MVP:DeepSeek、SiliconFlow 与 PostgreSQL/pgvector 实战 》中,我使用 Mastra、DeepSeek、SiliconFlow BGE-M3 和 PostgreSQL/pgvector 搭建了最小可运行的 RAG 知识库。
最初的链路是:Markdown → Chunking → Embedding → pgvector → Similarity Search → Agent。它能够完成基础检索,却没有解决文档更新后的版本问题。
当一篇文章从 v1 更新为 v2,旧版向量仍在 pgvector 中时,怎样保证 RAG 不会把旧内容当成最新知识?
这次在原有 POC 上引入 Drizzle ORM,增加三张文档元数据表,实现内容去重、版本创建、发布指针和 Version-aware Retrieval。本文基于实际 TypeScript 代码与命令输出,同时明确没有实现的生产级能力。
一、问题:相似度不等于有效性 假设第一次导入一篇 Go Argon2id 文章,生成 v1 的 35 个 Chunk;更新文章后,生成 v2 的 36 个 Chunk。若两个版本的向量都留在同一个索引,pgvector 可能将 v1 的某个 Chunk 排在 v2 前面。
向量检索只负责判断哪些内容与问题相似 ,无法单凭相似度判断哪些内容属于当前发布版本 。
因此需要独立的 Document Registry:
1 2 3 4 5 6 Document A ├── v1 (35 Chunks) └── v2 (36 Chunks) ← currentVersionId PgVector: v1 和 v2 的向量可以同时存在 Retrieval: 只允许当前发布版本的 Chunk 通过
二、技术栈与数据模型
组件
技术
职责
RAG / Agent
Mastra
文档处理与工具调用
Embedding
SiliconFlow BAAI/bge-m3
生成 1024 维向量
Vector Store
Mastra PgVector
相似度检索
数据库
PostgreSQL + pgvector
保存向量和文档元数据
ORM
Drizzle ORM
管理文档、版本、Chunk
内容
Markdown
知识库输入
三张表的关系如下:
1 2 3 4 5 6 7 documents │ 1:N ▼ document_versions │ 1:N ▼ document_chunks ── vector_id ──► PgVector
2.1 documents:逻辑文档与当前发布指针 1 2 3 4 5 6 7 8 9 10 export const documents = pgTable ("documents" , { id : uuid ("id" ).primaryKey ().default (sql`uuidv7()` ), sourceUri : text ("source_uri" ).notNull ().unique (), title : text ("title" ), status : text ("status" ).notNull ().default ("pending" ), currentVersionId : uuid ("current_version_id" ), createdAt : timestamp ("created_at" , { withTimezone : true }).defaultNow (), updatedAt : timestamp ("updated_at" , { withTimezone : true }).defaultNow (), deletedAt : timestamp ("deleted_at" , { withTimezone : true }), });
同一 sourceUri 始终代表同一篇逻辑文档。currentVersionId 指向当前已发布 版本,而不是版本号最大的记录。例如 v3 正在索引时,指针仍可指向 v2。
uuidv7() PostgreSQL18的新特性,如果你的pg版本不支持,可以把default(sqluuidv7()) ,使用改成defaultRandom()
2.2 document_versions:内容、处理管线和模型版本 1 2 3 4 5 6 7 8 9 10 11 12 13 export const documentVersions = pgTable ("document_versions" , { id : uuid ("id" ).primaryKey ().default (sql`uuidv7()` ), documentId : uuid ("document_id" ).notNull ().references (() => documents.id ), versionNumber : integer ("version_number" ).notNull (), contentHash : text ("content_hash" ).notNull (), pipelineVersion : text ("pipeline_version" ).notNull (), embeddingModel : text ("embedding_model" ).notNull (), status : text ("status" ).notNull ().default ("pending" ), chunkCount : integer ("chunk_count" ).default (0 ), errorMessage : text ("error_message" ), createdAt : timestamp ("created_at" , { withTimezone : true }).defaultNow (), publishedAt : timestamp ("published_at" , { withTimezone : true }), }, (table ) => [unique ().on (table.documentId , table.versionNumber )]);
这里保留三个决定是否需要重新索引的维度:contentHash、pipelineVersion、embeddingModel。即使文章没有变化,修改切分策略或 Embedding 模型也可能需要重新索引。
2.3 document_chunks:Vector ID 到版本的关联 1 2 3 4 5 6 7 8 9 export const documentChunks = pgTable ("document_chunks" , { id : text ("id" ).primaryKey (), documentVersionId : uuid ("document_version_id" ) .notNull ().references (() => documentVersions.id ), chunkIndex : integer ("chunk_index" ).notNull (), contentHash : text ("content_hash" ).notNull (), vectorId : text ("vector_id" ).notNull ().unique (), createdAt : timestamp ("created_at" , { withTimezone : true }).defaultNow (), }, (table ) => [unique ().on (table.documentVersionId , table.chunkIndex )]);
document_chunks 不需要重复保存 Chunk 正文,而是维护 vectorId → documentVersionId → documentId 的关系。
数据库迁移使用 Drizzle Kit:
1 2 npm run db:generate -- --name describe_change npm run db:migrate
三、导入:内容去重与版本创建 3.1 通过 sourceUri 识别文档 1 2 3 await db.insert (documents) .values ({ sourceUri, title }) .onConflictDoNothing ({ target : documents.sourceUri });
来源地址具有唯一约束。重复导入时先找到同一个 Document,再判断是否需要生成新版本。
3.2 SHA-256 内容哈希 1 2 3 4 5 import { createHash } from "node:crypto" ;export function sha256 (content: string ) { return createHash ("sha256" ).update (content, "utf8" ).digest ("hex" ); }
导入时比较当前发布版本:
1 2 3 4 5 6 7 8 9 10 11 12 if ( currentVersion?.contentHash === contentHash && currentVersion.pipelineVersion === PIPELINE_VERSION && currentVersion.embeddingModel === EMBEDDING_MODEL && currentVersion.status === "active" ) { return { status : "unchanged" as const , documentId : document .id , versionId : currentVersion.id , }; }
重复内容不进入重新 Embedding 的分支。这里验证的是顺序执行下的去重;完整并发幂等还需要额外设计。
3.3 版本号分配 版本创建放在 Drizzle 事务中,并对 Document 行加锁:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 return db.transaction (async (tx) => { const [document ] = await tx.select () .from (documents) .where (eq (documents.id , input.documentId )) .for ("update" ); if (!document || document .deletedAt ) { throw new Error ("Document not available" ); } const [latest] = await tx.select ({ versionNumber : documentVersions.versionNumber , }).from (documentVersions) .where (eq (documentVersions.documentId , input.documentId )) .orderBy (desc (documentVersions.versionNumber )) .limit (1 ); const [version] = await tx.insert (documentVersions).values ({ documentId : input.documentId , versionNumber : (latest?.versionNumber ?? 0 ) + 1 , contentHash : input.contentHash , pipelineVersion : input.pipelineVersion , embeddingModel : input.embeddingModel , status : "pending" , }).returning (); if (!version) throw new Error ("Failed to create version" ); return version; });
FOR UPDATE 用来序列化同一 Document 的版本号分配;它不等于完整的 Ingestion 分布式锁。
四、索引:Mastra Chunking、Embedding 和元数据 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 const doc = MDocument .fromMarkdown (content);const chunks = await doc.chunk ({ strategy : "recursive" , maxSize : 512 , overlap : 50 , }); const texts = chunks.map ((chunk ) => chunk.text );const { embeddings } = await embedMany ({ model : embeddingModel, values : texts, }); if ( embeddings.length !== texts.length || embeddings.some ((vector ) => vector.length !== 1024 ) ) { throw new Error ("Invalid embedding dimensions" ); } const ids = texts.map ((_, index ) => makeVectorId (versionId, index));await vectorStore.upsert ({ indexName : INDEX_NAME , ids, vectors : embeddings, metadata : texts.map ((text, index ) => ({ docId : documentId, versionId, chunkIndex : index, text, source, })), }); await saveDocumentChunks ({ versionId, texts, vectorIds : ids });
versionId 会被写入向量 Metadata,但Metadata 里的版本标识不是版本是否有效的权威依据 。真正的发布状态由 Drizzle 管理的 documents.currentVersionId 决定。
五、发布:先索引,再切换当前版本 流程如下:
1 2 3 4 5 6 7 8 9 10 11 Create Version (pending) ↓ Mark processing ↓ Chunk + Embedding + Vector Upsert ↓ Save document_chunks ↓ Publish Version (active) ↓ Update documents.currentVersionId
发布时在同一个数据库事务中更新版本状态和文档指针:
1 2 3 4 5 6 7 8 9 10 11 await tx.update (documentVersions).set ({ status : "active" , chunkCount, publishedAt : new Date (), }).where (eq (documentVersions.id , versionId)); await tx.update (documents).set ({ currentVersionId : versionId, status : "active" , updatedAt : new Date (), }).where (eq (documents.id , documentId));
这样 v2 尚未完成索引时,currentVersionId 仍然可以指向 v1。最新创建版本 与当前发布版本 是两个不同概念。
需要注意:这里的原子性只覆盖元数据发布事务;Embedding API、Vector Upsert 与数据库元数据写入并不共享一个端到端事务。
六、Version-aware Retrieval:核心设计 当前 POC 使用两阶段检索:
1 2 3 4 5 6 7 8 9 10 11 User Query ↓ BGE-M3 Query Embedding ↓ PgVector Top 50 Candidates ↓ Drizzle JOIN + currentVersionId 校验 ↓ 过滤过期版本 ↓ 有效 Top 5
6.1 生成 Query Embedding 1 2 3 4 const { embedding } = await embed ({ model : embeddingModel, value : query, });
embed() 只生成向量,不负责查询数据库,也不负责生成最终回答。
6.2 pgvector 检索候选 1 2 3 4 5 6 7 const candidates = await vectorStore.query ({ indexName : INDEX_NAME , queryVector : embedding, topK : candidateLimit, }); const candidateIds = candidates.map ((item ) => item.id );
此时得到的是按相似度排序的候选 Chunk,其中可能同时包含 v1 和 v2。
6.3 Drizzle 验证当前发布版本 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 const validChunks = await db .select ({ vectorId : documentChunks.vectorId , documentId : documents.id , versionId : documentVersions.id , }) .from (documentChunks) .innerJoin ( documentVersions, eq (documentChunks.documentVersionId , documentVersions.id ), ) .innerJoin ( documents, and ( eq (documentVersions.documentId , documents.id ), eq (documents.currentVersionId , documentVersions.id ), ), ) .where (and ( inArray (documentChunks.vectorId , candidateIds), eq (documents.status , "active" ), isNull (documents.deletedAt ), eq (documentVersions.status , "active" ), ));
最关键的条件是:
1 eq (documents.currentVersionId , documentVersions.id )
这句不是在比较 versionNumber,而是在比较当前发布指针 和Chunk 所属版本 ID 。
Vector
所属版本
当前版本
结果
vector-001
v1
v2
排除
vector-002
v1
v2
排除
vector-003
v2
v2
保留
vector-004
v2
v2
保留
6.4 Map 过滤并保持相似度顺序 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 const validMap = new Map ( validChunks.map ((chunk ) => [chunk.vectorId , chunk]), ); const results : SearchResult [] = [];for (const candidate of candidates) { const valid = validMap.get (candidate.id ); if (!valid) continue ; const score = candidate.score ?? 0 ; if (score < minScore) continue ; const metadata = candidate.metadata ?? {}; if ( typeof metadata.text !== "string" || typeof metadata.source !== "string" || typeof metadata.chunkIndex !== "number" ) continue ; results.push ({ id : candidate.id , text : metadata.text , score, documentId : valid.documentId , versionId : valid.versionId , source : metadata.source , chunkIndex : metadata.chunkIndex , }); if (results.length >= topK) break ; } return results;
批量 JOIN 避免了逐个 Vector ID 查询的 N+1 问题;Map 过滤则保留 pgvector 返回的原始排序。
七、可复现实验:v1 → unchanged → v2 7.1 首次导入 1 npx tsx src/scripts/ingest.ts
实际输出:
1 2 3 4 5 6 7 { "status" : "indexed" , "documentId" : "01a1585d-2b04-7d12-bbcb-8cd5710a166e" , "versionId" : "01a1585d-2b0a-7fd0-b143-80c64fe8aa59" , "versionNumber" : 1 , "chunkCount" : 35 }
7.2 重复导入 1 npx tsx src/scripts/ingest.ts
实际输出:
1 2 3 4 5 { "status" : "unchanged" , "documentId" : "01a1585d-2b04-7d12-bbcb-8cd5710a166e" , "versionId" : "01a1585d-2b0a-7fd0-b143-80c64fe8aa59" }
7.3 修改内容,生成 v2 为了不修改线上博客,可以在测试脚本中为抓取的 Markdown 追加内容:
1 2 3 4 5 6 7 8 const originalContent = await response.text ();const content = originalContent + ` ## Version 2 Test This document version introduces an updated Argon2id implementation for lifecycle testing. ` ;
执行:
1 npx tsx src/scripts/ingest2.ts
实际输出:
1 2 3 4 5 6 7 { "status" : "indexed" , "documentId" : "01a1585d-2b04-7d12-bbcb-8cd5710a166e" , "versionId" : "01a1586b-ed40-7a87-947c-b1bab677c6fd" , "versionNumber" : 2 , "chunkCount" : 36 }
Document ID 不变,Version ID 更新,说明系统识别了同一逻辑文档的新版本。
7.4 检索当前版本 1 npx tsx src/scripts/test-retrieval.ts
测试 Query:Go 语言中如何使用 Argon2id 进行密码哈希?
Result
Score
Version
1
0.770230952973452
v2
2
0.7123205539320796
v2
3
0.7062564598416385
v2
4
0.6941330516975417
v2
5
0.6928984748194693
v2
五条结果的 versionId 均为:
1 01a1586b-ed40-7a87-947c-b1bab677c6fd
这与 v2 导入返回的 Version ID 一致。在当前测试数据上,最终结果没有混入 v1。
还可以补充自动化断言:
1 2 3 4 5 6 7 8 9 10 11 12 13 import assert from "node:assert/strict" ;const expectedVersionId = "01a1586b-ed40-7a87-947c-b1bab677c6fd" ;const results = await searchKnowledge ( "Go 语言中如何使用 Argon2id 进行密码哈希?" , { topK : 5 , candidateLimit : 100 }, ); assert.ok (results.length > 0 , "No results found" ); assert.ok ( results.every ((result ) => result.versionId === expectedVersionId), "Outdated version detected" , );
这段断言是可补充的测试代码;前面的实际检索输出来自手动运行的脚本。要长期稳定复现,最好使用隔离测试数据库与固定的 Markdown 测试数据。
八、明确的工程边界 8.1 先 Top K 再过滤可能漏召回 当前先检索 50 个候选,再通过 Drizzle 过滤。如果前 50 个恰好全部来自旧版本,最终可能返回空结果,即使有效版本中存在相关 Chunk。
更严格的方案是在 PostgreSQL 查询中同时完成向量相似度排序与当前版本约束。实际落地时需要检查 Mastra 向量表结构、查询计划和 pgvector 近似索引的过滤行为。
8.2 索引与发布不是端到端原子操作 Embedding API、向量写入、Chunk 注册和版本发布跨越多个步骤。当前失败版本可以标记为 failed,但残留向量清理、可靠重试和并发导入去重尚未完整实现。
8.3 历史版本向量仍占空间 v1 与 v2 的向量可以同时保留。这有利于历史追踪,但也意味着存储成本随版本增长。后续可以引入版本保留策略、垃圾回收或未变化 Chunk 复用。
8.4 Markdown Front Matter 影响检索内容 实际结果中,Hexo YAML Front Matter 也参与了 Chunk 切分。后续可以先解析 title、tags、categories、date,把它们保存为文档元数据,再仅对正文生成 Embedding。
8.5 未实现的功能 本次 POC 没有实现 Soft Delete/Restore、完整失败恢复、Vector Garbage Collection、跨版本增量索引,也没有验证高并发或大规模历史版本下的召回率。
九、总结 本次实现并不是再做一个 RAG Demo,而是为已有 Mastra RAG MVP 增加了明确的版本语义:
1 2 3 4 v1 导入 → 35 Chunks 相同内容再次导入 → unchanged 修改内容 → v2 / 36 Chunks Version-aware Retrieval → Top 5 全部来自 v2
最重要的设计选择,是使用 documents.currentVersionId 表示当前已发布版本,而不是直接信任向量 Metadata 或最大版本号。
RAG 中的向量相似度负责回答“哪些内容相关”,而 Document Registry 负责回答“哪些内容现在有效”。
对于当前 POC,这已经足够。等真正遇到可靠性、规模或运维需求时,再逐步引入数据库侧版本过滤、失败恢复与向量垃圾回收。