搜"干净"它说不知道?我用 Spring AI Alibaba 手写后端,TRAE Work 补齐了全栈
本文参加「TRAE Work 实战帮」征文。内容全部来自真实开发过程:后端核心功能是我自己手写的,前端页面、中文全文搜索、模型动态切换等改进,是交给 TRAE Work 一步步完成的。文中所有截图均为实际操作记录。
写在前面
DocSolo(中文名"私文简搜 ")是我一直在迭代的一个轻量化文档知识库系统,我在掘金用十几篇文章完整记录了它的开发过程:从 Spring Boot3 脚手架、Sa-Token 全局认证与数据权限,到 Markdown 文档解析、ElasticSearch 全文搜索,再到离线轻量化部署和 Vue3 前端。感兴趣可以翻我的掘金主页,系列文章都在。
之前的 DocSolo 能"搜",但不能"聊"。这次重构的目标很明确:既然 Milvus 能保存文本向量,又能以聊天方式和 AI 对话,那就结合 Spring AI Alibaba,把 DocSolo 升级成一个"能导入、能聊天"的 RAG 知识库系统------这是 DocSolo 系列的延续。
先交代一下技术栈和环境,方便大家复现:
| 项 | 说明 |
|---|---|
| 后端框架 | Spring AI Alibaba 1.1.2.0 |
| 向量数据库 | Milvus 3.0(192.168.55.130:19530) |
| 大模型 | 本地 Ollama(qwen3.5:4b + nomic-embed-text)/ 阿里百炼 DashScope(qwen 系列),两者可动态切换 |
| 前端 | Vue3 + Vite(TRAE Work 生成) |
| 中文分词 | HanLP(配合 Lucene 做全文搜索) |
分工上我的原则是:核心后端逻辑自己写,保证可控;重复性高、跨领域的活儿(前端、全文搜索方案)交给 TRAE Work。下面按这个顺序展开。
一、后端:我自己写的功能
功能设计比较简单,就两块:
一、导入功能
- 将 markdown 文件放在指定目录(deploy 目录),调用 deploy 接口导入;
- 将文件图片的 zip 压缩包上传,调用 upload 接口导入。
二、聊天功能
通过文本向量查询知识库内容,再以聊天方式流式返回答案。
1.1 导入功能
ImportController 提供两个接口:/knowledge/deploy 扫描部署目录导入,/knowledge/upload 接收 ZIP 上传导入。两个接口都是异步执行、立即返回------因为向量化过程要调用 embedding 模型,比较耗时,不能让用户干等着。
核心实现在 ImportServiceImpl,这里只列我认为最有价值的两段。
第一段:分批写入 Milvus,并对慢批次给出提示。 本地 Ollama 推理很慢,不分批写入的话一旦卡住,连进度都看不到:
java
private void doUploadMilvus(File file) {
Resource resource = new FileSystemResource(file);
TikaDocumentReader documentReader = new TikaDocumentReader(resource);
List<Document> documents = documentReader.get();
MarkdownHeadingTextSplitter splitter = new MarkdownHeadingTextSplitter();
List<Document> split = splitter.split(documents);
log.info("按markdown分解...." + split.size());
for (int i = 0; i < split.size(); i += batchSize) {
int batchNum = i / batchSize + 1;
int end = Math.min(i + batchSize, split.size());
long batchStart = System.currentTimeMillis();
vectorStore.add(split.subList(i, end));
long batchCost = System.currentTimeMillis() - batchStart;
log.info(" 批次 {}完成, 耗时={}ms", batchNum, batchCost);
// 如果单批耗时超过 30 秒,输出提示
if (batchCost > 30000) {
log.warn(" 批次 {} 耗时较长({}ms),Ollama 推理较慢,请耐心等待", batchNum, batchCost);
}
}
}
第二段:ZIP 解压时的 Zip Slip 防护。 上传解压是最容易被路径穿越攻击的地方,解压前必须校验每个条目的真实路径是否还在目标目录内:
java
File destFile = new File(extractDir, entry.getName());
// 防止 Zip Slip 路径穿越攻击
if (!destFile.getCanonicalPath().startsWith(canonicalBase)) {
log.warn("跳过可疑的 ZIP 条目: {}", entry.getName());
continue;
}
导入完成后,文件统一移动到 data 目录归档(move2Data),deploy 目录保持干净,随时可以再次导入。
1.2 自定义 Markdown 标题分割器
Spring AI 自带的 TextSplitter 是按长度切块的,会把一个标题下的内容拦腰截断。知识库场景下,按标题切分 才符合语义:每个分片就是一个标题下的完整内容。所以我继承了 TextSplitter,自己写了一个按标题切分的分割器:
java
public class MarkdownHeadingTextSplitter extends TextSplitter {
// 匹配 # 开头标题:# 标题、## 标题,捕获标题内容
private static final Pattern HEADING_PATTERN =
Pattern.compile("^(#{1,6})\\s+(.*)$", Pattern.MULTILINE);
@Override
protected List<String> splitText(String text) {
List<String> chunks = new ArrayList<>();
if (text == null || text.isBlank()) {
return chunks;
}
Matcher matcher = HEADING_PATTERN.matcher(text);
int lastPos = 0;
String lastHeading = null;
while (matcher.find()) {
int currentStart = matcher.start();
// 上一段内容存在,则收集
if (lastHeading != null && currentStart > lastPos) {
String segment = text.substring(lastPos, currentStart).trim();
if (!segment.isBlank()) {
chunks.add(segment);
}
}
lastPos = currentStart;
lastHeading = matcher.group(2);
}
// 处理最后一段文本
String lastSegment = text.substring(lastPos).trim();
if (!lastSegment.isBlank()) {
chunks.add(lastSegment);
}
return chunks;
}
}
1.3 聊天功能:RAG 检索 + 流式输出
聊天走的是 Spring AI 的标准 RAG 管线:RetrievalAugmentationAdvisor 负责检索增强,检索前先用 RewriteQueryTransformer 把用户问题重写一遍(口语化的问题重写后召回率明显更好),检索结果交给 ContextualQueryAugmenter 拼进上下文,最后流式输出:
java
RewriteQueryTransformer queryTransformer = RewriteQueryTransformer.builder()
.chatClientBuilder(builder)
.build();
RetrievalAugmentationAdvisor retrievalAugmentationAdvisor = RetrievalAugmentationAdvisor.builder()
.queryTransformers(queryTransformer)
// 指定你使用哪一种文档检索器
.documentRetriever(VectorStoreDocumentRetriever
.builder()
.vectorStore(vectorStore)
.build())
.queryAugmenter(ContextualQueryAugmenter.builder()
.allowEmptyContext(true)
.build())
.build();
Flux<ChatEventDto> chatEventDtoFlux = chatClient.prompt()
.advisors(retrievalAugmentationAdvisor)
.system("""
# 角色
你是一个知识库问答助手。你必须严格根据提供的知识库内容来回答用户问题。用户将以自然语言提出需求。
你有能力访问矢量数据库内容,并组合回答用户问题。
# 要求
1.你能认真阅读知识库中内容、代码以及图片,给出的答案要求能具体,可以给出示例代码段。
2.可以结合多篇知识库内容进行回答。
""")
.user(question)
.stream()
.chatResponse()
.map(chatResponse -> {
// 大模型生成的内容
var text = chatResponse.getResult().getOutput().getText();
ChatEventDto dto = new ChatEventDto();
dto.setEventData(text);
dto.setEventType(ChatEventTypeEnum.DATA.getValue());
return dto;
})
...
接口用 SSE 流式返回,事件分三种类型:1001 数据事件(流式文本)、1002 停止事件(携带来源文档列表)、1003 参数事件。
这里还有一个我自己加的小设计:回答结束后,额外做一次轻量检索,只取 source 元数据里的文件名,随停止事件一起返回。这样前端能展示"回答来自哪些文档",用户点击就能看原文。真正的 RAG 检索由 advisor 内部完成,这次检索只为提取来源,职责是分开的。
1.4 后端测试
接口联调通过,问答基本可用:
后端功能到此完成,但已经埋下了一个小问题------直接搜"干净"这种短词,返回不了内容。这个坑后面会专门讲,它直接引出了本次最大的一次改进。
二、TRAE Work 实战:从前端到重大改进
后端有了,缺前端、缺搜索优化。这部分全部交给 TRAE Work,下面是完整的实操过程。
2.1 一句话生成 Vue3 前端
我给 TRAE Work 的提示词很直接:
python
在当前目录生成web界面,主要是显示项目ivy-service-knowledge-bootstrap中的功能:
上传文档,有两种方式,1、上传zip 2、deploy整个目录,
还有就是聊天,并记录历史。
分析接口。前端使用Vue3
TRAE Work 先分析了我的后端接口,然后给出了执行计划:
按步骤执行:

