ARTICLE DETAIL

资讯详情

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

手写知识库RAG系统:MySQL+Elasticsearch双写架构实践

手写知识库RAG系统:MySQL+Elasticsearch双写架构实践 这几年做知识库相关项目绕不开一个名字FastGPT。它最大的价值不只是开箱即用的问答界面而是把一个知识库引擎最核心的环节——文档解析、分段、索引、召回——完整地拆开摆在你面前让你知道一个能打的 RAG 系统究竟是怎么串起来的。我这次要分享的就是借鉴 FastGPT 的设计思路用 MySQL 管结构化元数据、用 Elasticsearch 管向量和全文索引从零手写一套轻量知识库的完整方案包含可直接抄走的表结构和核心代码。这套东西我实际用在一个内部文档问答项目上跑了小半年中间踩了不少坑也把一些关键参数调到了相对稳的状态。写出来主要是给那些不想一上来就套全家桶想搞清楚知识库内部到底发生了什么的朋友一个参考。1. 整体设计思路为什么是 MySQL ES1.1 先理解 FastGPT 在做什么FastGPT 这类成熟项目的底层逻辑其实就四步文档入库、内容切片、向量化存储、检索生成。听起来简单但每一步都有取舍。FastGPT 用 MongoDB 存业务数据用 ES 存向量和全文索引再用编排层把知识库检索和 LLM 对话串起来。我一开始也想过照搬 MongoDB 方案但回头一看我们团队对 MySQL 的运维经验更足业务数据里还有大量需要事务保障的元信息比如文档状态、权限、版本。MongoDB 虽然灵活但为了一个知识库场景引入一套新库代价偏大。所以折中方案就是MySQL 管结构化数据ES 管非结构化检索两个各干各擅长的活。1.2 MySQL 和 ES 各承担什么职责在知识库系统里数据大致分两类一类是关于文档的数据比如标题、来源、上传时间、解析状态、分段数量另一类是文档本身的内容比如段落文本、语义向量。MySQL 适合管前一类因为它有事务、有外键约束语义当然我一般不用物理外键、有成熟的权限控制。而 ES 适合管后一类因为它的倒排索引和向量索引天生就是为检索设计的尤其是混合检索关键词 语义场景下ES 一个查询就能把两种结果都拉出来。有人会问向量直接存 MySQL 里用 pgvector 那种方案行不行如果你用的是 PostgreSQL当然可以。但我们这里限定 MySQL那 MySQL 里即使装了 vector 相关插件也不够成熟而且全文检索能力比 ES 差太多。所以双写方案是当前最稳妥的架构。1.3 整体数据流拆解整个链路是这样的用户上传文档MySQL 里插入一条文档记录状态为待解析。后台任务解析文档内容按段落或固定窗口切分成 chunks。每个 chunk 生成向量调用 embedding 模型接口同时也保留原始文本。chunk 的元数据属于哪个文档、排序、字符数写 MySQL文本和向量写 ES。用户提问时先对问题生成向量再去 ES 做混合检索。把检索到的 top-k chunk 文本拼进 prompt交给 LLM 生成回答。这套流程里 MySQL 和 ES 的同步是关键。我采用的策略是先写 MySQL再写 ES。MySQL 写入成功后ES 写入失败则通过重试队列补偿。这样能保证至少 MySQL 里永远有最全的元数据ES 挂了可以重建索引而不会出现两边数据对不上的死锁。2. 表结构设计把知识库的骨架搭起来2.1 四张核心表我最终的表结构比第一版精简了很多核心就四张知识库表、文档表、分段表、分段向量表。其中向量表在 MySQL 里只存向量 ID 和元数据不存向量本身专门给 ES 做关联用的。先看建表语句-- 知识库表 CREATE TABLE kb_knowledge_base ( id bigint NOT NULL AUTO_INCREMENT COMMENT 知识库ID, name varchar(128) NOT NULL COMMENT 知识库名称, description varchar(512) DEFAULT COMMENT 描述, embedding_model varchar(128) DEFAULT text-embedding-v1 COMMENT 向量模型标识, search_mode tinyint NOT NULL DEFAULT 1 COMMENT 检索模式: 1-向量检索 2-全文检索 3-混合检索, status tinyint NOT NULL DEFAULT 1 COMMENT 状态: 1-启用 0-停用, creator_id bigint NOT NULL COMMENT 创建人ID, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_creator (creator_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT知识库信息表;-- 文档表 CREATE TABLE kb_document ( id bigint NOT NULL AUTO_INCREMENT COMMENT 文档ID, kb_id bigint NOT NULL COMMENT 所属知识库ID, doc_name varchar(256) NOT NULL COMMENT 原始文件名, doc_type varchar(32) DEFAULT COMMENT 文件类型: pdf/docx/md/txt, storage_path varchar(512) DEFAULT COMMENT 存储路径, file_size bigint DEFAULT 0 COMMENT 文件大小(字节), status tinyint NOT NULL DEFAULT 0 COMMENT 解析状态: 0-待解析 1-解析中 2-已完成 3-失败, chunk_count int NOT NULL DEFAULT 0 COMMENT 分段数量, error_msg varchar(512) DEFAULT COMMENT 失败原因, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_kb_id_status (kb_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT知识库文档表;-- 分段表 CREATE TABLE kb_chunk ( id bigint NOT NULL AUTO_INCREMENT COMMENT 分段ID, doc_id bigint NOT NULL COMMENT 所属文档ID, kb_id bigint NOT NULL COMMENT 所属知识库ID, chunk_index int NOT NULL DEFAULT 0 COMMENT 段落在文档中的顺序, content_hash char(64) NOT NULL COMMENT 内容哈希用于去重或变更检测, char_count int DEFAULT 0 COMMENT 字符数, status tinyint NOT NULL DEFAULT 1 COMMENT 状态: 1-有效 0-已删除, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_doc_id (doc_id), KEY idx_kb_id (kb_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT文档分段表;-- 分段向量关联表 CREATE TABLE kb_chunk_vector ( id bigint NOT NULL AUTO_INCREMENT, chunk_id bigint NOT NULL COMMENT 分段ID, es_doc_id varchar(64) NOT NULL COMMENT ES中的文档ID格式建议为 kb_{kbId}_doc_{docId}_chunk_{chunkId}, vector_model varchar(128) DEFAULT COMMENT 向量模型标识, status tinyint NOT NULL DEFAULT 1 COMMENT 索引状态: 1-已索引 0-已删除, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_chunk_model (chunk_id, vector_model), KEY idx_es_doc_id (es_doc_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT分段向量索引关联表;我在设计时有个原则能算出来的字段就不落库。比如 chunk 数、文档状态这类能实时统计的尽量实时查只有需要频繁查询且统计成本高的才加字段。chunk_count 在文档表里保留是因为列表页需要展示而且统计涉及跨表实时 count 在大文档场景下会慢。2.2 为什么用复合索引而不是外键看上面表结构可以发现我没有在 kb_chunk 上加外键约束。原因很现实一是导入数据时外键检查影响写入速度二是后续如果要分库分表物理外键是个巨大障碍三是业务层已经能保证数据一致性外键的收益不大。但索引必须建好。我的经验是复合索引的字段顺序要以等值条件优先。比如idx_kb_id_status (kb_id, status)因为知识库列表页最常见的查询就是查某知识库下所有已完成文档kb_id 是等值条件status 是范围条件把等值放前面才能最大程度利用索引。有个细节要注意content_hash字段我用了 char(64)也就是固定长度的 SHA256 哈希值。这里不要用 varcharchar 类型存储固定长度字符串时检索效率更高而且能避免尾部空格问题。每次文档重新解析时先查一下 hash 是否存在就能跳过重复内容省下不少 embedding 调用费。2.3 字段类型选择的硬经验文档表里file_size我用的是bigint而不是int别小看这个。文件大小计量单位最准的是字节一个 2GB 的 PDF 文件字节数已经超过 int 上限了。很多线上事故都是从字段溢出开始的。storage_path我留了 512 长度但真实路径一般没这么长之所以预留是因为同一个文件可能会有多个衍生版本比如 PDF 转出的文本文件、图片转出的 base64 缓存路径拼接后长度会明显增加。这种字段宁宽勿窄。status字段我统一用 tinyint 而不是枚举类型。MySQL 的 enum 类型在后续要新增状态时必须执行 ALTER TABLE 修改枚举值这在 online DDL 下会锁表非常痛苦。tinyint 加上代码层的常量定义就够了。3. 核心代码MySQL 侧的数据落库逻辑3.1 文档上传与初始状态写入用户上传文档后第一步是落一条 MySQL 记录。代码我用 Java 写但思路可以平移到任何语言。Transactional(rollbackFor Exception.class) public Long uploadDocument(Long kbId, MultipartFile file) { // 1. 校验知识库是否存在 KnowledgeBase kb kbMapper.selectById(kbId); if (kb null) { throw new BizException(知识库不存在); } // 2. 保存文件到本地或OSS String storagePath fileStorageService.save(file); // 3. 插入文档记录状态为待解析 KbDocument doc new KbDocument(); doc.setKbId(kbId); doc.setDocName(file.getOriginalFilename()); doc.setDocType(FileTypeUtil.detect(file.getOriginalFilename())); doc.setStoragePath(storagePath); doc.setFileSize(file.getSize()); doc.setStatus(DocumentStatus.WAITING_PARSE); docMapper.insert(doc); // 4. 发送消息触发异步解析 mqSender.sendParseTask(doc.getId()); return doc.getId(); }注意Transactional只包到 MySQL 写入这一步不能把文件保存也包进去。因为文件保存走的是 OSS 或本地磁盘回滚机制不同如果文件保存成功后 MySQL 插入失败事务回滚不会自动删除文件反而会造成孤儿文件。我踩过这个坑后来把文件落盘放到事务外部增加一个定时任务清理孤儿文件。3.2 文档解析与分段写入文档解析是最容易出问题的一环。PDF 和 Word 的解析库看着简单实际处理起来什么奇怪数据都有。我的解析流程是用 TextExtractor 提取纯文本并按页保留一个页码标记。按固定窗口切分文本默认每段 600 字符重叠 100 字符。计算每段内容的 content_hash去重。批量插入 kb_chunk拿到自增 ID。调用 embedding 接口逐批生成向量。分段这一步我强烈建议你用重叠窗口而不是简单按句号切。原因是如果一个关键段落正好被切开语义就被拆散了检索时匹配到的片段信息不完整。重叠 100 字符能保证两头的内容都不丢失代价是稍微多占点存储但检索效果提升明显。public ListChunkInfo splitText(String rawText, int chunkSize, int overlap) { ListChunkInfo result new ArrayList(); int start 0; int index 0; int length rawText.length(); while (start length) { int end Math.min(start chunkSize, length); String chunkText rawText.substring(start, end); // 尝试在边界处找更合理的切分点句号、换行 if (end length) { int adjust findBetterBoundary(rawText, end, 50); chunkText rawText.substring(start, adjust); end adjust; } result.add(new ChunkInfo(index, chunkText)); start end - overlap; } return result; }findBetterBoundary的逻辑很简单从 end 位置往后最多看 50 个字符找到第一个。、、、\n就停。这样做的好处是让每个 chunk 尽量是语义完整的句子而不是硬生生截断。坏处是 chunk 的实际长度可能不均匀但比均匀截断的效果好得多。3.3 chunk 批量插入的性能优化批量插入 chunk 的时候几百几千条数据一条条 insert 是不现实的。我用了 MyBatis 的批量 insert一次 500 条配合 rewriteBatchedStatements 参数速度能提升数倍。INSERT INTO kb_chunk (doc_id, kb_id, chunk_index, content_hash, char_count, status) VALUES (#{item.docId}, #{item.kbId}, #{item.chunkIndex}, #{item.contentHash}, #{item.charCount}, 1)但批量插入有个前置条件需要拿到 MySQL 自动生成的主键 ID后面填 ES 文档 ID 要用。MyBatis 的 useGeneratedKeys 在批量场景下依赖数据库驱动MySQL Connector/J 是支持的但记得在连接 URL 上加上rewriteBatchedStatementstrue否则驱动不会真正走批量提交而是逐条执行性能等于没优化。批量插入完成后会拿到 List chunkIds。这里要特别注意MySQL 批量插入返回的主键顺序和入参顺序是否一致取决于驱动实现。为了避免错位我在业务代码里做了一个防御性校验用 content_hash 做二次匹配确保 chunkId 和 chunkIndex 对应正确。这个 bug 不常见但一旦出现检索结果会张冠李戴非常难排查。4. ES 侧实现索引设计与数据写入4.1 ES 索引的 mapping 设计ES 索引设计是整个知识库的检索核心。我给每个知识库单独建索引不这样索引数量会爆炸。我的方案是所有知识库共用一个索引用字段kb_id做过滤。这样运维成本低查询时只要在 bool 查询里加一个 filter 条件即可。{ settings: { number_of_shards: 3, number_of_replicas: 1, analysis: { analyzer: { ik_smart_pinyin: { type: custom, tokenizer: ik_smart, filter: [lowercase, pinyin_filter] } }, filter: { pinyin_filter: { type: pinyin, keep_full_pinyin: false, keep_first_letter: true, keep_joined_full_pinyin: true } } } }, mappings: { properties: { kb_id: { type: keyword }, doc_id: { type: keyword }, chunk_id: { type: keyword }, content: { type: text, analyzer: ik_smart_pinyin, search_analyzer: ik_smart }, content_vector: { type: dense_vector, dims: 1536, index: true, similarity: cosine }, chunk_index: { type: integer }, created_at: { type: date, format: yyyy-MM-dd HH:mm:ss } } } }这里最容易被忽略的是content字段的 analyzer 配置。我用的是 IK 分词 拼音过滤。为什么加拼音因为知识库里经常有英文缩写、人名、产品名比如用户搜支付的费率但文档里写的是charge rate纯中文分词根本匹配不上。加了拼音过滤器后英文关键词能被拼音前缀匹配到召回率提升明显。代价是索引体积变大分词复杂度上升但和召回效果比这点代价可以接受。4.2 向量字段的选型细节content_vector用的是 dense_vector维度 1536。这个数字来自我用的 embedding 模型。如果你的模型输出 768 维就改成 768。不要为了省存储强行降维那会损失检索精度。这里有个版本细节不同 ES 版本的 dense_vector 参数不一样。ES 8.x 里要显式声明index: true和similarity: cosineES 7.x 里直接指定 dims 就行。如果你用 ES 8.7还可以考虑 int8 量化把向量精度降到 int8存储能省四倍但召回效果会略降。我的线上环境没开量化因为索引规模不大存储成本可以接受。4.3 写入 ES 的代码逻辑ES 写入我用了官方 Java Client批量写入用 BulkRequest。关键点是每批写入 500 条每 5 秒 flush 一次避免一次性攒太多导致内存溢出。public void indexChunk(ChunkVectorDTO dto) { IndexRequest request new IndexRequest(kb_chunk_index) .id(dto.getEsDocId()) .source(jsonMapper.writeValueAsString(dto), XContentType.JSON); try { esClient.index(request, RequestOptions.DEFAULT); } catch (IOException e) { log.error(ES索引写入失败, esDocId{}, dto.getEsDocId(), e); retryService.sendRetryTask(dto.getEsDocId()); } }esDocId的格式我定为kb_{kbId}_doc_{docId}_chunk_{chunkId}这样从 ES 里查出来一条记录立刻能反推它属于哪个文档、哪个分段不用额外查 MySQL。这个约定帮我省了好多次 join 查询。有个小坑ES 的index请求是覆盖式写入。如果同一个 esDocId 被重复提交会直接覆盖旧文档不会报错。这在重试场景下很有用但也意味着你要保证每次写入的内容是完整一致的。我在重试逻辑里特意把整个 chunk 的数据都带上而不是只带字段增量。4.4 数据同步失败的兜底策略MySQL 和 ES 的同步不可能 100% 成功。我的兜底策略是把失败的 esDocId 写到一张重试表定时任务每 5 分钟拉取重试超过 5 次标记为失败人工介入。CREATE TABLE kb_sync_retry ( id bigint NOT NULL AUTO_INCREMENT, es_doc_id varchar(64) NOT NULL, retry_count int DEFAULT 0, max_retry int DEFAULT 5, status tinyint DEFAULT 0 COMMENT 0-待重试 1-成功 2-失败, last_error varchar(512) DEFAULT , created_at datetime DEFAULT CURRENT_TIMESTAMP, updated_at datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这个表很轻量但它保证了整个双写方案的最终一致性。ES 短暂宕机后恢复重试任务会自动把中间积压的索引任务处理掉不会丢数据。5. 检索实现让 MySQL 和 ES 协同工作5.1 查询串的改造检索流程从用户输入问题开始。问题不能直接拿去搜要先做两件事生成向量同时做关键词提取。public SearchResult search(Long kbId, String query, int topK) { // 1. 生成问题的向量 float[] queryVector embeddingService.generateEmbedding(query); // 2. 构建 ES 混合检索 SearchRequest request buildHybridSearchRequest(kbId, query, queryVector, topK); // 3. 执行查询 SearchResponse response esClient.search(request, RequestOptions.DEFAULT); // 4. 解析结果 return parseSearchResponse(response, topK); }5.2 混合检索的 DSL 构造混合检索是 FastGPT 这类系统效果好的关键。它不只用向量也不只用关键词而是两者结合再用 RRFReciprocal Rank Fusion融合排序。ES 的查询 DSL 长这样{ query: { bool: { filter: [ { term: { kb_id: 1024 } }, { term: { status: 1 } } ], should: [ { script_score: { query: { match_all: {} }, script: { source: cosineSimilarity(params.queryVector, content_vector) 1.0, params: { queryVector: [0.123, 0.456] } } } }, { match: { content: { query: FastGPT 知识库 实现, boost: 1.2 } } } ], minimum_should_match: 1 } }, size: 20, _source: [chunk_id, doc_id, content, chunk_index] }script_score负责向量相似度match负责关键词命中。两路结果各取前 20 条然后合并排序。5.3 排序策略RRF 融合公式RRF 的公式很简单score sum(1 / (k rank))k 默认取 60。每个文档在两路结果里都有一个名次名次越靠前贡献的分值越高。如果一个文档同时被两路检索命中它的融合分数会明显高于只被一路命中的文档这就是 RRF 的优势。我一开始用的是加权和向量得分乘 0.7 加全文得分乘 0.3但效果很不稳定因为两路得分的分布区间不同权重调参很痛苦。换成 RRF 后复杂度降低了效果也没变差。所以我的建议是优先用 RRF而不是调权重。5.4 召回后的 MySQL 补充查询ES 返回的是 chunk 级别的结果但前端展示时需要把文档名、知识库名这些元数据一并返回。这里有两种做法一种是 ES 写入时把元数据冗余进去查询直接返回另一种是拿到 chunk_id 列表后再查 MySQL。我倾向后者因为 MySQL 查这批数据非常快而且避免了 ES 索引里冗余大量不参与检索的字段让索引更瘦。public ListChunkResultVO enrichChunkInfo(ListString chunkIds) { ListLong ids chunkIds.stream().map(Long::valueOf).collect(Collectors.toList()); ListKbChunk chunks chunkMapper.selectBatchIds(ids); // 避免 N1 查询批量查出文档信息 ListLong docIds chunks.stream().map(KbChunk::getDocId).distinct().collect(Collectors.toList()); MapLong, KbDocument docMap documentMapper.selectBatchIds(docIds) .stream().collect(Collectors.toMap(KbDocument::getId, Function.identity())); return chunks.stream().map(chunk - { ChunkResultVO vo new ChunkResultVO(); vo.setChunkId(chunk.getId()); vo.setContent(chunk.getContent()); vo.setDocId(chunk.getDocId()); vo.setDocName(docMap.get(chunk.getDocId()).getDocName()); return vo; }).collect(Collectors.toList()); }这个小逻辑看起来简单但有个性能关键点不要在循环里单条查文档信息。20 条 chunk 就可能来自 20 个不同文档循环查询会变成 20 次数据库 round trip。改成先查 docIds再批量查一次完成。5.5 仅用 MySQL 实现简易组合检索如果没有 ES 条件纯 MySQL 也能实现一个简化版知识库方法就是用 LIKE 进行全文模糊匹配。虽然性能差、不支持相关性排序但对几百条数据的小知识库足够用。SELECT chunk_id, content, MATCH(content) AGAINST (关键词 IN NATURAL LANGUAGE MODE) AS score FROM kb_chunk WHERE kb_id 1024 AND status 1 AND (content LIKE %FastGPT% OR content LIKE %知识库%) ORDER BY score DESC LIMIT 20;这里如果用 FULLTEXT 索引效率会比 LIKE 好很多但中文分词支持有限需要引入 ngram 解析器。如果你只是临时应急、数据量小LIKE 也能将就如果要上线还是老老实实上 ES。6. 实操中的踩坑记录与关键优化6.1 分词不一致导致的召回失败最典型的坑是文档里写的是FastGPT用户搜的是fastgpt大小写不一致导致 match 查不到。我加了 lowercase 过滤器后解决了这个问题。但拼音过滤又引入了新问题中文同音字被错误匹配。比如付费和回复的拼音开头都是 fh可能导致搜索回复时匹配到付费。我的解决办法是把拼音过滤只用于索引分词查询时用 ik_smart 原词匹配。这样索引侧能覆盖英文和拼音首字母场景查询侧不会过度扩大召回范围。同时在业务层对搜索结果做一次二次过滤用编辑距离去除明显不相关的匹配。6.2 ES 批量写入时的内存问题ES 批量写入时如果每批设置的条数太多比如一次 5000 条客户端内存和 ES 端 bulk 队列都会被打满尤其向量字段很大时。我调参的经验是单批数据控制在 5MB 左右。假设每条记录包含一个 1536 维浮点向量约 6KB那一批 500~800 条就是安全区间。另外HTTP 连接池要设置合理的 maxConnTotal 和 maxConnPerRoute。我遇到过一次 ES 客户端连接被耗尽导致写入超时而业务侧以为 ES 挂了触发大量重试进一步加剧了问题。后来把连接池和 bulk 线程池隔离问题就消失了。6.3 向量维度不一致的报错ES 的 dense_vector 对维度要求非常严格一旦写入的向量维度和 mapping 里的 dims 不一致直接报错。我遇到过 embedding 模型切换后旧的向量是 768 维新的是 1536 维导致批量写入失败。排查了半天才发现是模型配置没同步。解决方案是在代码里加一层维度校验从 embedding 模型接口返回的向量先检查长度是否等于kb_knowledge_base.embedding_model对应的配置值不等就抛异常并告警而不是傻傻写入 ES 等着报错。6.4 分页深度与召回精度如果知识库文档量很大比如超过 10 万 chunkES 的from size深分页性能会急剧下降。我的知识库还在这个量级以下所以暂时用常规分页。如果你要支撑百万级建议用 search_after 游标分页或者只在第一次查询时获取 top 100不做深分页。召回的 topK 也不是越大越好。我实测过topK 从 5 提到 20回答效果有个明显提升但从 20 提到 50效果提升就很小了反而会让 prompt 变长增加 LLM 调用成本和响应延迟。最终我把默认值定为 10特殊情况才调大。6.5 常见问题速查表现象可能原因解决方案ES 写入报门槛维度错误embedding 模型切换后维度不一致检查向量维度重建该批量的索引搜索中文没结果没有安装 IK 分词插件安装 analysis-ik重建 content 字段的 analyzer搜索英文缩写无效索引侧没有 lowercase/pinyin在 analyzer 中加入 lowercase 和拼音过滤器MySQL 批量插入很慢没开 rewriteBatchedStatementsJDBC URL 加这个参数实测提速数倍文档状态一直待解析解析任务队列积压或崩溃检查 MQ 消费日志确认是否死信同一文档重复解析没做 content_hash 去重解析前查 hash相同则跳过7. 实践心得这套 MySQL ES 方案我从搭建到上线用了大概两周最深的感受是知识库系统的复杂度不在代码量而在数据一致性和检索效果调优。MySQL 管元数据、ES 管检索的双写架构看起来比单体表结构多了一层同步成本但换来的是两个方向都能做到极致结构化数据的事务能力、非结构化数据的检索能力。我最后再分享一个小技巧。如果你还在调试阶段建议先别急着接 LLM。把知识库的检索链路单独拉出来输入一个问题直接看 ES 返回的 top 10 文档片段是不是你想要的。这一步能帮你快速定位问题是出在分段、向量化还是排序上比整个链路跑完再猜要高效得多。等检索链路稳定了再接上 LLM 生成答案你会发现问题少一大半。
返回列表