ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

工业级 RAG 存储设计:MySQL + Milvus 双写架构

工业级 RAG 存储设计:MySQL + Milvus 双写架构 这是「RAG 工程实践」系列的第二篇篇主题①《RAG 面试高频题原理、优化及工程落地》② 本篇工业级 RAG 存储设计MySQL Milvus 双写架构③《手写工业级 RAGMySQL Milvus 完整实现》本篇假设你已经知道 RAG 的基本链路解析 → 切片 → 向量化 → 召回 → 重排 → 生成 不清楚的话建议先看系列第一篇。写在前面RAG 做到能跑很容易解析文档、切块、丢进向量库、召回后拼 Prompt。难的是做到能上线——文档更新了怎么增量同步向量库和数据库双写怎么保证一致权限怎么过滤才不会越权换 embedding 模型了旧向量怎么办这些问题全落在存储层。这一篇就把存储层的三件事讲透表怎么建rag_documentrag_chunk两张表的字段设计与取舍向量库怎么配Milvus 集合该存什么、不该存什么双写怎么做对写入顺序、幂等机制、补偿与对账任务文末会解释一个容易被忽略的设计因果chunk_id用自增主键还是业务推导 ID会直接决定双写顺序相反。想清楚这个才算真的理解了双写。目录写在前面目录1. 先分清一件事原文到底放哪2. 生产表结构设计2.1 rag_document文档主表2.2 rag_chunkChunk 切片表核心2.3 Milvus 集合设计2.4 两侧一致性chunk_id 状态机2.5 可选扩展表大型 RAG 平台2.6 坑点清单3. 完整检索链路对照表结构4. 数据同步写入顺序、幂等与补偿4.1 标准写入链路4.2 三类变更的动作4.3 幂等的保证4.4 补偿与对账任务5. 可选扩展5.1 父子切片扩展5.2 权限过滤实现5.3 多租户扩展6. 小结1. 先分清一件事原文到底放哪这是 RAG 存储设计里最容易被面试追问、也最容易前后摇摆的问题。先把结论摆清楚方案 A原文放 MySQL本文采用方案 Bchunk 原文冗余进向量库原文存储rag_chunk.raw_textLONGTEXTMilvus 里加chunk_text VARCHAR(4096)召回后取文本WHERE chunk_id IN (...)主键批量点查直接从检索结果里读额外开销一次 MySQL 主键 IN 查询约 1~5ms无一致性单一可信源不存在文本双写不一致文本双写两侧可能不一致长度限制LONGTEXT无实际限制VARCHAR 上限长块会被截断权限与溯源天然支持仍要回查 MySQL 校验权限关键认知真正的性能陷阱是「召回后回 OSS 拉原始文件重新解析」几十到几百毫秒还要重新切片不是「回 MySQL 主键点查」毫秒级。所以方案 A 多出来的这一次 DB 往返完全可接受换来的是单一可信源和强权限。一句话总结可以回 MySQL不要回 OSS。2. 生产表结构设计设计原则四条后面所有代码都围绕它MySQL 是唯一可信主存储存文档元数据、chunk 原文、偏移、页码、权限、版本chunk_id全局唯一主键Milvus 只是向量检索索引只存向量、chunk_id、少量短元数据不存 chunk 原文关联键chunk_id两侧一一对应召回拿到 id 后回 MySQL 取原文支持增量更新、软删除、权限过滤、溯源、断点续传、Hybrid 召回扩展2.1 rag_document文档主表一份文件对应一条记录管文档生命周期、权限、来源。CREATE TABLE rag_document ( doc_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 文档唯一ID, doc_name VARCHAR(512) NOT NULL COMMENT 文档名称文件名, doc_type VARCHAR(64) NOT NULL COMMENT pdf/markdown/word/txt, doc_source VARCHAR(128) DEFAULT COMMENT 来源上传/爬虫/接口, storage_path VARCHAR(1024) DEFAULT COMMENT 原始文件 OSS 路径, owner VARCHAR(128) DEFAULT COMMENT 归属人/部门 id权限控制, permission JSON COMMENT 可见权限[user1, deptA], doc_md5 VARCHAR(64) NOT NULL COMMENT 文件 md5用于判断文件是否变更, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待解析 1正常 2已归档 3软删除, version INT NOT NULL DEFAULT 1 COMMENT 文档版本号更新时1, parse_err_msg TEXT COMMENT 解析失败原因, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (doc_id), UNIQUE KEY uk_doc_md5 (doc_md5), KEY idx_owner_status (owner, status), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENTRAG 文档主表;要点doc_md5 唯一键 内容级判重 断点续传同一份文件重复上传不会重复解析同步任务先算 md5 再决定要不要走后面的流程status是文档级生命周期0 待解析 → 1 正常 → 2 已归档 / 3 软删除permission用 JSON 存复杂权限用户、部门、角色列表简单过滤用owner文档更新 同一doc_id上version 1而不是新建一条文档记录⚠️UNIQUE KEY uk_doc_md5在多租户下会踩坑两个不同租户上传同一份文件md5 相同会直接插入冲突。多租户场景请改成UNIQUE KEY uk_owner_md5 (owner, doc_md5)或按 4.3 加tenant_id。2.2 rag_chunkChunk 切片表核心一条记录 一个切片存 chunk 原文、文本位置、切片元信息。CREATE TABLE rag_chunk ( chunk_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 切片全局唯一ID与向量库主键一致, doc_id BIGINT UNSIGNED NOT NULL COMMENT 所属文档ID关联 rag_document.doc_id, raw_text LONGTEXT NOT NULL COMMENT chunk 原始切片文本, start_offset INT NOT NULL DEFAULT 0 COMMENT 在原始文档文本中的起始偏移量, end_offset INT NOT NULL DEFAULT 0 COMMENT 结束偏移量, page_num INT DEFAULT NULL COMMENT 页码PDF 才有纯文本为空, chunk_seq INT NOT NULL DEFAULT 0 COMMENT 切片在文档内顺序号用于上下文拼接, chunk_strategy VARCHAR(64) NOT NULL COMMENT 切片策略recursive/section/semantic, chunk_size INT NOT NULL COMMENT 本次切片设定大小, chunk_overlap INT NOT NULL COMMENT 重叠窗口, vector_model VARCHAR(128) NOT NULL COMMENT 向量化模型名称防止向量版本混乱, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待向量化 1正常 2待删除 3失效, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (chunk_id), KEY idx_doc_id (doc_id), KEY idx_status (status), CONSTRAINT fk_chunk_doc FOREIGN KEY (doc_id) REFERENCES rag_document (doc_id) ON DELETE CASCADE ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENTRAG 切片主表;重点字段说明chunk_id与 Milvus 主键完全一致。召回拿到chunk_id列表后IN (...)批量查 MySQL 拿raw_textvector_model容易被忽略但很关键。换 embedding 模型后旧向量与新向量不在同一个空间全部失效。靠这个字段区分做全量重建或双集合灰度切换status软状态机。文档更新时旧 chunk 标记3 失效不物理删除方便回溯start_offset/page_num溯源用回答时可以告诉用户这段来自第几页、哪个位置几条工程建议索引建议改为(doc_id, status)按文档查 chunk 时几乎总会带 status 条件联合索引能省掉回表过滤建议补一个token_count INT上下文预算裁剪时不用现算Rerank 后按 token 拼装有它方便很多外键要谨慎ON DELETE CASCADE在大表 / 分库场景下有性能和运维风险很多公司明令禁用外键。生产更常见做法是不要物理外键改为应用层维护 定时对账清理孤儿数据2.3 Milvus 集合设计Milvus不存 raw_text只存向量 chunk_id 少量短元数据用于前置过滤。collection_name: rag_chunk_vector description: RAG 切片向量索引只存向量与 chunk 关联 ID dim: 1024 # 依模型而定见下方「维度对照」 primary_field: chunk_id fields: - name: chunk_id type: INT64 is_primary: true description: 和 rag_chunk.chunk_id 完全一致 - name: vector type: FLOAT_VECTOR dim: 1024 description: embedding 向量 - name: doc_id type: INT64 description: 文档 ID用于前置过滤如按文档检索 - name: owner type: VARCHAR max_length: 128 description: 归属人/部门简单权限前置过滤复杂权限放 MySQL - name: status type: INT8 description: 状态和 rag_chunk.status 保持一致 index_params: index_type: HNSW metric_type: COSINE params: M: 16 ef_construction: 128from pymilvus import MilvusClient, DataType client MilvusClient(uriMILVUS_URI, tokenMILVUS_TOKEN) schema client.create_schema(auto_idFalse, enable_dynamic_fieldFalse) schema.add_field(chunk_id, DataType.INT64, is_primaryTrue) schema.add_field(vector, DataType.FLOAT_VECTOR, dim1024) schema.add_field(doc_id, DataType.INT64) schema.add_field(owner, DataType.VARCHAR, max_length128) schema.add_field(status, DataType.INT8) index_params client.prepare_index_params() index_params.add_index( field_namevector, index_typeHNSW, # 低延迟高召回IVF_FLAT 需要调 nlist/nprobe这里省事 metric_typeCOSINE, params{M: 16, efConstruction: 128}, ) client.create_collection(rag_chunk_vector, schemaschema, index_paramsindex_params)为什么选 HNSW索引特点HNSW图索引查询延迟低、召回率高M控制图连通度、efConstruction控制建图质量查询期ef是召回率/延迟的主旋钮IVF_FLAT倒排聚类需要调nlist和查询期nprobe参数敏感但内存占用更低、构建更快维度对照按你实际的 embedding 模型填别照抄 1024模型dimbge-large-zh / bge-m31024bge-small-zh512阿里 text-embedding-v2 / v31536OpenAI text-embedding-3-small1536OpenAI text-embedding-3-large3072支持缩维到 1024 / 1536系列第三篇的代码默认取1536通过环境变量EMBEDDING_DIM覆盖。 建集合时填错维度不会报错但会在upsert阶段才炸——换模型时务必同步改并用rag_chunk.vector_model把新旧向量区分开。检索时除了filterstatus 1用search_params{params: {ef: 64}}做召回率与延迟的权衡ef 越大召回越全、越慢。Hybrid 召回扩展需要 BM25 关键词路时Milvus 2.4 支持SPARSE_FLOAT_VECTOR可加一个sparse_vector字段做原生稀疏检索否则就外接 Elasticsearch走 《面试高频题》§3.3 融合RRF vs 分数融合。2.4 两侧一致性chunk_id 状态机chunk_id是唯一的关联键而status是双写一致性的核心机制rag_chunk.statusMilvus 侧含义能否被召回0待向量化无记录已切片还没 embedding❌1正常status1双写完成✅2待删除待清理软删中❌3失效待清理 / 无文档更新后的旧版本切片❌检索时永远带filterstatus 1只有写完两侧的 chunk 才对检索可见。这一条设计把双写中间态直接挡在了检索之外。2.5 可选扩展表大型 RAG 平台表用途rag_embedding_recordembedding 调用日志耗时、token、模型版本用于成本归因与模型效果对比rag_query_log用户检索日志query、召回 chunk_id、答案、耗时、反馈用于评估 RAG 效果与 badcase 回流2.6 坑点清单❌ 错误做法后果 / 正解召回后回 OSS 拉原文重新解析latency 极高。原文必须落 MySQL主键IN点查只要毫秒级用向量库做主权限校验向量库不支持事务两侧不同步会越权泄露。owner只做前置过滤MySQL 兜底换 embedding 模型但不换集合新旧向量不在同一空间召回全乱。靠vector_model区分全量重建或双集合灰度两侧chunk_id不一致映射断裂权限校验与取原文全部失效物理删除 chunk / 文档无法回溯、无法审计。一律用status软状态低频定时清理多租户下用UNIQUE(doc_md5)不同租户同文件会插入冲突改成(owner, doc_md5)或加tenant_id往 Milvus 塞 raw_text文本双写不一致 VARCHAR 长度受限 权限绕过风险3. 检索召回链路① Query → Embedding ↓ ② Milvus searchfilterstatus 1 owner 前置过滤召回 TopN 个 chunk_id ↓ ③ MySQLSELECT chunk_id, raw_text, page_num, doc_id FROM rag_chunk WHERE chunk_id IN (...) AND status 1 ↓ ④ JOIN rag_document 校验文档 status 是否正常、permission 是否命中 ↓ ⑤ Rerank 精排 token 预算裁剪 ↓ ⑥ 组装上下文带 page_num 溯源标注→ LLM 生成# ② 向量召回只要 chunk_id不取文本 res client.search( collection_namerag_chunk_vector, data[query_vec], limit50, filterstatus 1, # 只召回双写完成的切片 search_params{metric_type: COSINE, params: {ef: 64}}, output_fields[chunk_id, doc_id, owner], ) chunk_ids [hit[entity][chunk_id] for hit in res[0]] # ③④ 回 MySQL 取原文 权限兜底 rows mysql.query( SELECT c.chunk_id, c.raw_text, c.page_num, c.chunk_seq, c.doc_id, d.doc_name, d.permission, d.status AS doc_status FROM rag_chunk c JOIN rag_document d ON d.doc_id c.doc_id WHERE c.chunk_id IN %s AND c.status 1 AND d.status 1 , (tuple(chunk_ids),)) # 权限兜底Milvus 的 owner 过滤只是优化这里才是权威判定 rows [r for r in rows if has_permission(r[permission], current_user)]第 ③ 步不是瓶颈几十到几百个主键的IN点查走聚簇索引实测通常在毫秒级。真正要优化掉的是回 OSS和重复 embedding。4. 数据同步写入顺序、幂等与补偿4.1 标准写入链路① 算文件 md5 → 按 uk_doc_md5 判重已存在且 status1 则直接返回幂等第一道 ↓ ② INSERT rag_documentstatus0 待解析 ↓ ③ 解析 切片 → 批量 INSERT rag_chunkstatus0 待向量化拿到自增 chunk_id ↓ ④ 读 raw_text → 调 embedding 生成向量 ↓ ⑤ Milvus upsert主键 chunk_id→ 幂等第二道 ↓ ⑥ UPDATE rag_chunk.status 1 ↓ ⑦ UPDATE rag_document.status 1, version version 1为什么这里必须先写 MySQL、再写 Milvus因为chunk_id来自 MySQL 的自增主键Milvus 的主键要用它顺序物理上无法颠倒。 对比如果主键是{doc_id}_{version}_{seq}这类业务推导出来的确定性 ID就可以先写向量库 —— 两种主键选型决定了相反的双写顺序面试时说清楚这个因果关系很加分。那MySQL 已写、Milvus 未写的窗口怎么堵靠status状态机status0的记录在检索侧被filterstatus 1直接挡掉用户感知不到中间态失败的切片留在库里等补偿任务重试即可。这正是status字段存在的核心意义而不只是个软删除标记。4.2 三类变更的动作场景MySQLMilvus新增文档插rag_document(status0)→ 切片插rag_chunk(status0)→ 成功后双双置 1upsert 向量status1更新文档doc_id不变、version1、更新doc_md5重新切片生成新的 chunk_id旧 chunk 标status3 失效写入新 chunk 向量旧向量标记待清理删除文档rag_document.status3、其下 chunk 标status2 待删除低频定时清理对应向量注意更新语义同一doc_id原地升版version字段 doc_md5唯一键的结果旧切片靠status3保留用于回溯而不是新建一条文档记录。4.3 幂等的保证幂等层级机制解决什么文档级uk_doc_md5唯一键同一份内容不会重复解析、重复 embedding切片级Milvus 按主键chunk_idupsert重试只会覆盖同 id 向量不产生重复数据流程级status状态机中断后可从status0继续实现断点续传4.4 补偿与对账任务def retry_pending_chunks(timeout_min: int 10, max_retry: int 3): 补偿把卡在「待向量化」的切片重新 embedding 并写 Milvus。 rows mysql.query( SELECT c.chunk_id, c.raw_text, c.vector_model, d.owner, d.doc_id FROM rag_chunk c JOIN rag_document d ON d.doc_id c.doc_id WHERE c.status 0 AND c.update_time NOW() - INTERVAL %s MINUTE , (timeout_min,)) for r in rows: try: vec embed(r[raw_text], modelr[vector_model]) client.upsert(rag_chunk_vector, [{ chunk_id: r[chunk_id], vector: vec, doc_id: r[doc_id], owner: r[owner], status: 1, }]) mysql.execute(UPDATE rag_chunk SET status 1 WHERE chunk_id %s, (r[chunk_id],)) except Exception as e: incr_retry(r[chunk_id]) if retry_count(r[chunk_id]) max_retry: mark_failed(r[chunk_id], str(e)) # 落 parse_err_msg 告警 def reconcile(batch_size: int 1000): 对账比对「MySQL status1 的 chunk_id 集合」与「Milvus 中的 chunk_id 集合」 - MySQL 有、Milvus 没有 → 补写向量或把 MySQL status 打回 0 等重试 - Milvus 有、MySQL 没有 / MySQL 已失效 → 孤儿脏向量标记待清理 ... def clean_deleted(): 低频定时如每天凌晨物理清理 status IN (2,3) 对应的 Milvus 向量。 ids mysql.query(SELECT chunk_id FROM rag_chunk WHERE status IN (2,3)) if ids: client.delete(rag_chunk_vector, ids[r[chunk_id] for r in ids]) mysql.execute(DELETE FROM rag_chunk WHERE status IN (2,3) AND update_time NOW() - INTERVAL 7 DAY)一致性兜底建议消息在 MySQL 事务提交后再投递事务消息表 / binlog 订阅避免消息发了但事务回滚补偿与对账任务都要可重放依赖的就是upsert幂等 状态机物理删除只在低频定时任务里做不要放在主链路5. 可选扩展基础的rag_chunk是单层切片不含父子字段、不含多租户字段。下面三个是常见扩展按需取用。5.1 父子切片扩展如需支持 《面试高频题》§2.3 父子切片加两列即可ALTER TABLE rag_chunk ADD COLUMN chunk_type VARCHAR(16) NOT NULL DEFAULT child COMMENT parent 父块 / child 子块, ADD COLUMN parent_chunk_id BIGINT UNSIGNED DEFAULT NULL COMMENT 父块 chunk_id子块必填父块为 NULL, ADD KEY idx_parent (parent_chunk_id);约定父块也存进rag_chunkraw_text是完整大块原文但不写 Milvus、不参与召回只有子块进向量库。def retrieve_with_parent(query_vec, user, top_k5, token_budget3000): # ① 只召回子块 hits client.search( collection_namerag_chunk_vector, data[query_vec], limittop_k * 4, # 多召一些权限过滤后仍有余量 filterstatus 1 AND chunk_type child, output_fields[chunk_id, doc_id, owner], )[0] child_ids [h[entity][chunk_id] for h in hits] if not child_ids: return [] # ② 回 MySQL 取子块 父块 id同时做权限兜底 rows mysql.query( SELECT c.chunk_id, c.parent_chunk_id, c.chunk_seq, c.raw_text, c.doc_id, d.doc_name, d.permission, d.status AS doc_status FROM rag_chunk c JOIN rag_document d ON d.doc_id c.doc_id WHERE c.chunk_id IN %s AND c.status 1 AND d.status 1 , (tuple(child_ids),)) rows [r for r in rows if has_permission(r[permission], user)] # ③ 按父块 id 批量取回完整上下文MySQL 主键点查不走向量 parent_ids sorted({r[parent_chunk_id] for r in rows if r[parent_chunk_id]}) parents {} if parent_ids: for p in mysql.query( SELECT chunk_id, raw_text FROM rag_chunk WHERE chunk_id IN %s, (tuple(parent_ids),), ): parents[p[chunk_id]] p[raw_text] # ④ 按父块去重 token 预算裁剪 ctx, used, seen [], 0, set() for r in sorted(rows, keylambda x: x[chunk_seq]): pid r[parent_chunk_id] if pid in seen: # 多个子块命中同一父块只取一次 continue seen.add(pid) text parents.get(pid) or r[raw_text] # 父块缺失时降级用子块原文 cost count_tokens(text) if used cost token_budget: break ctx.append({doc_id: r[doc_id], doc_name: r[doc_name], text: text}) used cost return ctx几个容易忽略的点父块用 MySQL 主键点查不走向量检索不消耗向量算力父块必须去重多个子块命中同一父块时不去重会出现大段重复上下文降级策略父块因同步延迟缺失时退回子块原文不要抛异常不加parent_chunk_id的替代方案是让父块记录 seq 区间、子块用BETWEEN反查 —— 重切片时区间维护成本高不推荐5.2 权限过滤实现# ① 向量侧前置过滤优化减少候选集不能作为权威判定 filter_expr fstatus 1 AND owner {user.dept} # ② MySQL 兜底校验权威permission 是 JSON 数组 rows mysql.query( SELECT ... FROM rag_chunk c JOIN rag_document d ON d.doc_id c.doc_id WHERE c.chunk_id IN %s AND (JSON_CONTAINS(d.permission, %s) OR JSON_CONTAINS(d.permission, %s)) , (tuple(ids), json.dumps(user.id), json.dumps(user.dept)))原则向量库只做粗筛提性能能不能看由 MySQL 说了算。复杂的部门树、角色继承不要塞进向量库放在 MySQL 侧算好可见范围再下推成doc_id IN (...)的过滤条件。5.3 多租户扩展基础表结构用ownerpermission表达权限没有租户维度。要多租户ALTER TABLE rag_document ADD COLUMN tenant_id VARCHAR(64) NOT NULL DEFAULT COMMENT 租户ID, ADD UNIQUE KEY uk_tenant_md5 (tenant_id, doc_md5); -- 替代单列 uk_doc_md5 ALTER TABLE rag_chunk ADD COLUMN tenant_id VARCHAR(64) NOT NULL DEFAULT COMMENT 租户ID, ADD KEY idx_tenant_status (tenant_id, status);Milvus 侧同步加tenant_id VARCHAR(64)字段检索时前置过滤。所有 SQL 强制带tenant_id并在数据层禁止跨租户检索。6. 小结存储层的设计可以收敛成四条MySQL 是唯一可信源——原文、权限、版本、状态全在这里Milvus 只是可重建的检索索引chunk_id是唯一的关联键——两侧必须严格一致断了就全链路失效双写顺序由主键生成方式决定——自增主键必须先 MySQL业务推导的确定性 ID 可以先向量库。这个因果想清楚双写问题就解决了一半status状态机是幂等的地基——它让中断可续传、让中间态不可见、让补偿任务可重放最后提醒一句不要为了少一次 DB 往返把原文冗余进向量库。多出来的是毫秒级开销换来的是文本双写不一致、VARCHAR 长度受限、权限绕过三个新麻烦。真正要躲的性能陷阱是回 OSS 拉原文件重新解析。想看完整实现系列第三篇把这些设计全部写成了可运行代码 《手写工业级 RAGMySQL Milvus 完整实现》「RAG 工程实践」系列① RAG 原理、优化及工程落地 ② MySQL Milvus 双写架构 ③ 完整实现三篇互链将在发布后回填
返回列表