自动运行的结果:

我觉得这才是 AI 编程工具的精华所在:模拟操作、代替人进行操作,而且过程可观测、有反馈。 它不是把代码一扔就完事,而是真的去跑、去验证:
顺手让它把接口分析和功能说明沉淀成文档:
将接口分析结果、功能说明等编写到readme.md中

2.2 前端联调测试
页面起来后直接测上传接口:
接口调用成功。因为这个接口本来就是异步执行的,所以前端只反馈"已提交"。这里其实还能优化------比如前端轮询任务状态、后端提供状态查询接口------我记下来了,后面可以接着让 TRAE Work 做。当前没有查询功能,只能先看日志确认:
再测聊天:
如何快速部署多台虚拟机

2.3 第一轮问题修改:把问题直接喂给 TRAE Work
用了一圈,我整理出三个问题,原样喂给 TRAE Work:
bash
现在的问题:
1、如果搜索到内容,有图片,要优先显示,这个可能要改接口
2、来源文档,要能正常显示,图片也要正常显示,后端没有配置data网络访问
3、接口不要搞那么多,添加/api,只映射一个
TRAE Work 逐条处理:改接口返回图片信息、后端增加 data 目录的静态资源访问、前端代理统一收敛到 /api:

2.4 中途遇到一个编译 bug
过程中报了一个编译错误:
css
java: 找不到符号
符号: 方法 forbidden()
位置: 类 org.springframework.http.ResponseEntity
把报错直接丢给 TRAE Work,它定位到是 ResponseEntity 没有 forbidden() 静态方法(应该用 status(HttpStatus.FORBIDDEN)),直接修复。这类错误处理起来很快,把完整报错贴给它比用嘴描述高效得多。
2.5 体验优化:图片预览做成对话框
继续提需求:
bash
1、相关图片放在来源下面
2、查看来源时的图片要能显示,并且不要跳到新页面做成对话框吧。图片如果是http这种就不替换,如果是相对路径,则替换成/api/...
改完接着聊天验证,多轮对话正常:

