做Java方向AI应用开发,最容易卡住的地方不是你调不通API,而是API调通之后不知道下一步该干什么。我前三天基本就处于这种状态:依赖排好了,模型接口也能正常返回内容了,但脑子里只有“我会调接口了”这个念头,离“会做AI应用”还差着十万八千里。于是第四天我给自己定了一个任务——不学新东西,就用手头已经会的东西,做一个完整的小应用,把链路真正跑起来。最后我做出来的是一个本地知识问答Demo:把几份内部文档喂进去,它能从文档里找到答案并给出回答。这篇记录的就是第四天的完整过程,包括链路怎么搭、代码怎么写、以及我踩过的所有坑。
1. 第四天为什么挑“本地知识问答”当第一个完整Demo
前三天我做的事情很简单:第一天把开发环境跑起来,搞清楚Java生态里调用大模型的基本姿势;第二天试了不同类型的大模型接口,搞明白了结构化输出和流式返回的区别;第三天研究了一下Prompt的基本套路,尝试让模型按固定格式返回结果。三天下来知识是散的,每个环节单独拎出来都懂一点,但凑不出一个“应用”。
第四天我决定做一个完整的RAG(检索增强生成)知识问答流程。为什么挑这个?因为它是目前AI应用开发里最典型、也最能跑通全链路的一个场景:你有几份内部文档,希望AI能基于这些文档回答你的问题,而不是依赖它脑子里那点通用知识。这个需求在现实里非常常见:产品说明书问答、企业内部制度问答、个人笔记整理,本质都是同一套逻辑。对学习者来说,把它做出来,相当于把数据加载、文本切分、向量化、检索、Prompt组装、模型调用这六件事全部串了一遍,而且每一步都能看到中间结果,非常适合验证自己的理解。
更现实的原因是:这个Demo对算力要求极低,不需要微调模型,只需要调用现成的API;数据准备也简单,几乎人人都能凑出几份文档。你不需要有大GPU,也不需要懂深度学习原理,只要会Java基础,能写Spring Boot工程,就能跟着做下来。对Java开发者来说,这是切入AI应用开发成本最低的路径,没有之一。
我当时给自己列的第四天目标只有三条:第一,做一个命令行能跑通的问答脚本,能从文档里检索并回答;第二,把检索到的文本片段打印出来,确认“答案”确实有依据;第三,把整个过程用代码注释和朋友能看懂的文档记录下来。后来实际做完,又额外加了一条:把过程中遇到的所有问题记下来,因为我知道这些坑以后还会再遇到。
2. 一条链打通的五个环节:加载、切分、向量化、检索、再生成
在动手写代码之前,我花了大概四十分钟把整个链路的原理理了一遍。后来证明这是最值回票价的一件事——如果不懂原理,后面调参和排错会完全无从下手。
RAG这个词看起来高大上,实际上就是“先查资料,再让AI写回答”。为什么要插这么一步?因为通用大模型的训练数据是有截止时间的,它不知道你内部的资料内容;更重要的是,它可能不懂你的业务细节。直接问它“我们团队的报销流程是什么”,它只能给一个通用回答,甚至可能编造一个。而RAG的思路是:你先把问题拿过来,去你的文档库里做一次搜索,找到最相关的几个片段,然后把这些片段和用户问题一起丢给大模型,让大模型“基于片段回答问题”。这样一来,模型不再凭记忆硬答,而是有了参考答案,答错和编造的概率会大幅下降。
整个链路拆开来看,是五个环节:
第一个环节是文档加载。你要把PDF、Word、TXT、Markdown这些不同格式的文件,统一读成纯文本。这一步的难点在于格式解析。PDF还好,有现成的解析库;Word如果带复杂表格就很折腾。Demo阶段我直接用纯文本和Markdown,把不确定性降到最低。
第二个环节是文本切分。这一步的原理一句话就能说清:模型有上下文长度限制,你不可能把一整本书塞进一次请求里;而且从检索角度讲,被检索的“单元”越小越精准。所以需要把长文本切成一段一段的“chunk”。切法有讲究,按固定字符数硬切,容易把完整语义切断;最好按段落、标题、句子边界来切。我照社区里的经验设了500个字一段,相邻段落之间保留50个字的overlap重叠,防止关键信息恰好被腰斩在上一段结尾。
第三个环节是向量化,也就是Embedding。这一步可以理解为“把一段文字变成一个多维坐标”。用生活化的类比就是:每个句子都能在语义空间里找到一个位置,“苹果”和“水果”靠得近,“苹果”和“银行流水”离得远。Embedding模型就是把文字映射成这个坐标的函数。这个环节直接决定检索质量——如果向量化得不好,后面搜出来的内容大概率是垃圾进、垃圾出。
第四个环节是向量检索。做法很简单:把用户的问题也做一次向量化,得到一个坐标,然后去向量库里面找和它距离最近的几个文本片段。这里的“距离”常用余弦相似度,值越大表示语义越接近。这一步是RAG和传统关键词搜索最大的不同:传统搜索是“字面匹配”,你搜“怎么申请报销”,必须文档里也有“报销”这个词;向量检索是“语义匹配”,你问“我要报销差旅费该走什么流程”,它可能能找出“员工出差费用报销流程”这一句,哪怕字面上几乎对不上。这一点非常关键,值得写进你自己的理解里。
第五个环节是再生成。把检索出来的TopN个片段,拼到一个Prompt模板里,告诉大模型:“请根据下面提供的参考资料回答问题,如果资料里没有你要找的答案,请说不知道。”然后调用大模型接口,得到最终回答。说白了,大模型在这里的角色是一个“按材料作答的答题员”,而不是一个“张口就来的背诵员”。
这五个环节串起来,就形成了一条完整的数据流。我第四天的大部分时间都花在把这条数据流从理论变成代码上。
3. 动手写:从Maven工程到能跑通的问答Demo
我用了Spring Boot作为基础框架,理由只有一个:Java生态里做应用,它最省事。模型调用这块,我用了一个支持OpenAI兼容协议的本地模拟网关,API地址指向我本机开的一个服务,这样不依赖任何具体的大模型厂商,你换成任何一个提供兼容接口的服务商都行。嵌入向量这里我用了一个本地轻量级Embedding模型,通过HTTP接口调用,避免引入太重的Python依赖。
3.1 工程初始化和依赖准备
我建议你新建一个干净的Maven工程,JDK用17或21都行,我用的是17。pom.xml里核心依赖大概是这样:
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- 我用的AI框架starter,具体坐标以你选择的开源库为准 --> <dependency> <groupId>org.example</groupId> <artifactId>ai-framework-starter</artifactId> <version>0.1.0</version> </dependency> </dependencies>一开始我还加了PDF解析库,后来发现Demo阶段用不上,果断删了。这里分享一个我的判断标准:新项目起步时,依赖能少则少,每多一个依赖就多一个潜在的不兼容点。先把主流程跑通,再按需引入格式解析,否则你会被一堆莫名其妙的依赖冲突干扰。
3.2 配置文件里最容易忽略的几项
application.yml里我配了模型网关的地址、API Key、模型名称和Embedding接口地址:
ai: model: base-url: http://127.0.0.1:8088 api-key: sk-local-demo chat: model: local-chat-model embed: model: local-embed-model这里三个容易踩坑的点提前说:第一,base-url不要带/v1或/api这些路径,框架会自动拼接,带上了地址就重复了;第二,API Key如果本地测试,随便填一个非空字符串就行,但要保证请求头里有这个字段;第三,Embedding模型和对话模型很可能是两个不同的地址,我一开始就犯了这个错,把两个地址配成一个,结果对话正常、向量化全部失败。
3.3 数据准备:从文档到文本片段
我在项目根目录下建了一个data/目录,放了三份Markdown文档,内容是模拟的团队内部制度:考勤制度、报销流程、设备申请。加载逻辑很简单,遍历目录,把.md和.txt文件读成字符串。
public List<Document> loadDocuments(Path dir) throws IOException { List<Document> docs = new ArrayList<>(); try (Stream<Path> paths = Files.list(dir)) { List<Path> files = paths .filter(p -> p.toString().endsWith(".md") || p.toString().endsWith(".txt")) .toList(); for (Path file : files) { String content = Files.readString(file, StandardCharsets.UTF_8); docs.add(new Document(file.getFileName().toString(), content)); } } return docs; }3.4 文本切分:先按段落再补长度
我的切分逻辑很简单:先按Markdown的二级标题和换行拆出大段,然后每段再按字符数切成不超过500字的小块,最后相邻块之间保留50字重叠。
public List<String> split(String text, int chunkSize, int overlap) { List<String> result = new ArrayList<>(); String norm = text.replaceAll("\r\n", "\n").trim(); String[] sections = norm.split("\n(?=#{1,6} )"); for (String section : sections) { int start = 0; while (start < section.length()) { int end = Math.min(start + chunkSize, section.length()); if (end < section.length()) { int lastNewline = section.lastIndexOf('\n', end); if (lastNewline > start) { end = lastNewline; } } result.add(section.substring(start, end)); start = Math.max(end - overlap, start + 1); } } return result; }为什么要先按标题再按字符切?因为如果直接按500字硬砍,一个标题下面可能只切出半句话,检索到的片段要么缺上下文,要么把二级标题孤立出去了。我实测过,硬切的片段检索出来的完整度明显不如按标题切的,这在后面的调优记录里也能看到。
3.5 向量化与本地向量存储
为了不引入额外的数据库依赖,我用一个内存Map做向量存储。每个片段存两样东西:原始文本和它的float数组向量。
@Component public class SimpleVectorStore { private final List<String> chunks = new ArrayList<>(); private final List<float[]> vectors = new ArrayList<>(); public void addChunk(String text, float[] vector) { chunks.add(text); vectors.add(vector); } public List<ScoredChunk> search(float[] queryVector, int topK) { List<ScoredChunk> results = new ArrayList<>(); for (int i = 0; i < vectors.size(); i++) { double score = cosineSimilarity(queryVector, vectors.get(i)); results.add(new ScoredChunk(chunks.get(i), score)); } results.sort((a, b) -> Double.compare(b.score(), a.score())); return results.subList(0, Math.min(topK, results.size())); } private double cosineSimilarity(float[] a, float[] b) { double dot = 0, normA = 0, normB = 0; for (int i = 0; i < a.length; i++) { dot += a[i] * b[i]; normA += a[i] * a[i]; normB += b[i] * b[i]; } return dot / (Math.sqrt(normA) * Math.sqrt(normB)); } }这里有个点必须提醒:你的Embedding模型输出的向量维度要和查询时保持一致。但这不是你手动控制的,而是Embedding服务端决定的。你只需要保证切分和检索用的是同一个Embedding模型,否则维度对不上,代码直接报错。
3.6 组装Prompt并调用大模型
检索到TopK片段后,最关键的一步是Prompt组装。我用的模板是这样:
你是团队内部知识助手,请根据以下参考资料回答用户问题。 如果资料中没有相关信息,请直接回答“抱歉,我没有在内部资料中找到相关内容”,不要编造。 参考资料: --- {context} --- 用户问题:{question}这里{context}是一个字符串,把TopK个片段拼在里面。实测下来,拼的时候最好在每个片段前面加一个序号,比如[1]、[2],这样模型回答时如果引用了某个片段,你能从序号定位到原文,对排查很有帮助。
调用部分用框架提供的ChatClient,传模型名和消息。第一次跑通的时候说实话挺激动的:我输入“出差回来怎么报销?”它回答“根据内部资料,出差归来应在三天内填写报销单,并附上发票和行程单……”同时打印出来的检索片段里正好有“出差”相关的段落。那一刻我意识到,RAG这条路是通着的。
4. 今天踩过的五个坑,以及我是怎么一步步定位的
这部分是第四天最值钱的内容。很多问题查文档是查不到的,得实际跑一遍才能遇到。而且解决问题的过程比结果重要得多,我尽量把排查思路还原出来。
4.1 坑一:Embedding服务一直连不上,对话API却正常
现象是启动时报连接超时,但对话接口完全正常。排查链路:第一步我以为是服务没起,检查端口,发现通了;第二步怀疑是地址拼错,把配置里的base-url加打印日志输出,发现框架在Embedding接口地址后面自动多拼了一个/embedding路径,和我本地服务实际监听的路由对不上;第三步我找到本地服务的健康检查接口,手动用curl测了一遍,确认问题出在路径拼接上。最后我把Embedding地址单独配置成http://127.0.0.1:8088/embed,匹配了实际路由才解决。
这个坑让我明白:基础框架集成的接口路径是有约定的,对接本地服务时,最好先看框架源码里拼接URL的代码,或者用抓包工具看一眼实际请求的完整地址,别凭猜。
4.2 坑二:切分太机械,把表格和代码块切得稀碎
我第一次对文档做切分时,没有区分内容类型,结果一份Markdown里的代码示例被拦腰切断,一份带表格的文档里表格头和表体也被拆开了。现象是:检索到的片段经常是“前面半行代码,后面半行代码”,回答完全没有参考价值。
排查流程是先看切分结果日志,打印每个chunk的文本内容,发现有人为硬断的痕迹;然后我总结出规律:切分的粒度要跟着内容结构走,不能光按字符数。解决办法是:遇到代码块,如果它不长就直接整块保留,不切;遇到表格,以行为单位保留完整表头加若干行,不跨行切。虽然代码量多了,但检索质量明显提升。
4.3 坑三:没有上下文数量上限,一次把所有chunk都塞进Prompt
这个问题出现在我调试Prompt模板的时候。最初我图省事,把向量库里的所有片段拼进{context},第一次问答成功,第二次明显慢,第三次直接报“请求超长”。排查思路:我先去看模型服务的日志,发现是输出侧拒绝了请求,提示内容过长;然后我意识到向量存储里的文本总量已经超过模型上下文窗口;最后我把检索结果的TopK限制成5,并给每个片段设置了最大长度。从这以后,每次请求的token量变成了一个可预估的数,速度稳定了,费用也稳定了。
这里我学到一条铁律:RAG里的“检索”不是为了秀技术,而是为了解决“资料太多,模型装不下”这个现实约束。如果检索结果超过上下文窗口,RAG就失去了意义。
4.4 坑四:中文乱码,问答结果里出现一堆乱符号
现象很直接:控制台打印的中文变成乱码。排查链路:我先以为是数据文件编码问题,检查发现Markdown文件是UTF-8;又以为是Maven打包导致编码错乱,检查pom.xml里没设置project.build.sourceEncoding;最后逐一排查后发现,控制台输出流在Windows下默认用的GBK,而我打印的是UTF-8字符串,两边对不上。解决办法是在pom.xml加编码属性,同时给控制台配置-Dfile.encoding=UTF-8启动参数。
这个坑可能有人觉得不算技术问题,但在中文AI应用里非常普遍。建议你从一开始就在工程配置里把UTF-8固定下来,别等出现乱码再找原因。
4.5 坑五:检索结果为空,但向量库明明有数据
这个坑排查了很久。现象很诡异:启动时加载文档、向量化、添加进存储都没有报错,但输入问题后检索结果永远是空数组。排查链路:我先在search()方法里加了日志,打印向量库大小和查询向量的维度,发现一个问题——查询向量的维度比存储向量的维度大了一截。再往源头查:加载文档时调用的Embedding模型是本地一个轻量模型,而查询时调用的Embedding服务是另一个模型。两个模型输出维度不同,导致余弦相似度计算的结果全部是极小值,排序后取TopK自然为空。
解决办法是把加载和查询的Embedding地址统一指向同一个模型服务。事后我想,如果当时没打印维度,这个bug可能得花一天才能定位。所以想送大家一句:排查问题的时候,先把“数据长什么样”印出来,再去猜“逻辑哪里不对”。
5. 参数怎么调、效果怎么看、下一步往哪走
一个Demo跑通只是开始,要让它“好用”,就得动手调参数。第四天下午我做了一组很简单的对比实验,不严谨,但足够说明问题。
我预设了五个问题,比如“请假三天需要什么流程”“购置电脑找谁审批”,然后在不同的chunkSize下各跑一遍,记录回答内容和检索到的最相关片段序号。
| chunkSize | 检索命中质量 | 回答完整性 | 观察到的现象 |
|---|---|---|---|
| 200 | 一般 | 一般 | 片段太碎,上下文经常不完整 |
| 500 | 好 | 好 | 既能命中重点,又不会超出上下文 |
| 1000 | 较差 | 较差 | 片段太长,容易混入无关信息,回答发散 |
这个结果符合我查到的社区经验:500到800是中文文档比较常用的切分范围。但要注意,这不是绝对的,如果你的文档本身是长段落式技术文档,可能300更合适;如果是问答形式的FAQ,甚至可以一段一条不切。关键是你要能自己复现这个对比过程,而不是照抄参数。
TopK我也做了测试,3和5的区别不大,但5比8的稳定性好——8条片段会让模型在不同答案之间摇摆。另外我还加了一个相似度阈值:如果检索出来的第一个片段相似度低于0.3,直接判定“没有找到相关资料”,返回兜底话术而不是硬答。这对防止模型编造答案很有效。
调优的方向理清楚之后,第四天基本就结束了。我给自己留了三个下一步的扩展方向:把流式输出接进来,让回答像ChatGPT一样一个字一个字蹦出来;把内存版向量存储换成真正的向量数据库,支持更多文档和持久化;再往后试着让这个问答能维护多轮对话,用户追一句“如果离职怎么办”时,它能理解这句话是针对报销流程的追问,而不是一个新问题。
最后再分享一个我个人的体会。第四天最大的收获不是那个能跑的Demo,而是我建立了一套“先链路、后细节、再参数”的学习节奏。遇到问题的时候,先问自己它在链路里属于哪一环,再把这一环的输入输出打出来看,大多数问题都能在半小时内定位。这个方法不一定适合所有人,但对于Java工程师转AI应用开发,它真的能省下大量自己瞎折腾的时间。如果你也在走这条路,希望这篇记录能让你少踩几个我今天踩过的坑。