夜雨聆风学习资料网

ARTICLE · 1184549

第一版 RAG:上传文档就能问,我用了不到 200 行

第一版 RAG:上传文档就能问,我用了不到 200 行
JAVA 后端做 AI2026.10

先把企业级架构设计好

先把最小那条

链路跑通

五个步骤 · 三个 Bean · 一条能问的链路

项目实战AI笔记

PART 05RAG 起步
01

先别急着设计架构

专栏二 · 从零搭本地 RAG 第 1 篇(共 14 篇)

前三篇一直在讲选型和认知:为什么不引 Python、LangChain4j 到底给了什么、Agent 和聊天机器人的区别。

从这篇开始动手。

但我做的第一件事,不是设计企业级架构,而是让一条最小的链路先跑起来——上传一个文档,问一句话,模型能照着文档答上来。

理由很简单:企业级那套(混合检索、Rerank、权限、审计)我一项都还没验证过。链路都没通,谈优化是空谈。

02

最小闭环,就五步

RAG 听着玄,剥到最里面就五步:

#
这一步干什么
第一版的实现
1
读文本
上传文件 → new String(bytes, UTF_8),只认纯文本
2
切片
固定 500 字一刀,相邻块重叠 80 字
3
向量化
每片调一次 nomic-embed-text,出 768 维向量
4
存
InMemoryEmbeddingStore,存在 JVM 堆里
5
检索 + 生成
问题向量化 → 召回 top-3 → 拼进 prompt → qwen3:14b 作答

一句话:把文档切碎、变成向量存起来;提问时找出最像的几片,连问题一起塞给模型。

前后端连起来,就是一个能用的文档问答。

03

三个 Bean,撑起整条链路

整个 RAG 的骨架在 RagConfig 里,就三个 Bean:

...java

@Configuration

public class RagConfig {

  @Bean

  public ChatModel chatModel() {              // 对话模型

    return OllamaChatModel.builder()

        .baseUrl(baseUrl)               // localhost:11434

        .modelName("qwen3:14b")

        .timeout(Duration.ofSeconds(300)) // 本地模型首响慢,超时给足

        .build();

  }

  @Bean

  public EmbeddingModel embeddingModel() {    // 向量化模型

    return OllamaEmbeddingModel.builder()

        .baseUrl(baseUrl)

        .modelName("nomic-embed-text")  // 768 维

        .build();

  }

  @Bean

  public EmbeddingStore<TextSegment> embeddingStore() {  // 向量库

    return new InMemoryEmbeddingStore<>();             // ← 就这一行,第一版

  }

}

这三个 Bean 分别对应「生成 / 向量化 / 存储」,互相解耦、可替换。

第一版和后面所有版本的差别,几乎都只体现在最后那个 Bean 上。

第 06 篇要干的事,就是把 embeddingStore() 这一行:

...java

return new InMemoryEmbeddingStore<>();          // 内存,重启就没了

换成:

...java

return PgVectorEmbeddingStore.builder()          // PostgreSQL + pgvector,落盘

    .host(pgHost).port(pgPort).database(pgDatabase)

    .table("embedding_store").dimension(768)

    .createTable(true)

    .build();

业务代码一行不用改——add() 还是 add(),search() 还是 search()。

04

入库:切片 → 向量化 → 存

...java

/**

* 入库:一篇文档 → 切块 → 逐块向量化 → 写入向量库。返回切块数。

*/

public int ingest(String text) {

  List<String> chunks = split(text, 500, 80);   // 固定窗口 500,重叠 80

  int count = 0;

  for (String chunk : chunks) {

    TextSegment segment = TextSegment.from(chunk);          // 切片 → TextSegment

    Embedding embedding = embeddingModel.embed(segment).content(); // 调模型向量化

    embeddingStore.add(embedding, segment);                 // 存

    count++;

  }

  return count;

}

三个类型,对应三个动作:TextSegment(文本片)、Embedding(向量)、EmbeddingStore(库)。

到这里有个很关键的习惯:add() 是「往库里塞一行」,不是「覆盖」。所以第一版最大的隐患——同一份文档传两遍,库里就有两份。这事我在第 04 篇讲过(幂等去重),第一版是完全没有的。

05

提问:拿 top-3 → 拼 prompt

...java

public String ask(String question) {

  // 1) 问题向量化:和入库时用的是同一个 embedding 模型

  Embedding questionEmbedding = embeddingModel.embed(question).content();

  // 2) 检索:交给 LangChain4j 封装的单路向量检索

  EmbeddingSearchRequest request = EmbeddingSearchRequest.builder()

      .queryEmbedding(questionEmbedding)

      .maxResults(3)        // top-k = 3

      .minScore(0.3)        // 相似度低于 0.3 的不要

      .build();

  List<EmbeddingMatch<TextSegment>> matches =

      embeddingStore.search(request).matches();

  // 3) 拼 prompt:把命中的片段拼成「参考资料」,连问题一起给模型

  String context = matches.stream()

      .map(m -> m.embedded().text())

      .collect(Collectors.joining("\n\n---\n\n"));

  return chatModel.chat(prompt(question, context));

}

配一句系统提示词就完事了,大意是:只能依据【参考资料】回答,资料里没有就说「没有」,别编。

这一版能答对问题,靠的就是这句话——它把模型从「凭记忆瞎编」摁回「照着材料说话」。

