基于 NovelAi 项目从零搭建 RAG + Agent 系统的完整记录。
- 时间跨度:三天
- 技术栈:Spring Boot 4.1.1 + LangChain4j 1.20 + Ollama + Qdrant + 智谱 GLM
一、项目背景与目标
1.1 项目定位
NovelAi —— 一个面向小说创作场景的智能助手,核心能力:
- 知识库检索:从私有资料(小说内容、设定、笔记)中检索信息
- RAG 问答:基于检索结果回答问题,避免 LLM 幻觉
- AI 写作:写小说时自动检索设定,保持前后一致
- 创作归档:写完章节后自动摘要、入库,形成"记忆闭环"
1.2 核心架构
┌──────────────────────────────────────────────────────────┐
│ 用户层 │
│ 上传资料 / 提问 / 写小说 │
└────────────────────┬─────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ Controller 层 │
│ IngestionController / ChatController / NovelController │
└────────────────────┬─────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ Service 层 │
│ DocumentIngestionService / NovelWritingService │
│ NovelArchivingService / HybridSearchService │
└────────────────────┬─────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ Agent 层 │
│ NovelAssistant / NovelWriterAgent / Summarizer │
│ SearchTools / NovelWriterTools │
└────────────────────┬─────────────────────────────────────┘
│
┌───────────┴───────────┐
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Ollama (本地) │ │ 智谱 GLM (云端) │
│ qwen3.5:4b │ │ glm-5.3 │
│ bge-base-zh │ │ │
└────────┬────────┘ └─────────────────┘
│
▼
┌─────────────────┐
│ Qdrant (向量库) │
│ Collection: Novel│
└─────────────────┘
二、完整流程梳理
2.1 资料入库流程
上传 PDF / 文本
│
▼
[1] Tika 解析 → 原始文本
│
▼
[2] 正则清洗 → 去掉 wiki 链接、URL、图片链接、乱码
│
▼
[3] 递归切分(500 字 / 50 重叠)
│
▼
[4] BGE 向量化 → 768 维稠密向量
│
▼
[5] 写入 Qdrant(原文 + 摘要双字段)
│
▼
下次检索时使用
2.2 RAG 问答流程
用户提问
│
▼
[1] NovelAssistant(Ollama + 工具调用)
│
▼
[2] 判断是否需要检索
│
▼
[3] 调用 SearchTools.searchKnowledge
│
▼
[4] 生成查询向量(BGE)
│
▼
[5] Qdrant 检索 TopK(score >= 0.4)
│
▼
[6] 拼装上下文(摘要 + 原文)
│
▼
[7] LLM 基于上下文回答
│
▼
返回答案
2.3 小说创作流程(核心闭环)
用户:"写第三章:主角遇到师父"
│
▼
[1] NovelWritingService.writeChapter()
│
▼
[2] NovelWriterAgent(智谱 GLM)
│
├── LLM 判断:需要检索设定
│
├── 调用 NovelWriterTools.searchKnowledge
│ │
│ ▼
│ Qdrant 检索:主角设定、前文、伏笔
│ │
│ ▼
│ 返回检索结果
│
└── LLM 基于检索结果创作正文
│
▼
[3] NovelArchivingService.archiveChapter()
│
├── Summarizer 摘要(Ollama)
│
└── DocumentIngestionService.ingestText()
│
▼
原文 + 摘要写入 Qdrant
│
▼
下次写第四章时,能检索到第三章内容
这就是"创作 → 摘要 → 入库 → 反哺"的闭环。
三、核心组件详解
3.1 文本清洗(TextCleaner)
问题:PDF 解析后充满乱码。
乱码来源:
| 类型 | 示例 | 来源 |
|---|---|---|
| wiki 链接 | (wiki/%E7%99%BD%E9%87%91...) | 页面内跳转 |
| 图片链接 | (wiki/%E6%96%87%E4%BB%B6:xxx.webp) | 图片文件 |
| HTTP 链接 | https://jojo.huiwiki.com | 页眉页脚 |
| URL 编码 | %E7%99%BD | 链接残留 |
| HTML 标签 | <table>...</table> | 表格标记 |
解决方案:正则清洗
public static String clean(String raw) {
return raw
// 1. 去掉 wiki 链接
.replaceAll("\\(wiki/[^)]*\\)", "")
.replaceAll("\\(wi[^)]*\\)", "")
// 2. 去掉图片文件链接
.replaceAll("\\([^)]*?\\.(webp|png|jpg|jpeg|gif|bmp|svg)\\)", "")
// 3. 去掉 http / https 链接
.replaceAll("https?://\\S+", "")
// 4. 去掉 URL 百分号编码
.replaceAll("%[0-9A-Fa-f]{2}", "")
// 5. 去掉 Page 标记
.replaceAll("={3,}\\s*Page\\s*\\d+\\s*={3,}", "")
// 6. 去掉 HTML 标签
.replaceAll("<[^>]+>", "")
// 7. 合并空格和空行
.replaceAll("[ \\t]+", " ")
.replaceAll("\\n{3,}", "\n\n")
.trim();
}
效果:PDF 从 177879 字 → 26041 字,去掉 85% 乱码。
3.2 文档切分策略
DocumentSplitters.recursive(500, 50).split(document);
| 参数 | 值 | 说明 |
|---|---|---|
| chunkSize | 500 | 500 字/块,约 200~300 中文 token |
| chunkOverlap | 50 | 50 字重叠,防止跨块语义断裂 |
为什么是 500/50?
- 500 字能保证语义完整
- 50 字重叠避免断句
- 这个值是经验值,可基于检索效果调优
3.3 摘要设计
关键设计:摘要不应该藏在 DocumentIngestionService 里。
错误做法:
上传 PDF → DocumentIngestionService
├── 切分
├── 摘要 ← ❌ 上传任何文件都触发摘要
├── 向量化
└── 入库
正确做法:
【场景 1:上传资料】
上传 PDF → DocumentIngestionService
├── 切分
├── 向量化
└── 入库(无摘要)
【场景 2:写完小说】
NovelWritingService.writeChapter()
│
├── NovelWriterAgent 写正文
│
▼
NovelArchivingService.archiveChapter()
│
├── Summarizer 摘要 ← ✅ 只在"写完"后触发
└── DocumentIngestionService 入库(带摘要)
摘要 Prompt:
@SystemMessage("""
你是小说资料整理助手。用户会给你一段从 Wiki 或 PDF 提取的文本。
你的任务:
1. 用简洁的中文总结这段文本的核心信息
2. 如果涉及人物,提取:姓名、身份、能力、关系
3. 如果涉及事件,提取:时间、地点、参与者、结果
4. 如果涉及设定,提取:名称、作用、限制
5. 直接输出结果,不要解释、不要加"总结如下"之类的引导语
6. 控制在 200 字以内
""")
String summarize(String text);
3.4 Agent 与工具调用
核心机制:LangChain4j 把 @Tool 注解翻译成 JSON Schema,塞进 LLM 请求,LLM 决定是否调用。
public interface NovelWriterAgent {
@SystemMessage("""
你是 NovelAi 的专属小说作家...
工作流程:
1. 先判断:本次写作是否需要参考资料?
2. 如果需要检索,调用 searchKnowledge
3. 基于检索结果创作
4. 直接输出小说正文
""")
String write(@UserMessage String instruction);
}
@Component
public class NovelWriterTools {
@Tool("检索小说设定库,获取与本次写作相关的已有资料...")
public String searchKnowledge(
@P("检索关键词,如:主角李默的性格") String query,
@P("返回条数,默认 5,最大 10") Integer maxResults) {
// Qdrant 检索
}
}
注册:
@Bean
public NovelWriterAgent novelWriterAgent(
@Qualifier("zhipuWriter") ChatModel zhipuWriter,
NovelWriterTools novelWriterTools) {
return AiServices.builder(NovelWriterAgent.class)
.chatModel(zhipuWriter)
.tools(novelWriterTools)
.build();
}
3.5 Qdrant 数据模型
PointStruct {
id: UUID,
vectors: {
novel_semantic_vector: [768维稠密向量]
},
payload: {
text_segment: "原文片段",
summary: "该片段摘要",
source: "来源文档",
type: "original" | "summary"
}
}
四、遇到的问题与解决方案
问题 1:LangChain4j 版本冲突
现象:
NoClassDefFoundError: dev/langchain4j/exception/AsyncNotSupportedException
原因:langchain4j-open-ai:1.18.1 引入的 langchain4j-core:1.18.1 与主库 1.20.0 冲突。
解决:
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-open-ai</artifactId>
<version>1.20.0</version> <!-- 和主库保持一致 -->
</dependency>
问题 2:HTTP Client 冲突
现象:
Conflict: multiple HTTP clients have been found in the classpath:
[SpringRestClientBuilderFactory, JdkHttpClientBuilderFactory]
原因:classpath 里同时有两个 HTTP 客户端实现,LangChain4j 无法自动选择。
解决:在 main 方法最开头加系统属性:
public static void main(String[] args) {
System.setProperty("langchain4j.http.clientBuilderFactory",
"dev.langchain4j.http.client.jdk.JdkHttpClientBuilderFactory");
SpringApplication.run(NovelAiApplication.class, args);
}
问题 3:multipart 上传限制
现象:
- 12MB 的 PDF 报 MaxUploadSizeExceededException
- 但 80MB 的 PDF 能上传成功
原因:Spring Boot 4.x 的 spring.servlet.multipart.* 配置绑定可能有变化。
排查过程:
- 先用 Environment.getProperty() 打印配置 → 显示 100MB
- 改用 MultipartProperties 注入打印 → 显示 104857600B(100MB),配置生效
- 但 12MB 文件仍报错 → 说明是 PDF 本身的特性(27页+大量图片)触发了 Tika/Tomcat 内部限制
解决:改用纯文本上传(/api/ingest/text),绕过文件上传。
问题 4:Ollama 摘要超时
现象:
17:40:59 开始写章节
17:46:13 TimeoutException: request timed out ← 5 分 14 秒
17:46:33 Connection reset
原因:
- 54 个片段串行调用 qwen3.5:4b 摘要
- 每个片段 5~10 秒
- 总耗时 8 分钟,超过 5 分钟超时限制
解决:
- 并行化:2 线程并行(匹配 OLLAMA_NUM_PARALLEL=2)
- 异步上传:上传接口立即返回 202,后台处理
- 超时加长:timeout(Duration.ofMinutes(30))
问题 5:智谱 API Key 为 null
现象:
- 请求 /api/chat/write-with-context 卡 5 分钟
- 日志显示 TimeoutException + Connection reset
原因:application.yml 里 zhipu.writer 少了 api-key 字段,导致 API Key 为 null,智谱返回 401,LangChain4j 不断重试直到超时。
解决:
zhipu:
writer:
base-url: https://open.bigmodel.cn/api/paas/v4/
api-key: ${ZHIPU_API_KEY:} # 补上
model-name: glm-5.3
temperature: 0.9
timeout-minutes: 15 # 5 → 15
同时在 IDEA 里设环境变量 ZHIPU_API_KEY=真实key。
问题 6:摘要不应在入库流程里
现象:上传任意 PDF 都触发摘要,耗时长且不合理。
原因:DocumentIngestionService 里硬编码了摘要逻辑。
解决:拆分成两个 Service:
- DocumentIngestionService:纯入库(切分 + 向量化 + 写入)
- NovelArchivingService:摘要 + 入库(由"写完小说"触发)
问题 7:双 ChatModel 注入冲突
现象:启动报 NoUniqueBeanDefinitionException,因为有两个 ChatModel Bean(ollamaChatModel 和 zhipuWriter)。
解决:所有注入点加 @Qualifier:
public ChatController(
@Qualifier("ollamaChatModel") ChatModel ollamaChatModel,
@Qualifier("zhipuWriter") ChatModel zhipuWriter,
NovelAssistant novelAssistant) { ... }
问题 8:模型选型与稀疏向量不匹配
现象:BgeM3Client 期望返回稠密 + 稀疏向量,但 quentinz/bge-base-zh-v1.5 是纯稠密模型,不输出稀疏向量。
结果:BgeM3Result 的 indices / sparseValues 永远为 null,HybridSearchService 的混合检索退化为纯稠密检索。
解决:
- 短期:接受纯稠密检索,方法改名为 searchDense
- 长期:换 bge-m3 模型,打通稀疏链路
五、经验总结
5.1 架构设计原则
| 原则 | 说明 |
|---|---|
| 职责分离 | 摘要、入库、检索、写作各自独立 Service |
| 配置外置 | 模型名、超时、集合名全部走 yml |
| 异步优先 | 耗时操作(摘要、入库)异步化,HTTP 立即返回 |
| 降级容错 | 云端模型超时 → 降级到本地 Ollama |
| 进度可视 | 长任务打印进度条,避免"假死" |
5.2 模型选型建议
| 场景 | 推荐模型 | 理由 |
|---|---|---|
| 检索/摘要 | qwen3.5:4b(本地) | 低温度,事实准确,不花钱 |
| 小说创作 | glm-5.3(智谱) | 高温度,文笔好,创作力强 |
| Embedding | bge-base-zh-v1.5 | 中文优化,768 维 |
5.3 性能优化清单
| 优化项 | 效果 |
|---|---|
| 2 线程并行摘要 | 速度 ×2 |
| 异步上传 | 上传接口 2 秒返回 |
| num-ctx 降到 8192 | 显存占用降低,速度提升 |
| 文本清洗 | 去掉 85% 乱码,检索质量提升 |
| 摘要 + 原文双字段 | 检索既能看摘要,也能看原文 |
5.4 踩坑清单
| 坑 | 教训 |
|---|---|
| LangChain4j 版本不一致 | 所有 langchain4j 模块版本必须一致 |
| HTTP Client 冲突 | 用系统属性显式指定 |
| multipart 配置绑定问题 | 用 MultipartProperties 验证真实值 |
| 摘要硬编码在入库里 | 摘要应由"写完"触发,不藏在入库 |
| API Key 为空 | 一定要打印验证,别等到超时才发现 |
| 串行调用 LLM | 能并行就并行,配合 OLLAMA_NUM_PARALLEL |
| 超时太短 | 免费模型排队久,超时给到 15~30 分钟 |
| 双 Bean 注入冲突 | 所有注入点加 @Qualifier |
六、面试话术准备
Q1:为什么用 RAG 而不是微调?
微调成本高、更新慢,私有资料经常变动;RAG 只需重新入库即可更新知识,且能给出引用来源,可解释性强。本项目场景是小说设定管理,资料频繁更新,RAG 更合适。
Q2:为什么选 Qdrant?
Qdrant 原生支持 gRPC、命名向量、Payload 过滤,性能好且部署轻量(单 Docker)。Milvus 偏重、Chroma 偏轻量但生产特性弱。本项目单机部署,Qdrant 性价比最高。
Q3:切分参数 500/50 怎么定的?
500 字符大约对应 200~300 中文 token,能保证语义完整;50 重叠防止跨块语义断裂。这个参数是经验值,实际可以基于检索效果调优。
Q4:为什么用 Agent 而不是直接检索?
Agent 让 LLM 自主决策是否需要检索。比如用户说"你好",不需要检索;问"主角成长线",才触发检索。这样避免无谓的向量查询,也更符合对话直觉。
Q5:这个项目最难的点是什么?
依赖冲突排查。LangChain4j 1.20 与 1.18 版本混用导致 NoClassDefFoundError;两个 HTTP Client 实现导致启动失败;Spring Boot 4.x 的 multipart 配置绑定问题。每一个都需要深入理解框架内部机制才能解决。
Q6:有什么不足 / 下一步优化?
- 稀疏向量链路还没打通,目前是纯稠密检索,计划接入 BGE-M3 实现混合检索 + RRF 融合
- 没有流式输出,体验上可以优化
- 缺少多轮对话记忆
- 检索结果没有 Rerank
七、关键文件清单
| 文件 | 作用 |
|---|---|
| NovelAssistant.java | 检索问答 Agent 接口 |
| NovelWriterAgent.java | 写小说 Agent 接口 |
| Summarizer.java | 摘要 Agent 接口 |
| SearchTools.java | 检索问答工具 |
| NovelWriterTools.java | 写作检索工具 |
| AgentConfig.java | 注册所有 Agent Bean |
| TextCleaner.java | 文本清洗工具 |
| DocumentIngestionService.java | 纯入库服务 |
| NovelWritingService.java | 写作服务 |
| NovelArchivingService.java | 摘要 + 归档服务 |
| HybridSearchService.java | 向量检索服务 |
| BgeM3Client.java | BGE 向量化客户端 |
| ChatModelConfig.java | ChatModel Bean 配置 |
| IngestionController.java | 入库接口 |
| ChatController.java | 对话接口 |
| NovelController.java | 小说接口 |
八、总结
RAG 系统的核心不是"接上大模型",而是"把资料处理好、把检索做准、把流程理顺"。
这个项目从零搭建,经历了:
- 依赖地狱:LangChain4j 版本冲突、HTTP Client 冲突
- 配置陷阱:multipart 绑定问题、API Key 为空
- 性能瓶颈:串行摘要超时、显存不足
- 架构重构:摘要从入库流程剥离、双 ChatModel 注入
最终形成一个创作 → 摘要 → 入库 → 反哺的完整闭环。
最大的收获:理解了 RAG 不是"向量库 + LLM"那么简单,而是数据清洗、切分策略、模型选型、异步架构、容错降级的综合工程。