这几年做知识库相关项目,绕不开一个名字:FastGPT。它最大的价值不只是开箱即用的问答界面,而是把一个知识库引擎最核心的环节——文档解析、分段、索引、召回——完整地拆开摆在你面前,让你知道一个能打的 RAG 系统究竟是怎么串起来的。
我这次要分享的,就是借鉴 FastGPT 的设计思路,用 MySQL 管结构化元数据、用 Elasticsearch 管向量和全文索引,从零手写一套轻量知识库的完整方案,包含可直接抄走的表结构和核心代码。这套东西我实际用在一个内部文档问答项目上,跑了小半年,中间踩了不少坑,也把一些关键参数调到了相对稳的状态。写出来,主要是给那些不想一上来就套全家桶,想搞清楚知识库内部到底发生了什么的朋友一个参考。
1. 整体设计思路:为什么是 MySQL + ES
1.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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分段向量索引关联表';我在设计时有个原则:能算出来的字段就不落库。比如 chunk 数、文档状态这类,能实时统计的尽量实时查,只有需要频繁查询且统计成本高的才加字段。chunk_count 在文档表里保留,是因为列表页需要展示,而且统计涉及跨表,实时 count 在大文档场景下会慢。
2.2 为什么用复合索引而不是外键
看上面表结构可以发现,我没有在 kb_chunk 上加外键约束。原因很现实:一是导入数据时外键检查影响写入速度;二是后续如果要分库分表,物理外键是个巨大障碍;三是业务层已经能保证数据一致性,外键的收益不大。
但索引必须建好。我的经验是:复合索引的字段顺序要以等值条件优先。比如idx_kb_id_status (kb_id, status),因为知识库列表页最常见的查询就是"查某知识库下所有已完成文档",kb_id 是等值条件,status 是范围条件,把等值放前面才能最大程度利用索引。
有个细节要注意:content_hash字段我用了 char(64),也就是固定长度的 SHA256 哈希值。这里不要用 varchar,char 类型存储固定长度字符串时检索效率更高,而且能避免尾部空格问题。每次文档重新解析时,先查一下 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 List<ChunkInfo> splitText(String rawText, int chunkSize, int overlap) { List<ChunkInfo> 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 上加上rewriteBatchedStatements=true,否则驱动不会真正走批量提交,而是逐条执行,性能等于没优化。
批量插入完成后,会拿到 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: cosine,ES 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这个表很轻量,但它保证了整个双写方案的最终一致性。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 这类系统效果好的关键。它不只用向量,也不只用关键词,而是两者结合,再用 RRF(Reciprocal 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 List<ChunkResultVO> enrichChunkInfo(List<String> chunkIds) { List<Long> ids = chunkIds.stream().map(Long::valueOf).collect(Collectors.toList()); List<KbChunk> chunks = chunkMapper.selectBatchIds(ids); // 避免 N+1 查询,批量查出文档信息 List<Long> docIds = chunks.stream().map(KbChunk::getDocId).distinct().collect(Collectors.toList()); Map<Long, 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 万 chunk,ES 的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 批量插入很慢 | 没开 rewriteBatchedStatements | JDBC URL 加这个参数,实测提速数倍 |
| 文档状态一直待解析 | 解析任务队列积压或崩溃 | 检查 MQ 消费日志,确认是否死信 |
| 同一文档重复解析 | 没做 content_hash 去重 | 解析前查 hash,相同则跳过 |
7. 实践心得
这套 MySQL + ES 方案我从搭建到上线用了大概两周,最深的感受是:知识库系统的复杂度不在代码量,而在数据一致性和检索效果调优。MySQL 管元数据、ES 管检索的双写架构,看起来比单体表结构多了一层同步成本,但换来的是两个方向都能做到极致:结构化数据的事务能力、非结构化数据的检索能力。
我最后再分享一个小技巧。如果你还在调试阶段,建议先别急着接 LLM。把知识库的检索链路单独拉出来,输入一个问题,直接看 ES 返回的 top 10 文档片段是不是你想要的。这一步能帮你快速定位问题是出在分段、向量化还是排序上,比整个链路跑完再猜要高效得多。等检索链路稳定了,再接上 LLM 生成答案,你会发现问题少一大半。