顺带说一个细节:这里的 minScore(0.3) 当时是真在起作用的(过滤低相似的片)。后来换成混合检索、分数换成 RRF 融合分之后,这个 0.3 的量级就对不上了,成了死代码——这个坑我在第 12 篇单独讲。

06

第一版到底有多糙

把第一版和后来每一处改动列在一起,你会看出一个规律:

环节
第一版
后来换成了
哪一篇
向量库
InMemoryEmbeddingStore 内存
PostgreSQL + pgvector
06
切片
固定 500 字硬切,重叠 80
段落优先,窗口 1200 / 重叠 200
11
检索
单路向量 search()
稠密 + 稀疏双路召回 + RRF 融合
08~09
top-k
3
8
11
文档解析
只认纯文本
PDF / Word / Excel 都能读
—
关键词命中
只能靠语义
加一路 pg_trgm 字面匹配
08~09

看每一行都会发现:没有一行是「从零重写」,全是在这条链路上换零件。

这也是我坚持先把最小版跑通的原因——骨架立住了,后面每次优化都只是换一块。

07

它为什么「够用」

第一版够用,不是因为它好,是因为它把要验证的东西都验证完了:

模型能不能跑

qwen3:14b 在本地 Ollama 上出词正常、体验可接受

链路通不通

切片 → 向量化 → 存 → 召回 → 生成,每一段都跑通了

「照着材料答」成不成立

能引用到正确的片段,也会在资料不足时说「没有」

至于召回准不准、快不快、能不能上线——这些一个都还没验证。但没关系,那不是第一步该操心的事。

先让它跑起来,再让它跑得好。

顺序反了,就会陷在「调参调不明白」里——因为你连基线在哪都不知道。

08

但它撑不过一周:三个问题

跑通那天我挺得意。真拿去用,一周之内被三个问题连着打脸:

① 重启就丢

InMemoryEmbeddingStore 存在 JVM 堆里,服务一重启,半个字都不剩,所有文档要重传。

→ 第 06 篇:迁到 PostgreSQL + pgvector

② 字面命中很差

单路向量只按语义排序。问「biz_order 这张表怎么用」,库里明明有这个词,但向量把它语义打散了,反而排不进 top-3。

→ 第 08~09 篇:加一路 pg_trgm 稀疏召回,再做 RRF 融合

③ 文档查不全

固定 500 字硬切,不认识「一张表的字段」是一个整体。问到某个表的字段,命中片段残缺、还混着别的表,模型只能回一句「资料中没有相关信息」。

→ 第 11 篇:改成段落优先切片;更彻底的做法是按表标题切

这三个问题很典型:都不是「模型不够聪明」,是工程没做到位。

09

一句话总结

第一版 RAG 的价值,不在于它有多能用,而在于它证明了这条路能走。

五个步骤 · 三个 Bean · 不到 200 行

把「文档问答」从一个念头,变成能跑的东西。

剩下的十几篇,都是在这条链路上逐个换零件——换存储、换切片、换检索、加权限、加流式。

附 · 一份整理好的完整资料

这份资料包,我整理好了

本来这篇只打算讲第一版那点代码。写的时候发现,真正卡人的不是「怎么写出来」,而是「写出来之后还差什么」。

所以我把整个项目从头到尾理了一遍,做成一份 《Java 后端做本地 RAG · 项目完整技术资料》(Word 版,12 章,7 张自绘图)。不是概览,是能当手册查的完整版:

章节
里面有什么
系统架构
分层架构图 + 73 个 Java 类的代码模块全景
数据库设计
14 张表逐张给建表语句:9 张业务 + 3 张 RBAC + Agent 记忆 + 向量表
核心链路实现
11 条链路的源码级拆解:入库、切片、混合检索 RRF、Prompt、SSE、记忆、MCP…
安全与治理
JWT 全链路 + RBAC 权限矩阵 + 操作审计 + 密钥加密,一张图看懂七道关
接口总览
61 个 HTTP 端点逐个列出方法与用途
配置项全清单
配置文件里每一项的默认值、作用和调参经验
32 条踩坑全录
现象 → 根因 → 修法,按环境依赖 / 向量检索 / 前端流式 / Agent+MCP / 安全 / 文档漂移分成六组
部署与运维
环境清单、建库脚本、Nginx、换机迁移、常见故障对照

7 张图都是自己画的:分层架构、代码模块全景、数据模型 ER、入库六步、检索链路对比(单路 vs 混合双路 + RRF)、多 Agent 编排、安全七道关。

踩过的坑一条没删。到现在还没做的那几件事,文中也都如实标了出来——哪项是「方案已定、还没落地」,哪项是「完全没动」,都标了。

关注公众号「项目实战AI笔记」,私聊发送「资料包」,直接发你。

下期预告

内存向量库重启就没了:迁 PostgreSQL + pgvector 排坑

把 embeddingStore() 那一行换掉,业务代码一行不动。但装 pgvector 扩展这一步,我卡了整整一轮。

我是AI民工,一个写了多年 Java 的后端。

后面继续写 Java 老兵做 Agent ——真实踩坑,不讲概念只讲根因。

如果觉得这篇有用,欢迎转发给正在做 RAG 的同事——他们大概率刚跑通第一版,正准备踩这三个坑。

相关学习资料