资讯动态

项目二 Day 3:补一个漏掉的字段,再按 800 字切分文本

发布时间:2026/10/24 6:44:33 来源:尧图企业网站定制
项目二 Day 3一个字段漏到要 ALTER 才想起来切分第一刀把「检查」劈成了两半Day2 我把上传接口跑通了文件用 UUID 改名丢进 uploadsdoc 表插一条记录status0。当时觉得这块挺完整。今天要写「解析」第一步就走不下去了。doc 表里只有name那是原始文件名sample.txt磁盘上那个文件叫2089d2e6a0f14c5c8d3b0e6f7a9c1d2e.txt。我拿着sample.txt去 uploads 里找找不到。这不是我编的教学例子。写 Day2 的时候stored_name对上传这件事毫无用处所以我压根没想过要存它。等到要读了才发现表里没有。写的时候用不上读的时候才想起来这种漏字段大概是工程里最常见的一种漏。一、补字段一条 ALTER我在 IDEA 里连撞两次SQL 本身只有一行ALTERTABLEdocADDCOLUMNstored_nameVARCHAR(64)NULLCOMMENT磁盘文件名AFTERname;我把它追加到docs/schema.sql末尾然后点了编辑器右上角那个绿色三角。弹出来的是没有可执行内容我以为是 SQL 写错了把这一行复制到一个新的查询窗口再点还是一样。折腾了一会儿才想明白问题不在 SQL.sql文件没绑定 Data Source那个绿色三角是 Run Configuration对 SQL 文件根本不生效。真正能跑的是把光标放进语句里按 CtrlEnter或者在 Database 面板里右键库 → New → Query Console 再粘贴。绑定数据源、选中语句、CtrlEnter这次终于执行成功。但 IDEA 的执行窗口没给我什么明显的反馈我以为没生效又点了一次这次是红的[42S21][1060] Duplicate column name stored_name我第一反应是「失败了刚才那次白点了」。跑去查库SHOWCOLUMNSFROMdocLIKEstored_name;一行。Typevarchar(64)NullYES位置在name后面。它早就成功了第二次执行才报的重复。这里有个我原来不知道的点MySQL 8.0 的ADD COLUMN没有IF NOT EXISTSMariaDB 有。所以这种迁移语句天生不可重复执行第二次必然报 1060。真实工程靠 Flyway / Liquibase 记版本号不靠人记自己执行过没有。字段为什么允许 NULL也顺手记一下Day2 那条旧记录本来就没有这个值设成 NOT NULL 会让 ALTER 在已有数据上直接失败。代价是旧记录解析不了所以我让它在解析时明确报「文档缺少 stored_name」而不是拼出个空路径去读文件。二、第一版切分我按 800 个字硬切我的第一版思路很简单每 800 个字符切一刀切完塞进 list// 第一版只按长度硬切publicstaticListStringsplit(Stringtext){ListStringpiecesnewArrayList();for(inti0;itext.length();i800){pieces.add(text.substring(i,Math.min(i800,text.length())));}returnpieces;}拿一份 5039 字的中文测试文本跑出来 7 块。看着挺对字数对得上块数也对得上。但这段代码有三个地方是错的。句子会在块边界被砍断。这个我知道所以我加了重叠窗口。加完再跑还是断了。测试文本里有一句「用于检查相邻两块重叠区域是否正好覆盖前一块的结尾部分」5039 字、800/100 的参数下第 700 个字正好落在块边界上块 1 从第 700 个字开始那个字是「查」它前面的「检」留在块 0 里。两块单独读一块结尾是「…用于检」一块开头是「查相邻两块…」。重叠 100 让它至少在某一块里是完整的但边界本身仍然和语义无关。substring是按 char 切的。Java 的 String 用 UTF-16 存一个 emoji 占两个 charsubstring可能切出半个字符写进chunk.content之后显示成?。中文常用字没事生僻字和 emoji 会中招。没有参数校验。我一开始把step size - overlap写成循环步长只要有人传overlap sizestep 就是 0 或负数for 循环里start永远不增直接死循环把内存打满。整条链路长这样右边是 doc.status 怎么走三、改完的代码切分器最终长这样packagecom.first.aikb.chunk;importjava.util.ArrayList;importjava.util.List;/** * 固定长度切分滑窗每块最多 size 个字符相邻块之间重叠 overlap 个字符。 */publicfinalclassFixedSizeChunker{privateFixedSizeChunker(){}publicstaticListStringsplit(Stringtext,intsize,intoverlap){if(size0){thrownewIllegalArgumentException(chunk 大小必须为正数size);}if(overlap0||overlapsize){thrownewIllegalArgumentException(overlap 必须 0 且小于 sizeoverlap);}ListStringpiecesnewArrayList();if(textnull||text.isBlank()){returnpieces;}// 统一换行符否则 \r\n 会白占一个字符位Stringnormalizedtext.replace(\r\n,\n).replace(\r,\n);// 按 code point 切避免把一个 emoji 从中间劈开int[]cpsnormalized.codePoints().toArray();inttotalcps.length;intstepsize-overlap;for(intstart0;starttotal;startstep){intendMath.min(startsize,total);StringpiecenewString(cps,start,end-start);if(!piece.isBlank()){pieces.add(piece);}if(endtotal){break;}}returnpieces;}}四个地方和第一版不一样改动在防什么overlap size抛异常step会变成 0 或负数死循环codePoints()替代substringemoji 被切成半个字符入库变?if (end total) break不加会多切出一个长度 ≤ overlap 的碎尾巴piece.isBlank()跳过纯空白块对检索零价值还白花一次 embedding 调用落库的部分我单独拆了一个类packagecom.first.aikb.chunk;importcom.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;importcom.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;importcom.first.aikb.entity.Chunk;importcom.first.aikb.entity.Doc;importcom.first.aikb.mapper.ChunkMapper;importcom.first.aikb.mapper.DocMapper;importlombok.RequiredArgsConstructor;importorg.springframework.stereotype.Component;importorg.springframework.transaction.annotation.Transactional;importjava.util.List;/** * 切分结果的落库器。 * 单独成类Transactional 走 Spring AOP 代理同类 this.xxx() 调用不经过代理事务不生效。 */ComponentRequiredArgsConstructorpublicclassChunkWriter{privatefinalChunkMapperchunkMapper;privatefinalDocMapperdocMapper;TransactionalpublicvoidreplaceAll(LongdocId,ListStringpieces){// 幂等重复解析先清旧块chunkMapper.delete(newLambdaQueryWrapperChunk().eq(Chunk::getDocId,docId));for(inti0;ipieces.size();i){Stringcontentpieces.get(i);ChunkchunknewChunk();chunk.setDocId(docId);chunk.setSeq(i);chunk.setContent(content);chunk.setCharCount(content.codePointCount(0,content.length()));chunkMapper.insert(chunk);}docMapper.update(null,newLambdaUpdateWrapperDoc().eq(Doc::getId,docId).set(Doc::getStatus,2).set(Doc::getChunkCount,pieces.size()));}}Service 里的 parse 只做编排Overridepublicintparse(LongdocId){DocdocdocMapper.selectById(docId);if(docnull){thrownewIllegalArgumentException(文档不存在docId);}if(doc.getStoredName()null||doc.getStoredName().isBlank()){thrownewIllegalStateException(文档缺少 stored_name无法定位磁盘文件Day2 上传的旧数据需要重新上传);}StringsuffixsuffixOf(doc.getName());DocumentParserparserparsers.stream().filter(p-p.supports(suffix)).findFirst().orElseThrow(()-newIllegalArgumentException(暂不支持解析该类型suffix));PathfileuploadDir().resolve(doc.getStoredName());if(!Files.exists(file)){thrownewIllegalStateException(磁盘文件不存在file);}updateStatus(docId,1);try{// 读文件 切分都在事务外可能耗时几百毫秒不该占着连接Stringtextparser.parse(file);ListStringpiecesFixedSizeChunker.split(text,CHUNK_SIZE,CHUNK_OVERLAP);if(pieces.isEmpty()){thrownewIllegalStateException(解析结果为空文件里没有可切分的正文);}chunkWriter.replaceAll(docId,pieces);returnpieces.size();}catch(IOExceptione){updateStatus(docId,3);thrownewIllegalStateException(读取文件失败e.getMessage(),e);}catch(RuntimeExceptione){updateStatus(docId,3);log.warn(解析失败 docId{}: {},docId,e.getMessage());throwe;}}四、几个我卡了一会儿才想通的点为什么事务不能写在 parse 上。我最初的想法很自然parse 里又删又插当然要加事务。加完会出两个问题。一是解析里有读文件和切分可能几百毫秒事务一开数据库连接就一占到底。二是更麻烦的那个失败时要写status3如果整个 parse 在一个事务里throw会把刚写的 3 一起回滚掉文档永远停在1解析中下次再点还被判「正在解析」卡死。所以 parse 不挂事务每次update独立提交事务只包replaceAll那三步。Transactional为什么必须换一个类写。它靠 Spring 生成的代理对象生效。同一个类里this.replaceAll(...)走的是原始对象代理插不进来注解贴上去等于没贴。这个坑不会报错你不去查库根本发现不了数据没回滚过。800 和 100 是怎么定的。800 字中文大约 500~600 token是 embedding 上下文和召回精度之间一个常见的折中。定太小一句话被拆散检索出来的块答不出完整意思定太大一块里混进好几个主题向量被平均掉召回也不准。100 是块大小的 12% 左右行业里常见的是 10%~20%。把这两件事画在一起更清楚下面那条报错是今天真正让我误判的地方五、怎么验证接口返回的块数不算验收真正要看的是库里的行。块和块是怎么挪的看这张图最快。下面那条红色的线就是第 700 个字先准备一份够长的文本我用了 60 段、共 5039 字每段开头写「第01段」「第02段」这样切点落在哪一眼能看出来。参数 800/100 下我这边的结果检查项期望值我跑出来的块数7×800 139 8 块8SUM(char_count)5039 7×100 57395739块 0 结尾 100 字 块 1 开头 100 字相等相等再解析一次chunk 行数不变不变SQL 如下改doc_id就行USEai_knowledge_base;-- ① docstatus 必须是 2chunk_count 等于接口返回的数字SELECTid,name,stored_name,chunk_count,statusFROMdocORDERBYidDESCLIMIT3;-- ② chunkseq 从 0 连续不跳号SELECTseq,char_count,LEFT(content,20)AS开头,RIGHT(content,20)AS结尾FROMchunkWHEREdoc_id1ORDERBYseq;-- ③ 重叠校验两列都应该是 1SELECT(SELECTRIGHT(content,100)FROMchunkWHEREdoc_id1ANDseq0)(SELECTLEFT(content,100)FROMchunkWHEREdoc_id1ANDseq1)AS块0块1重叠,(SELECTRIGHT(content,100)FROMchunkWHEREdoc_id1ANDseq1)(SELECTLEFT(content,100)FROMchunkWHEREdoc_id1ANDseq2)AS块1块2重叠;块数和你跑出来的不一样很正常字数不一样嘛。但恒等式必须成立SUM(char_count) 原文字数 (块数 - 1) × overlap。对不上就是切分或重叠算错了。四个错误场景场景期望在验证什么同一个 id 再 parse 一次块数不变chunk 行数不变幂等删旧块那段真的跑到了parse 不存在的 id999400 「文档不存在999」全局异常处理生效不再吐 Spring 默认错误页传个后缀是 .pdf 的假文件再 parse400 「暂不支持解析该类型pdf」解析器按 supports 挑实现传一个只有空格和换行的 txt 再 parse500 「解析结果为空」doc.status3失败状态能落库第 4 条最容易出错也最该测。它一口气验证两件事空正文没被当成 0 块静默通过以及 status3 没有被回滚掉。六、报错对照表报错真因怎么修没有可执行内容.sql文件没绑 Data Source点了绿色三角绑数据源光标放进语句按 CtrlEnter[42S21][1060] Duplicate column name stored_name这条 ALTER 之前已经执行成功SHOW COLUMNS查一下别急着重跑Unknown column stored_name in field listALTER 在别的库上执行了或者实体字段没加确认当前库是ai_knowledge_baseRefresh 表结构文档缺少 stored_name无法定位磁盘文件解析的是 Day2 上传的旧记录正常现象重新上传一次再解析新 idCannot invoke java.util.List.stream() because this.parsers is null字段没写finalLombok 构造函数没带上它private final ListDocumentParser parsers;Data too long for column contentCHUNK_SIZE太大超过 TEXT 的 65535 字节改回 800中文一个字 3 字节七、明天第二刀怎么切今天切的这一刀有个绕不过去的缺陷下刀的位置和语义没关系。5039 字那次「检查」被劈在两块之间不是巧合是必然因为第 700 个字和它所在句子的边界不可能刚好重合。重叠 100 只能让内容在两块里都出现不能让边界落回句号上。所以明天写第二种切分先按空行切段落段落超长时才退回今天的固定长度。同一份文件跑两遍比块数和每块开头结尾的完整性把结论写进 README。面试里「两种切分策略怎么选」这道题我手上就有自己跑出来的数据了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