先聊个现象。我接触过的很多团队在做RAG知识库时第一反应就是把所有东西都塞进向量数据库文档切完直接embedding入库检索的时候就拿向量去topK。结果呢召回结果经常莫名其妙问A部门报销标准能把B部门团建预算捞出来而且审核记录、来源追踪、数据更新这些偏系统管理的需求反而越做越累。后来研究FastGPT的落地思路发现它并没有把宝全押在向量检索上而是让MySQL承担知识库的元数据和关系管理让ES承担文档的全文检索与向量检索。这套MySQL ES的组合恰恰是很多人忽略但又非常实用的工程化方案。这篇文章我就把FastGPT的这套思路剥开来讲为什么它选择MySQL ES而不是All in向量库核心表结构怎么设计ES索引mapping怎么写混合检索怎么做以及我自己在实现过程中踩过的坑。文章里有完整的表结构DDL和核心代码片段偏后端落地适合已经在做RAG但觉得检索质量、数据管理差点意思的开发者参考。1. 先拆解FastGPT的知识库架构为什么是MySQL ES很多人对FastGPT的认知停留在开源项目、能跑、有工作流但真正值得借鉴的是它对知识库的模块化拆法。FastGPT把知识库拆成了三层数据管理层MySQL、索引检索层ES、模型应用层LLM Embedding。这三层各有各的职责不是简单的存一下、查一下。1.1 知识库的核心链路到底在做什么一个知识库从数据进来到被LLM使用要经历四步数据入库上传文档PDF、Word、txt等解析成纯文本。文本切分把长文档切成有语义边界的chunk段落每个chunk是检索的最小单元。向量化与存储把每个chunk通过Embedding模型转成向量同时把原始文本和元数据落库。检索与问答用户提问时先从库里召回相关chunk拼进Prompt交给LLM生成答案。这四步里第一步和第三步最容易被人轻视。很多方案只做了存向量导致后面查出来的东西没有来源、没有归属、无法追溯就是个黑盒。FastGPT的做法是MySQL管知识库长什么样ES管怎么快速定位到相关内容两者配合而不是互相替代。1.2 对比三种存储方案后MySQL ES是我认为更稳的组合我见过三种主流落地方案各有优劣直接说结论方案优点缺点适用场景纯向量数据库Milvus、qdrant、pgvector向量检索性能好代码少事务弱、元数据管理少、中文关键词检索弱小规模Demo、纯语义问答纯ES走BM25全文检索关键词精准生态成熟自带高可用语义理解弱同义改写、口语化提问会漏召专业术语密集、代码文档、精确匹配多的场景MySQL ESFastGPT路线双层各管一摊事务可靠检索可解释性强可扩展需要维护两套存储写入链路复杂一些生产级知识库需要追踪数据来源、更新频繁FastGPT选MySQL ES不是偶然。向量数据库再快它解决不了这个知识库有哪些数据集、每个数据集多少文档、这个文档属于哪个部门、哪些chunk已经失效这类关系型问题。而ES本身就是一个支持向量检索的分布式检索引擎不需要额外引入向量库就可以同时做关键词检索和向量检索。MySQL负责业务状态ES负责检索能力这是一种职责分离的架构。2. 表结构设计把知识库拆成可管理的最小单元要说借鉴FastGPT最值得抄的就是它的数据模型。不是说要照搬它的字段而是理解它的拆表思路一个完整的知识库不是一张大表存了事而是拆成数据集→文档→段落→向量索引四个层级。这样每个环节都可审计、可单独控制。2.1 核心四张表dataset、document、paragraph、vector_index先上DDL这是我根据FastGPT的设计思路结合自己的业务场景调整后的版本-- 数据集表对应一个业务知识库根节点 CREATE TABLE dataset ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 主键, name varchar(128) NOT NULL COMMENT 数据集名称, type tinyint(4) NOT NULL DEFAULT 1 COMMENT 类型1-通用文档库2-问答对库3-表格库, embed_model varchar(128) NOT NULL DEFAULT bge-large-zh COMMENT 使用的向量模型标识, index_mode tinyint(4) NOT NULL DEFAULT 2 COMMENT 索引模式1-仅关键词2-关键词向量3-仅向量, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 状态0-禁用1-启用, creator varchar(64) DEFAULT NULL COMMENT 创建人, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), KEY idx_name (name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT知识库数据集表; -- 文档表每个文档对应一次上传/导入 CREATE TABLE dataset_document ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 主键, dataset_id bigint(20) unsigned NOT NULL COMMENT 数据集ID, name varchar(255) NOT NULL COMMENT 文档名称, source_type tinyint(4) NOT NULL DEFAULT 1 COMMENT 来源类型1-上传2-导入3-接口同步, size int(11) NOT NULL DEFAULT 0 COMMENT 原始文件大小(字节), status tinyint(4) NOT NULL DEFAULT 0 COMMENT 处理状态0-等待1-解析中2-已完成3-失败, process_msg varchar(512) DEFAULT NULL COMMENT 处理失败时的错误信息, creator varchar(64) DEFAULT NULL COMMENT 上传人, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), KEY idx_dataset_id (dataset_id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT文档表; -- 段落表切分后的每个chunk是LLM上下文的最小单元 CREATE TABLE dataset_paragraph ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 主键, dataset_id bigint(20) unsigned NOT NULL COMMENT 数据集ID, document_id bigint(20) unsigned NOT NULL COMMENT 文档ID, content text NOT NULL COMMENT 段落原始文本, content_hash char(64) NOT NULL COMMENT 内容哈希用于判重和增量更新, token_count int(11) NOT NULL DEFAULT 0 COMMENT token数用于控制context长度, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 状态1-启用0-禁用, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), KEY idx_dataset_id (dataset_id), KEY idx_document_id (document_id), KEY idx_hash (content_hash) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT文档段落表; -- 向量索引表记录每个段落对应的向量在ES中的索引信息 CREATE TABLE dataset_vector_index ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 主键, dataset_id bigint(20) unsigned NOT NULL COMMENT 数据集ID, paragraph_id bigint(20) unsigned NOT NULL COMMENT 段落ID, document_id bigint(20) unsigned NOT NULL COMMENT 文档ID, es_index_name varchar(128) NOT NULL COMMENT ES索引名称, es_doc_id varchar(64) NOT NULL COMMENT ES文档ID, vector_model varchar(128) NOT NULL COMMENT 向量模型标识, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 状态1-有效0-已失效, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), KEY idx_paragraph_id (paragraph_id), KEY idx_es_doc_id (es_doc_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT向量索引映射表;2.2 每张表的关键设计意图dataset表的index_mode字段这是FastGPT设计里很灵活的一点。不是所有知识库都需要向量检索比如内部代码规范库里全是精确术语走关键词检索反而更准。留三个模式可以让不同数据集用不同检索策略。paragraph表保留content原文有些人会疑惑既然ES里已经存了文本MySQL为什么还要存一份答案是ES不适合作为唯一数据源。我的实践中ES经常因为写入延迟、refresh间隔导致数据暂时不一致而且如果后面要重新向量化比如换Embedding模型直接从MySQL捞原文重算就行不用反向导数据。ES负责检索MySQL负责存档各司其职。content_hash字段这个字段很多人会漏掉。它的作用是判断文档内容是否变化。比如用户重新上传了同名文档系统可以先计算新文档分段的hash和存量hash比对没变的段落跳过向量化变了才重新embedding。这在高频更新文档的知识库里能省一大笔API调用费。dataset_vector_index表它其实是一个映射关系表把逻辑段落和ES物理文档关联起来。有人会问直接在ES文档里存dataset_id和paragraph_id不就行了为什么还要单独建表因为在ES里做复杂条件过滤会拖慢查询而且ES不适合作为业务数据的事实来源。MySQL里有这一层映射做删除、权限校验、状态变更都方便得多。2.3 数据生命周期删除和更新怎么联动知识库不是一次性写完就不动了文档更新、删除很频繁。以删除为例我推荐用标记删除 异步清理而不是物理删。具体流程-- 删除文档时先把文档状态置为禁用 UPDATE dataset_document SET status 0 WHERE id ?; -- 同时把该文档下的段落标记为禁用 UPDATE dataset_paragraph SET status 0 WHERE document_id ?;然后后台任务异步把ES里对应的doc删除并更新dataset_vector_index表状态。这样做的好处是删错了可以秒级恢复而且不会因为ES删除抖动导致业务接口报错。我用这个方案在线上跑了大半年基本没有出现过数据不见了但系统状态异常的问题。3. ES索引与检索管线倒排索引、向量检索和混合召回MySQL管好元数据后真正的检索能力由ES提供。ES在这个架构里不是简单的文本搜索框我做了两个索引策略一个是传统倒排索引用于关键词召回一个是稠密向量索引用于语义召回然后用混合策略做融合排序。3.1 ES索引mappingIK分词 dense_vectorES侧的mapping设计直接决定召回精度。这是我的索引mapping模板{ settings: { index: { number_of_shards: 3, number_of_replicas: 1, analysis: { analyzer: { ik_analyzer: { type: ik_max_word, tokenizer: ik_max_word } } } } }, mappings: { properties: { paragraph_id: { type: long }, dataset_id: { type: long }, document_id: { type: long }, content: { type: text, analyzer: ik_analyzer, search_analyzer: ik_smart }, content_vector: { type: dense_vector, dims: 1024, index: true, similarity: cosine }, create_time: { type: date } } } }几个关键点ik_analyzer负责中文分词。我试过标准分词器中文效果很差报销标准会被拆成报销和标准两个独立token如果不用ik_max_word很多语义边界就丢了。FastGPT默认也是类似思路。ES自带标准分词器对中文不友好一定要装IK分词插件。dense_vector的dims要和Embedding模型输出维度一致。我这里用的bge-large-zh是1024维如果你用OpenAI的text-embedding-ada-002就是1536维用m3e-base是768维。这个值在mapping里写死上线后就不能随便改所以选模型前先确认好维度。content字段用ik_max_word建索引用ik_smart查。这是IK官方推荐的组合索引时尽量切细检索时切得粗一点召回率会高一些。不要把向量写入和业务写入放在一个节点。如果数据量很大建议用ILM索引生命周期管理策略区分热索引和冷索引不过初期数据量不大时闲聊即可。3.2 混合检索BM25 向量召回 RRF融合这一步是FastGPT检索比较核心的地方它不是只做向量检索而是把BM25关键词召回和向量语义召回的结果做一个融合排序。这个融合算法叫RRFReciprocal Rank Fusion倒数排名融合。RRF的核心思路很简单每个文档在多个召回结果里都有个排名把排名的倒数加起来总分越高越靠前。公式score(d) Σ (1 / (k rank(d)))k一般取60。这个算法不需要对两个检索结果做分数归一化因为BM25的分数和cosine相似度的尺度不同直接加权不好调但排名融合就避免了这个问题。public class HybridSearchService { Autowired private RestHighLevelClient esClient; public ListParagraphVO hybridSearch(Long datasetId, String query, float[] queryVector, int topK) { // 1. 向量检索KNN 召回 top50 ListESDoc semanticDocs searchByVector(datasetId, queryVector, topK * 5); // 2. 关键词检索BM25 召回 top50 ListESDoc keywordDocs searchByKeyword(datasetId, query, topK * 5); // 3. RRF 融合排序 ListESDoc merged mergeByRrf(semanticDocs, keywordDocs, 60); // 4. 取前 topK 返回 return merged.stream().limit(topK).collect(Collectors.toList()); } }我实际调参后的经验是BM25召回top50 向量召回top50做RRF融合最后取topK10的效果最好。如果只取top20再融合你会发现语义召回的一些长尾chunk永远排不上来。有人会问为什么不直接用RRF代替评分因为RRF本质只是一个排序融合策略它依赖两个基础检索器的质量。如果BM25召回的内容本身就是错的关键词RRF也救不回来。所以基础检索器要尽量做对。3.3 相关段落召回后的重排RRF融合后的结果还不是最终答案我会再加一层rerank重排。说白了RRF已经把可能相关的段落聚过来了但它们的顺序不一定符合用户真实意图这时候用一个cross-encoder模型比如bge-reranker-large对问题-段落做一次精细打分。从我实践来看bge-reranker对中文问题的排序效果提升非常明显尤其对那种问法口语化、正文书面化的场景。下面是一个简单的重排代码示例from sentence_transformers import CrossEncoder # 加载rerank模型bge-reranker-base在中文场景已经够用 reranker CrossEncoder(BAAI/bge-reranker-base) def rerank_results(query, candidates, top_n5): pairs [[query, cand[content]] for cand in candidates] scores reranker.predict(pairs) # 按照分数从高到低排序 scored sorted(zip(candidates, scores), keylambda x: x[1], reverseTrue) return [item[0] for item in scored[:top_n]]重排层不是必须的如果你的知识库只是做一个关键词精确查询工具可以跳过去。但只要你的QA是开放式的我强烈建议加。它的成本比embedding高但因为只对RRF召回的几十条文本打分量不大延迟可以控制在几十一百毫秒内。3.4 为什么保留关键词检索而不是纯靠向量这是我和很多同行讨论过的问题。现在市面上很多RAG方案把向量检索当成默认选项但落到中文知识库场景关键词检索依然有价值一是专有名词比如C、YAML、FastGPT这种词Embedding模型在切词时很容易混淆语义二是查询词在文档里原样出现时BM25的精确命中往往比向量相似更可信三是向量召回容易过度泛化比如问数据库连接失败排查可能召回一堆关于数据库性能优化的内容但BM25不会。FastGPT的做法是给我启发比较深的一点索引模式里把仅关键词/关键词向量/仅向量做成数据集的可选项。我线上实践时技术文档库用关键词向量效果明显好于纯向量政策问答库对语义泛化要求高向量权重可以大于关键词代码片段库我甚至直接设置了仅关键词模式。4. 核心代码落地文本切分、双写一致性和检索接口架构说得再好最终要落到代码。这一章给出一套可以直接跑的简化实现包含文本切分、MySQL和ES双写、以及最终检索接口的组合。4.1 文档解析与文本切分粒度决定召回率的上限在FastGPT里文本切分不是简单按字符数截断。我最终采用的方案是分隔符优先 滑动窗口兜底优先按Markdown标题#、##、段落换行符、句号分号切分如果某个块太长超过设定的max_chunk_size比如800个字符用滑动窗口按句子边界继续切相邻chunk之间保留一些重叠overlap避免句子被切断导致语义丢失。以下是我在项目中使用的切分逻辑import re def split_text(text, max_chunk_size800, overlap_size100): # 先按换行和标题分块 blocks re.split(r\n(?#{1,6}\s|\S), text) chunks [] current_chunk for block in blocks: block block.strip() if not block: continue # 如果当前块加上新块会超过max先输出当前块 if len(current_chunk) len(block) max_chunk_size and current_chunk: chunks.append(current_chunk) current_chunk block else: current_chunk \n block if current_chunk else block # 如果当前块还是太长比如表格、长代码继续按句子切 while len(current_chunk) max_chunk_size: split_pos find_split_pos(current_chunk, max_chunk_size) chunks.append(current_chunk[:split_pos]) current_chunk current_chunk[split_pos-overlap_size:] # 保留overlap if current_chunk: chunks.append(current_chunk) return chunks def find_split_pos(text, max_len): # 在max_len之前找最近的句号/分号/空格 for sep in [。, , , . , \n]: pos text.rfind(sep, 0, max_len) if pos max_len * 0.6: return pos len(sep) return max_len这段代码的核心在于尽可能不要在一个句子中间切断。我在最初版本是死板地按800字切结果经常出现一个问题后半段在上一块结尾、前半段在下一块开头检索时哪块都匹配不全。加了overlap和语义边界后召回质量提升了一个档次。4.2 MySQL和ES的双写一致性先写哪个双写一致是数据类系统绕不开的话题。我踩过先写ES导致MySQL失败的情况也踩过先写MySQL但ES写入超时的情况。现在的方案是先落MySQL再异步写ES。具体流程解析文档生成段落列表批量插入MySQL的dataset_paragraph表拿到自增ID每段计算embedding向量批量写入ESES文档里直接存paragraph_id和dataset_id写入成功后更新dataset_vector_index表里的映射关系。如果ES写入失败依赖一个重试队列补偿。要注意的是ES写入后的文档默认要等1秒refresh才能被检索到所以刚导入完立刻查可能会查不到。我处理的办法是导入完成后主动调用一下ES的_refresh接口保证用户搜索时数据可见public void refreshIndex(String indexName) throws IOException { RefreshRequest request new RefreshRequest(indexName); esClient.indices().refresh(request, RequestOptions.DEFAULT); }如果数据量大频繁refresh会影响写性能我的经验是在导入一批数据结束后统一refresh一次不要每写一条就刷新。4.3 检索接口的SQL与ES DSL实现检索接口分两层先走ES拿到候选doc再回MySQL补充业务字段。这里要注意的是不要把ES当成唯一字段来源ES检索时只返回paragraph_id其余字段查MySQL。代码大致public ListKnowledgeChunk search(Long datasetId, String query, Float[] vector, int topK) throws IOException { // 1. 构造ES查询条件 - 向量KNN 关键词BM25 混合查询 SearchRequest request new SearchRequest(knowledge_index_v1); BoolQueryBuilder boolQuery QueryBuilders.boolQuery(); boolQuery.filter(QueryBuilders.termQuery(dataset_id, datasetId)); // 关键词子查询 QueryBuilder keywordQuery QueryBuilders.matchQuery(content, query); // 向量子查询 - ES 8.x 使用KNN查询 QueryBuilder knnQuery QueryBuilders.knnQuery(content_vector, vector) .boost(1.0f); boolQuery.should(keywordQuery).should(knnQuery); SearchSourceBuilder source new SearchSourceBuilder() .query(boolQuery) .size(topK) .fetchSource(new String[]{paragraph_id}, null); request.source(source); SearchResponse response esClient.search(request, RequestOptions.DEFAULT); // 2. 解析结果 ListLong paragraphIds new ArrayList(); for (SearchHit hit : response.getHits().getHits()) { MapString, Object fields hit.getSourceAsMap(); paragraphIds.add(((Number) fields.get(paragraph_id)).longValue()); } // 3. 回MySQL查完整内容 return paragraphMapper.selectBatchByIds(paragraphIds); }值得一提的是ES 8.x里的knnQueryAPI它包装了HNSW的搜索逻辑。ES 7.x也有类似功能但依赖插件。如果你们公司的ES集群还停留在7.x我建议升到8.x向量检索的API会顺手很多。4.4 把召回结果还原成LLM能用的上下文最后一个环节是把检索结果拼装成Prompt。很多人会直接把所有召回段落一股脑塞给LLM这样不仅浪费token还会引入噪声。我用的拼装规则是请根据以下参考资料回答问题。如果参考资料中没有相关内容请直接回答未找到相关信息。 参考资料 [1] (来自文档《FastGPT部署指南》) FastGPT支持Docker Compose一键部署需要准备MySQL、ES和MongoDB... [2] (来自文档《ES向量检索配置》) dense_vector字段需要使用HNSW算法索引dims需要与模型输出维度一致... 问题FastGPT如何部署 回答关键细节每段前面都标注来源。这样LLM即使回答不准用户也能顺着引用编号去原文查证。这点我是从FastGPT的引用功能里学来的。知识库问答如果没有引用溯源在团队内部根本不敢放开用。5. 上线后遇到的典型问题和调优手记最后分享一些实战总结都是上线跑了一段后慢慢发现的问题没有经过二次加工的平滑感但每条都真实影响过系统表现。5.1 召回结果不准问题往往出现在切分而非模型这是我调优耗时最长的一个问题。一开始潜意识以为是向量模型不够强换了更强的模型后提升却有限后来把召回的chunk拉出来看发现很多chunk内部文本话题跨越太大比如一个chunk里前一半讲ES查询语法后一半已经跳到MySQL索引优化了模型再强也召回不准。解决办法就是回到切分逻辑把段落按Markdown标题、列表层级切分确保一个chunk只包含一个语义主题。换模型三个月不如重新设计切分策略两个星期这个值得反复强调。5.2 ES数据同步失败的补偿机制ES写入请求可能超时失败不可能完全避免。我现在的方案是在dataset_vector_index表里维护一个status字段写入ES成功后置为1。定时任务每5分钟扫一次status0且update_time大于10分钟前的记录重新推送。这套MySQL驱动ES补偿的方法帮我扛过了好几次ES节点抖动而且补偿逻辑不依赖MQ部署成本低。还有一个细节ES里的index不要建得太多不然分片管理会很痛苦。线上我统一用一个物理索引按月份别名数据集维度靠字段dataset_id过滤。这就意味着mapping了一层共享但换来的运维成本下降是值得的。5.3 性能优化从一次检索耗时1500ms降到200ms刚上线时一次检索要1.5秒左右非常拉胯。查了一下性能热点逐个优化瓶颈原因解决方案效果每次请求都加载Embedding模型模型加载耗时长启动时预加载用线程池并发调用降低约300msES分页查询取原始文本大字段传输慢检索阶段只取paragraph_id回MySQL拿content降低约400msMySQL查询没有索引paragraph_id没走索引加索引降低约100ms串行调用向量检索和关键词检索阻塞等待改成CompletableFuture并行调用RRF前汇合降低约400ms重排模型串行打分候选多、模型慢限制重排候选数量批量推理降低约100ms优化完后单次检索稳定在200ms左右其中ES召回约30ms、重排约80ms、MySQL回表约20ms其他为网络和组装开销。这个水平在业务上完全够用了。5.4 关于FastGPT本体的延伸思考我通篇都在说借鉴意思是你可以照着这套思路自己搭一个适合业务的RAG知识库也可以直接在FastGPT基础上二次开发。FastGPT整个项目对知识库老本的优雅处理——向量和关键词的索引模式可配置、段落级数据管理、双存储策略都是从工程实践中沉淀的。很多时候我们现在急着要的大而全工具反而不如把存储设计、检索策略这类基本功做扎实来得好用。如果你正在做知识库相关项目我的建议很直接不要急着上多复杂的编排和Agent先把MySQL表结构理清楚把ES检索调度做扎实再考虑上层应用。我见过太多项目倒在这一步之前。
阅读完成 · 觉得有帮助?