基于 NovelAi 项目从零搭建 RAG + Agent 系统的完整记录。

一、项目背景与目标

1.1 项目定位

NovelAi —— 一个面向小说创作场景的智能助手,核心能力:

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?

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 上传限制

现象:

原因:Spring Boot 4.x 的 spring.servlet.multipart.* 配置绑定可能有变化。

排查过程:

  1. 先用 Environment.getProperty() 打印配置 → 显示 100MB
  2. 改用 MultipartProperties 注入打印 → 显示 104857600B(100MB),配置生效
  3. 但 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

原因:

解决:

问题 5:智谱 API Key 为 null

现象:

原因: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:

问题 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 的混合检索退化为纯稠密检索。

解决:

五、经验总结

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:有什么不足 / 下一步优化?

七、关键文件清单

文件 作用
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 系统的核心不是"接上大模型",而是"把资料处理好、把检索做准、把流程理顺"。

这个项目从零搭建,经历了:

最终形成一个创作 → 摘要 → 入库 → 反哺的完整闭环。

最大的收获:理解了 RAG 不是"向量库 + LLM"那么简单,而是数据清洗、切分策略、模型选型、异步架构、容错降级的综合工程。