2.6 重大改进:搜"干净",它说不知道
测试时我搜了一个词:"干净",结果模型说不知道。
可我明明有一篇文档,里面就有"搭建干净、可重建的本地开发环境"这句话------"干净"两个字就在知识库里!
这就是纯向量检索的短板:短词、字面匹配的场景,语义向量的召回不如传统全文搜索。要知道,重构前的 DocSolo 用的就是 ElasticSearch 全文搜索,中文按词检索是什么体验我是有参照的------这次换成 Milvus 向量检索,等于把这个能力弄丢了,必须找回来。
这是一个重大需求,我没有让 TRAE Work 直接动手,而是明确要求先做计划:
搜索干净,说我不知道。明明搭建干净、可重建的本地开发环境中就有干净这两个字,这是大一个问题,
没法像elasticsearch那样拿中文来搜索的功能。我是希望能像elasticSearch那样,按中文词来搜索。
新的重大需求
1、导入时,能按中文字词,像elasticsearch一样能全文搜索
2、在聊天时,如果能按原先的方式就按原先的方式搜索,不行时才按中文字词进行搜索,内容放入llm,再返回。
这是重大改进,先做计划,并形成计划文档,按版本保存在doc中
TRAE Work 开始分析并制定计划:
它生成了《v2.0 中文全文搜索能力增强计划》文档,按版本保存在 doc/ 目录。完整计划很长(全部贴出来就是另一篇文章了),核心是两个方案的对比:
- 方案 A:Milvus 2.5+ 原生混合检索,内置 jieba 分词,所有检索在服务端完成,但要求 Milvus 版本支持;
- 方案 B:Lucene 本地索引 + Milvus 向量检索,双索引,向量优先、关键词兜底。
最终我选了方案 B,计划文档里给的理由我认可:
- 我的需求明确是"回退模式"------先向量搜索,不行再按中文字词搜索,Lucene 独立索引天然适配;
- 不依赖 Milvus 版本,当前环境无需升级就能用;
- Lucene 本身就是 Elasticsearch 的底层引擎,是"像 ES 一样搜索"的最佳实现;
- 中文分词器可以自由选择,最后用了 HanLP portable 版,分词效果可控。

