搜“干净”它说不知道?我用 Spring AI Alibaba 手写后端,TRAE Work 补齐了全栈

搜"干净"它说不知道?我用 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。下面按这个顺序展开。


一、后端:我自己写的功能

功能设计比较简单,就两块:

一、导入功能

  1. 将 markdown 文件放在指定目录(deploy 目录),调用 deploy 接口导入;
  2. 将文件图片的 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,计划文档里给的理由我认可:

  1. 我的需求明确是"回退模式"------先向量搜索,不行再按中文字词搜索,Lucene 独立索引天然适配;
  2. 不依赖 Milvus 版本,当前环境无需升级就能用;
  3. Lucene 本身就是 Elasticsearch 的底层引擎,是"像 ES 一样搜索"的最佳实现;
  4. 中文分词器可以自由选择,最后用了 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 是一个非常好的程序员助手,前提是你得知道让它做什么、怎么验收。

相关推荐
江昪2 小时前
这可能是 AdventureX 上最好玩儿的硬件了
ai编程
欧达克2 小时前
vibe coding:从本地开发到上线 app store
ai编程
极客小俊3 小时前
别再给大模型充钱!Agnes AI 永久免费全模态 API,代码 / 绘图 /短剧Token不限量调用
人工智能·openai·ai编程
计算机魔术师3 小时前
Midjourney V8.2 发布:美学与个性化全面升级,低质图率显著下降
人工智能·ai编程
webxue3 小时前
AI我们好像都用错了
程序员·ai编程
京东云开发者3 小时前
ICML 2026|NaviAgent:面向 Oxygen 智能体的可扩展工具编排
agent·ai编程
东小西3 小时前
第16篇:《把八股文喂给了AI:我搭建了专属 AI 面试知识库》
openai·ai编程