你有没有遇到过这种场面:自己维护的旅游应用里,用户问“去杭州玩三天怎么安排”,你知识库里明明有景区介绍、美食攻略、交通提醒,可通用大模型给出的答案却永远停留在“杭州是浙江省会,有西湖”这种层面。更头疼的是,问“西湖夜游船票多少钱”,它敢直接编一个价格出来,说得跟真的一样。
我想解决的就是这个问题:把散落在文档里的旅游知识,变成大模型可以实时翻阅的资料,让它在回答时不再“凭记忆发挥”。做法就是 RAG(检索增强生成),配合 Spring AI 这套 Java 生态里的集成框架,前后折腾了两套方案——一套完全本地跑,模型、向量库都在自己机器上;另一套依赖阿里云百炼,模型和向量化都走云端 API。这篇文章就把整个过程拆开讲清楚,从原理到代码,从切分策略到调优踩坑,适合想用 Java 技术栈做知识库问答、但还在技术选型边缘试探的开发同学。
1. 一个真实的痛点:通用大模型回答不了“家门口”的旅游问题
1.1 用户问得越具体,模型越容易露馅
做旅游类产品有个绕不开的场景:用户的问题大多是“某某景点现在门票多少”“某地最近天气怎么样适合穿什么”“两个人预算三千去某地够不够”。这些问题的共同点是时效性强、本地性强。大模型训练数据是有截止日期的,它可能知道西湖断桥是十年前的经典路线,但不知道今年园区调整了夜游开放时间,更不可能知道你知识库里刚刚更新的景区联票政策。
最典型的翻车案例我测过:问“南京某个小众博物馆现在需要预约吗”,通用模型压根不知道这个博物馆的存在,但依然能理直气壮地告诉你“不需要预约,直接现场购票即可”。这种一本正经的胡说八道,在通用问答里还能忍,放在面向用户的旅游产品里,直接就是客诉。
1.2 RAG 的套路:先翻资料,再写答案
RAG 的思路其实很好理解,类比一下就是“开卷考试”。模型不再凭空回答,而是先把你给它的知识库里翻一遍,把和问题最相关的几段内容找出来,然后看着这些内容组织答案。
具体到技术链路,就是五个环节:
- 用户输入问题
- 把问题转成向量(一串数字,代表语义)
- 拿向量去向量数据库里找最相似的候选内容
- 把候选内容拼进 Prompt 上下文
- 大模型基于上下文生成最终回答
关键在于第2、3步:文本被向量化之后,就能用“向量距离”衡量两段话在语义上的相似程度,而不只是字面匹配。所以用户问“西湖有没有游船可以坐”,即便资料里写的是“西湖游船码头及票价”,也能被正确召回。
1.3 为什么是 RAG,而不是微调模型
我当时也认真考虑过微调路线,但很快放弃了。原因是旅游知识库的更新频率太高:门票价格会调、开放时间会变、新景区会开。微调一次模型花费不小,而且每更新一条信息就得重新训练一轮,根本扛不住。
RAG 的优势在于知识更新成本极低——修改文档、重新向量化、入库,模型本身完全不用动。对中小团队来说,这是把大模型能力落地到自己业务里最经济的方式。另外一个隐性优势是可解释性:RAG 可以让用户看到“答案依据的是哪几段资料”,出了问题方便溯源,这在内容合规和用户信任层面都很重要。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Spring AI 的价值:让 Java 团队用工程化方式接上大模型
2.1 选型阶段:不想为了 AI 功能单独养一条 Python 服务
当时团队的情况很现实:整个技术栈以 Java 为主,如果为了接大模型专门起一条 Python 服务,既要处理跨服务通信,又要维护两套部署链路,非常麻烦。我需要的是一个能在 Spring Boot 工程里直接调用的方案,最好能像以前写数据库访问层那样,通过配置和接口把能力都接好。
Spring AI 正好卡在这个位置上。它不是某个具体云厂商的 SDK,而是定义了一套统一的抽象接口,底层可以接不同的模型供应商。Spring 框架本身的功底保证了它和 Spring Boot 生态的融合度:自动配置、starter 依赖、application.yml 绑定属性,所有这些 Java 工程师熟悉的套路,AI 能力也按这个套路来。
2.2 三个核心抽象:ChatModel、EmbeddingModel、VectorStore
Spring AI 里头最核心的抽象就三个,把它们搞清楚整个项目就通了一半。
| 抽象类/接口 | 职责 | 本项目中负责什么 |
|---|---|---|
| ChatModel | 对话模型,负责生成回答 | 输入 Prompt,输出最终答案 |
| EmbeddingModel | 文本向量化模型,把文字转为数值向量 | 把用户问题和知识库文本变成向量 |
| VectorStore | 向量数据库操作接口 | 存储文档向量,并提供相似度检索 |
这三个接口像是三层齿轮:EmbeddingModel 负责把文档变成向量,VectorStore 负责保存和召回向量,ChatModel 负责把召回的上下文变成人话。业务代码只需要面对这三个接口,不需要关心底层接的是 Ollama 还是云端 API。我后面提到的“双方案”,本质就是这三大组件换了几套不同的实现而已。
2.3 双方案共用一套业务代码
这是 Spring AI 这套抽象设计最吸引人的地方。我一开始规划时就定了两个目标:本地方案先跑通验证效果;阿里云百炼方案保证生产可用。两个方案在业务代码层面几乎完全一样,差异只体现在 pom.xml 里的依赖坐标和 application.yml 里的连接配置上。
也就是说,你在本地拿 Ollama 调试好的问答逻辑,切到云端时不用改业务代码。这个特性让摸索和上线之间的摩擦小了很多。下面两个实战章节,我会把两套方案分别展开,你可以看到它们是如何“同构”的。
3. 旅游知识库的数据链路:从景区资料到可被检索的向量
3.1 先理清楚哪些数据值得进知识库
一个常见误区是:把全部文档一股脑倒进知识库就完事。我在实践里的经验是,对旅游问答场景,先给数据分好类再决定要不要入库,效率会高很多。
适合进知识库的数据通常有几类:
- 景区基础信息:简介、开放时间、门票价格、地址、联系方式
- 交通指南:机场到市区交通、景区接驳、地铁线路
- 游玩攻略:路线安排、游玩时长、季节性提醒
- FAQ 常见问答:用户高频提问的标准答案
不适合进的也有:表情包文案、冗余公告历史记录、重复内容、质量太差的 UGC 内容。知识库的数据质量直接决定检索质量,这个环节花的时间值得。
3.2 切分策略:按逻辑块切,别按字数硬切
文档入库前必须先切块。我之前踩过一个坑:把一整篇“杭州景点全攻略”作为一条文档塞进向量库,结果用户问“西湖游船价格”时,召回的文档里包含了西湖、灵隐寺、河坊街的所有内容,模型不知道该看哪段,答案变得又臭又长。
我的做法是按逻辑边界切分:每个景区单独成块,每个 FAQ 问答单独成块,攻略文章按小节切开。一段干净的知识块,就是一个自包含的完整语义单元。在实现上,如果资料本身是结构化文本,直接用 \n\n 切段落就够了;如果是一大段长文,再配合 Spring AI 提供的 TokenTextSplitter 或自定义切分,控制每块大小在 300 到 500 token 左右。
这里给一个我做 FAQ 文档入库时的朴素切分代码,简单直接:
java复制List<Document> chunks = new ArrayList<>();
String[] sections = rawText.split("\\n\\n");
int seq = 0;
for (String section : sections) {
if (section.isBlank()) {
continue;
}
Map<String, Object> metadata = new HashMap<>();
metadata.put("source", fileName);
metadata.put("seq", seq++);
chunks.add(new Document(section, metadata));
}
vectorStore.add(chunks);
需要注意,切分时保留 metadata 很重要。我会在 metadata 里放 city、type(景点/美食/交通)等信息,后面做检索过滤时特别好用。
3.3 向量化:本地用 bge-m3,云端用 API 模型
向量化模型的选择对中文检索效果影响巨大。我做过对比测试:英文表现优异的 embedding 模型,在中文场景下有时连“西湖”和“苏堤”这种明显语义关联都召回不到。
本地方案里我用的是 Ollama 里的 bge-m3 模型,它对中文语义的理解比通用模型好很多,而且能输出 1024 维向量,适配性不错。云端方案里,百炼提供了 text-embedding-v3,中文效果好,还支持按业务需求配置向量维度。两套方案都通过 Spring AI 的 EmbeddingModel 抽象调用,业务代码上没有任何感知差异。
4. 本地方案实操:Ollama 跑模型,Spring AI 编排检索问答
4.1 环境准备:装 Ollama、拉模型、起 Chroma
本地方案的模型层面我用 Ollama 来跑:
bash复制ollama pull qwen2.5:7b-instruct
ollama pull bge-m3
qwen2.5:7b-instruct 负责回答生成,bge-m3 负责向量化。机器配置建议 16G 内存起步,7B 量级的模型量化后跑起来才不卡。
向量库用的是 Chroma,直接拿 Docker 起一个实例:
bash复制docker run -d -p 8000:8000 --name chroma chroma/chroma
Spring AI 对 Chroma 有现成的 starter,配置好连接即可。如果你只是想在本地快速验证链路,也可以先用 Spring AI 内置的内存型向量库跑通再换,不过生产上肯定要持久化方案。
4.2 依赖和配置落地
Maven 里引入 BOM 和两个关键 starter:
xml复制<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>1.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
xml复制<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-ollama-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-chroma-store</artifactId>
</dependency>
配置文件里把两段核心连接信息补上:
yaml复制spring:
ai:
ollama:
base-url: http://localhost:11434
chat:
options:
model: qwen2.5:7b-instruct
embedding:
options:
model: bge-m3
vectorstore:
chroma:
client:
host: localhost
port: 8000
collection: travel_kb
注意不同版本的 Spring AI 对 Chroma 配置项命名略有变化,这里以 1.0.0 的写法为准,升级版本时要留意 release notes。
4.3 核心代码:三步走通“知识注入 - 检索 - 问答”
整个业务代码可以拆成三段。第一段是知识注入,把切好的文档写进向量库:
java复制@Service
public class KnowledgeBaseService {
private final VectorStore vectorStore;
public KnowledgeBaseService(VectorStore vectorStore) {
this.vectorStore = vectorStore;
}
public void ingest(List<Document> documents) {
vectorStore.add(documents);
}
}
第二段是根据用户问题做相似度检索:
java复制public List<Document> search(String query, int topK) {
SearchRequest request = SearchRequest.builder()
.query(query)
.topK(topK)
.build();
return vectorStore.similaritySearch(request);
}
第三段是问答编排——把检索结果拼进 Promot,让大模型基于资料回答:
java复制@Service
public class TravelQaService {
private final ChatModel chatModel;
private final VectorStore vectorStore;
public String answer(String question) {
SearchRequest request = SearchRequest.builder()
.query(question)
.topK(5)
.build();
List<Document> docs = vectorStore.similaritySearch(request);
String context = docs.stream()
.map(doc -> doc.getContent())
.collect(Collectors.joining("\n"));
String prompt = """
你是一位资深的旅游顾问。请严格根据下面的景区资料回答用户问题。
如果资料中没有相关信息,请明确回复“知识库暂无此信息”,不要自行编造。
【景区资料】
%s
【用户问题】
%s
""".formatted(context, question);
return chatModel.call(new Prompt(prompt)).getResult().getOutput().getText();
}
}
这段代码是整个知识库问答的核心。你可以看到,Spring AI 把底层模型连接都抽走了,业务代码只需要关心“检索”和“拼 Prompt”这两件事。我在本地实测时,用这批代码回答“西湖景区现在开放到几点”,模型能正确引用文档里的时间信息,而不是自己猜一个。
4.4 本地方案的实测表现和资源感受
说下实测感受。7B 模型在普通 PC 上生成速度大概在每秒十几到二十个 token 左右,像一个一问一答的流程得等几秒,原型阶段完全能接受。资源方面,Ollama 常驻内存大概占 8G 左右,加上 Chroma 和 Java 应用,一台 16G 内存的机器跑起来已经有点吃紧,建议做本地验证时把浏览器和 IDE 能关的都关一关。
本地方案最大的价值是零 API 成本,调模型不花钱,可以放心地反复试 Prompt、调切分、调阈值。这也是我建议初学者先走这条路的原因,模型能力够用,环境问题少,出问题容易排查。
5. 阿里云百炼方案:把模型和向量化都挪到云端
5.1 百炼提供了什么能力
本地方案什么都好,就是部署环境要求高,真要上生产供用户调用,小团队很难保证 GPU 资源稳定。这时候阿里云百炼的优势就出来了:它把大模型能力和向量化能力都做成了 API,你不需要关心模型怎么部署,只要调用接口就行。
在本项目里,我用到百炼两个核心能力:
- 大模型问答:使用
qwen-plus或qwen-turbo,效果稳定,响应延迟远低于本地 7B 模型 - 文本向量化:使用
text-embedding-v3,中文效果比我对比过的开源 embedding 模型要好
5.2 接入方式:换依赖、换配置,代码不用动
最让我满意的就是这一步。通往百炼有两种接入姿势,我推荐优先用 Spring AI Alibaba 提供的 starter:
xml复制<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter</artifactId>
<version>1.0.0</version>
</dependency>
对应的配置:
yaml复制spring:
ai:
dashscope:
api-key: ${DASHSCOPE_API_KEY}
chat:
options:
model: qwen-plus
embedding:
options:
model: text-embedding-v3
如果你对特定 starter 的版本稳定性有顾虑,百炼也提供了 OpenAI 兼容接口,可以走 Spring AI 的 OpenAI starter,只改 base-url:
yaml复制spring:
ai:
openai:
base-url: https://dashscope.aliyuncs.com/compatible-mode/v1
api-key: ${DASHSCOPE_API_KEY}
chat:
options:
model: qwen-plus
embedding:
options:
model: text-embedding-v3
两种方式任选其一,百度都行。关键是业务代码不用动:KnowledgeBaseService 和 TravelQaService 里的逻辑完全复用。向量库也可以沿用你本地的 Chroma,因为 VectorStore 只负责存取和检索,不关心是本地模型还是云端 API 做的向量化。如果想全链路云上,后续把向量库也迁到云托管实例即可。
5.3 云端方案的链路验证
切换到百炼之后,我重新跑了一遍之前准备的测试集。印象最深的一题是“苏州有哪几个园林适合带老人玩”,知识库里有几段关于拙政园、留园、网师园的文档,且分别在不同切片中。
云端方案的检索结果非常精准地召回了三座园林的段落,模型给出的回答在结构和细节上都明显比本地 7B 模型扎实,甚至能提醒“老人游玩建议预留半天时间、注意台阶多”。这说明检索做对了,模型能力也更跟得上。
同时我也验证了“知识库没有的内容它会主动拒绝”:问“某景区附近的民宿推荐”,资料里没有民宿信息,模型的回复是“知识库暂无此信息,建议您关注景区住宿指南”,没有乱编。这种可控性对产品上线非常关键。
5.4 成本和使用体验说明
云端方案是典型的按量付费,问答和向量化各自计费。原型阶段用量小,几乎可以忽略;正式上线前我建议给 API 调用加个最基础的限流,并设计好缓存,因为高频问题的检索结果往往是重复的,直接缓存答案能省一大笔 Token 费。
百炼方案的另一层好处是免运维。模型升级、热更新、复杂的部署环境都不用自己处理,对于团队规模不大的产品很省心。
6. 检索质量调优:同样是 RAG,为什么效果差一大截
6.1 先诊断:是“没召回”还是“召回了没用上”
问答效果不好时,我不建议直接瞎调参数。先分清楚故障在哪一环。
如果模型说“知识库暂无此信息”,但知识库明明有相关内容,那是检索没召回——问题出现在向量化或检索参数上。如果模型回答得乱七八糟,或者张冠李戴,那是召回了但上下文不干净——问题出现在切分策略或 Prompt 约束上。
区分方法很简单:先把检索结果单独打出来看。我在调试阶段会临时把 search() 的返回结果打到日志里,确认召回内容是否相关,再决定调哪边。
6.2 TopK、相似度阈值、切分粒度的联动关系
这几个参数是日常调优的主战场。TopK 决定召回多少条,阈值决定哪些算相似。两者是相互制约的:TopK 太大,噪声多;阈值太高,可能把正确答案过滤掉。
我整理了一张常用参数对照表,可以当起点:
| 参数 | 我的建议范围 | 说明 |
|---|---|---|
| topK | 3 - 5 | 问答场景够了,再多上下文容易互相干扰 |
| similarityThreshold | 0.5 - 0.75 | 需要配合具体 embedding 模型实测,不同模型分布差异很大 |
| 分块大小 | 300 - 500 token | 太小语义不完整,太大噪声多 |
| 分块 overlap | 50 - 100 token | 防止切分处语义断掉 |
参数调优唯一可靠的方法就是构造一批典型问题,反复对比召回结果。我自己的做法是准备十几个高频用户问题,任何改动都拿这套问题回归一遍。
6.3 利用 metadata 过滤,让检索更精准
旅游知识库有一个很典型的问题:不同城市、不同类别的文档混在一起,纯向量检索偶尔会召回风格相似但实际无关的内容。比如用户问“成都美食推荐”,检索结果里混进了“成都景点介绍”虽然也能理解,但会稀释答案重点。
解决办法是给文档打标签,并在检索时加过滤条件。前面切分时我就在 metadata 里放了 city 和 type,现在正好利用:
java复制SearchRequest request = SearchRequest.builder()
.query("成都哪些小吃值得尝试")
.filterExpression("city == '成都' && type == '美食'")
.topK(3)
.build();
加了过滤之后,检索的空间从“整个知识库”缩小到“成都美食”这个子集,召回准确率提升非常明显。尤其是知识库文档量变大之后,metadata 过滤几乎成了必备操作。
6.4 常见问题速查表
调优过程中我积累了一张问题速查表,碰到症状先对号入座:
| 症状 | 可能原因 | 解决办法 |
|---|---|---|
| 几乎搜不到相关内容 | embedding 模型中文能力弱 | 换成 bge-m3 或云端 text-embedding-v3 |
| 检索到了,但答案张冠李戴 | 分块混入了多个主题 | 按逻辑边界重新切块,缩短块长度 |
| 答案包含太多无关信息 | TopK 太大或阈值太低 | 减小 TopK,提高相似度阈值 |
| 模型仍然自己编答案 | Prompt 约束不够 | 强化“仅根据资料回答,没有就直说” |
| 不同城市相似内容互相干扰 | 没有 metadata 过滤 | 给文档补城市、类别标签,检索时过滤 |
| 响应太慢 | 模型体积大或网络延迟 | 换更小的轻量模型,或改流式输出 |
7. 双方案选型建议:什么时候用本地,什么时候上云
7.1 本地对阵云端的完整比较
这两个方案我实际跑过之后,感受很立体。直接拉一张对比表:
| 维度 | 本地方案 | 阿里云百炼方案 |
|---|---|---|
| 数据隐私 | 数据不出机器,隐私性好 | 数据会上传给云服务,需要评估合规要求 |
| 成本结构 | 仅硬件一次性投入,无 API 费用 | 按 Token/调用量付费,规模上量后有持续成本 |
| 回答质量 | 开源 7B 模型够用但上限一般 | 商用大模型效果好,中文回答更自然 |
| 响应延迟 | 取决于本地 GPU/CPU,波动明显 | 延迟稳定,且可并发扩展 |
| 运维复杂度 | 自己管模型、向量库、升级 | 基本免运维,只管 API 调用 |
| 知识更新流程 | 重新向量化入库,全自动 | 同样全自动,更新及时 |
7.2 我的推荐路径和理由
如果你问我现在做一个新的旅游问答项目怎么选,我的建议是分阶段走。
个人学习、原型验证阶段,无脑选本地方案。免费、可控、能学到环境搭建和模型部署的细节。等业务要面向真实用户了,果断切百炼方案,先把稳定性和回答质量交给专业服务,把精力集中在知识库运营和产品体验上。
如果业务本身对数据隐私要求极高,不能接受数据出域,那就只能在本地方案基础上继续深入。大批量测试、用更好模型、做并发优化,这些都是后话,但至少路径是清晰的。
7.3 后续还可以扩展的方向
这套架构的扩展性很好,我列几个我认为值得做的方向:
- 流式输出:把
chatModel.call(prompt)改成chatModel.stream(prompt),回答逐字输出,大幅提升用户体感 - 多轮对话记忆:引入 Spring AI 的 ChatMemory,让搜索问答具备上下文连续性
- Rerank 重排:检索出候选后,用重排模型二次排序,进一步提升结果精度
- 多知识库隔离:给不同目的地建不同集合,用 metadata 天然隔离业务线
- 答案溯源:把召回的文档 ID 附进回答结果里,用户能快速核实信息来源
我在实际使用中最大的体会是:RAG 项目的瓶颈往往不在模型,而在对知识库内容本身的理解。你越了解自己的数据,切分和检索策略就越清晰,最终效果就越扎实。本地和百炼只是两种跑法,真正决定问答质量高低的,永远是知识准备和调优的细致程度。这套链路搭好之后,换个垂直领域,只要换文档和 metadata 体系,架构完全可以复用。