2.7 按方案 B 实施
一句话下达实施指令:
css
按方案B:Lucene 本地索引 + Milvus 向量检索 进行编写
TRAE Work 按计划落地:新增 FullTextIndexService / FullTextSearchService 及 Lucene 实现,写了一个 HanLP 中文分词的 Lucene Analyzer 适配器 (HanLPAnalyzer)解决中文切词问题,并改造了导入流程(导入时同步建 Lucene 索引)和聊天流程(向量检索优先,召回不行时降级到中文字词全文搜索,结果再交给 LLM)。
因为索引结构变了,已有数据需要重新导入:
再测"干净":
成功召回!到此,中文全文搜索这个重大改进完成,基本功能全部齐活。
2.8 收尾:ollama / dashscope 模型切换
最后一个需求:本地 Ollama 免费但慢,百炼 DashScope 快但要联网,我想能随时切换:
功能完成
现在增加ollama dashscope切换功能

不加一个切换配置吗
TRAE Work 一度给做成了前端切换开关,我纠正了一下:
这个前端切换就不用了,后端切换就好了
最终实现为后端配置 + API 动态切换:default-provider 指定启动默认提供方,switchable: true 允许运行时通过接口切换,ChatClientProvider 统一管理两套 ChatClient。
三、实战经验总结
这次重构,后端核心是我手写的,前端、全文搜索、模型切换是 TRAE Work 完成的。沉淀几条可复用的经验:
1. 重大需求,先计划再编码。 中文全文搜索这种改动,我没有让它直接写代码,而是要求"先做计划,形成计划文档,按版本保存在 doc 中"。有了《v2.0 中文全文搜索能力增强计划》,方案对比、取舍都有据可查,实施时也不容易跑偏。计划文档本身就是项目资产。
2. 给足规范和上下文,AI 才不容易幻觉。 TRAE Work 在我的后端项目里改代码,因为有完整的工程结构、现有代码风格做参照,改出来的代码质量是稳定的。反面教材是我之前用 opencode 写一个 Python 项目:同样的 bug,一重写就出现,连着出了五六次------那就是没有规范约束导致的幻觉。所以你给 AI 的工程基础越扎实,它越可靠。
3. 问题原样喂,报错完整贴。 "现在的问题:1、... 2、... 3、..."这种列清单式的反馈,TRAE Work 能逐条消化;编译报错直接整段贴过去,比用自然语言描述快得多。
4. 让 AI 顺手沉淀文档。 接口分析、功能说明让它写进 README,计划让它写进 doc,这些文档后面既是维护依据,也是下一次提需求时的上下文。
5. 说点真实的不足:慢。 可能是因为免费额度的原因,整个过程中感觉 TRAE Work 偏慢,复杂任务等待时间比较长。这个希望后续能解决,也算一点真实的使用反馈。
小结
这次 DocSolo 重构让我确认了一件事:AI 编程工具最好的用法不是全盘交给它,而是分工------核心逻辑自己把控,跨领域、重复性强的工作交给 TRAE Work。它模拟操作、自动运行、可观测反馈的工作方式,确实把"一个人 = 一个团队"这件事往前推了一步。
TRAE Work 是一个非常好的程序员助手,前提是你得知道让它做什么、怎么验收。
