如果你翻过市面上关于 RAG 的技术文章,大概率会看到这样一个公式:
RAG = 向量数据库 + 大模型 API
这个公式本身没有错——但它描述的是 Demo,不是产品。
当你真的要把 RAG 落到一个企业的知识库场景里,你会发现 Demo 里从来不出现的东西才是真正的工程量:文档怎么入库?长文档怎么切?中文检索关键词怎么抽?向量召回了 20 条,哪 5 条最该送进上下文?rerank 挂了怎么办?用户怎么知道这次回答引用了哪些原文?这还只是"能用"层面。到"好用"层面,你还得回答:检索参数应该是多少?怎么知道自己调参调对了还是调废了?
这篇文章是系列的第一篇,不讲某个单一技术点,而是把整个项目的骨架摊开——讲清楚一条能从 0 走到 1 的企业级 RAG 链路到底长什么样,每个模块解决什么问题,以及我是怎么把它们串起来的。
先定边界:这个项目做什么,不做什么
做项目最怕的不是技术难,而是范围漂移。所以在动第一行代码之前,我先划了三条线:
做什么:
- 完整 RAG 主链路:文档上传 → 解析 → 分块 → 向量化 → 混合检索 → 可选重排 → 上下文组装 → LLM 问答
- 配套管理控制台:知识库管理、文档生命周期、Chunk 级可观测、检索参数调优
- 检索评测闭环:能跑评测集、能看到每次检索命中了什么、能对比不同参数的实际效果
不做什么(至少这一版不做):
- 多租户和权限体系——中小企业场景下,一个知识库通常就一个团队在用,过早抽象租户只会增加无谓复杂度
- 复杂的文档预处理流水线——先用 Apache Tika 做通用解析,够用。什么表格提取、图片 OCR、层级结构保留,这些是第二阶段的事
- 自研 Embedding 模型——直接对接 OpenAI 兼容接口,你接 DeepSeek、接通义千问、接本地部署的 BGE 模型都行,我不绑你
这个边界一划,范围就清楚了:一条链路,一个控制台,一套评测闭环。 下面逐层拆开。
整体架构:一条 RAG 链路穿过 8 个模块
先给一张项目首页。左侧是导航菜单,右侧是工作台面板,顶上有个健康检查的绿灯——证明服务确实在跑。
你看到的菜单栏正好对应了 RAG 主链路上的每个阶段。把这条链路拉直了看,大概是这样的:
用户上传文档 → Tika 解析纯文本 → 段落优先分块 → Embedding 向量化
↓
用户提问 → 向量召回 + 关键词召回 → 可选 Rerank → 邻居 Chunk 补全
↓
组装上下文 → LLM 生成回答 → 带引用的最终响应
这条链路上的每个方块,在后端都对应一个独立的 Java Package:
| Package | 职责 | 核心类 |
|---|---|---|
document | 文档上传、异步索引、状态机管理 | DocumentIndexService |
parser | 文件格式解析(Tika) | TikaDocumentParserService |
chunk | 段落优先切分,支持长段落滑窗 | ParagraphTextChunker |
embedding | 调用 OpenAI 兼容 Embedding API | OpenAiCompatibleEmbeddingService |
vector | Qdrant 向量库读写 | QdrantVectorStoreService |
retrieval | 混合检索编排:向量+关键词+融合 | RetrievalService |
rerank | 精排服务调用,挂了自动降级 | HttpRerankService |
chat | 问答编排:检索→上下文→LLM→引用 | ChatService |
evaluation | 检索评测:用例管理+跑分 | RagEvaluationService |
knowledgebase | 知识库及其参数配置 | KnowledgeBaseService |
audit | 问答日志持久化 | RagQaLog |
每个 Package 都是按领域拆分的,而不是按层拆——document 包里既有 Controller 也有 Service 也有 Entity,不是那种"所有 Controller 放一个包、所有 Service 放一个包"的水平分层。这样做的好处是你改一个功能只需要在一个包里跳,不会在六七个包之间横跳。
前端路由跟后端 Package 是一一对应的——不是巧合,是刻意这么设计的:
// frontend/src/router/index.ts
{ path: "/workspace", component: () => import("@/views/WorkspaceView.vue"), meta: { title: "工作台" } },
{ path: "/knowledge-bases", component: () => import("@/views/KnowledgeBaseView.vue"), meta: { title: "知识库" } },
{ path: "/documents", component: () => import("@/views/DocumentView.vue"), meta: { title: "文档" } },
{ path: "/chunks", component: () => import("@/views/ChunkInspectorView.vue"), meta: { title: "Chunk" } },
{ path: "/settings", component: () => import("@/views/SettingsView.vue"), meta: { title: "参数配置" } },
{ path: "/chat", component: () => import("@/views/ChatView.vue"), meta: { title: "问答" } },
{ path: "/evaluation", component: () => import("@/views/EvaluationView.vue"), meta: { title: "评测" } },
这 7 个页面对应了 RAG 工作流中操作者真正关心的 7 件事:知识库管理、文档入库、Chunk 可观测性、参数调优、问答交互、效果评测。不是"因为能做所以做",而是"操作者在这个环节确实需要看一眼或调一下"。
基础设施:6 个容器 + 1 个 Spring Boot
一个 RAG 系统跑起来,只靠 Spring Boot 是不够的。这 6 个东西缺一不可:
| 服务 | 用途 | 为何不是可选的 |
|---|---|---|
| MySQL 8.4 | 文档元数据、Chunk、问答日志、评测用例 | 结构化数据必须有家 |
| Qdrant | 向量存储与检索 | Dense Vector 的最近邻搜索,MySQL 做不了 |
| MinIO | 原始文件存储 | 文件不能塞数据库,这是常识 |
| Embedding 模型 | 文本转向量 | 外部 API 也行,但本地模型零延迟、零费用 |
| Reranker 模型 | 检索结果精排 | CPU 跑,速度够用,精度提升明显 |
| Nginx(Embedding/Reranker 代理) | API 鉴权 | 模型容器本身没有认证机制,外面套一层 Nginx 做 Bearer Token 校验 |
部署侧我只贴一个启动命令就够了:
docker compose --env-file .env up -d
这个 compose 文件里做了几件"产品化该做但 Demo 不会做的事":
- 端口非标:MySQL 不用 3306,换成 23306;Qdrant 不用 6333,换成 26333。减少端口冲突和被扫的概率。
- 模型容器不直接暴露:reranker-model 和 embedding-model 容器本身没有网络暴露,外面套了一层 Nginx 做
Authorization: Bearer <token>校验。 - 数据持久化到 compose file 所在目录:
./data/mysql、./data/qdrant、./data/minio——你删容器、重建容器,数据不丢。 - 所有敏感信息走 env:
${MINIO_SECRET_KEY}、${QDRANT_API_KEY}、${RERANK_API_KEY}——env 文件进 .gitignore。
主链路拆解:从文档上传到问答返回
下面按顺序走一遍主链路。不贴大段代码,只在关键决策点上展开。
1. 文档入库:5 个状态的异步流水线
文档上传后不是同步处理——大文件解析可能几十秒,让 HTTP 请求等着不现实。所以上传动作只做两件事:文件落到 MinIO,数据库里建一条文档记录(状态 = UPLOADED),然后丢给线程池异步处理。
// DocumentIndexService.java —— 核心入口
public DocumentResponse uploadAndIndex(DocumentUploadRequest request) {
StoredFile storedFile = fileStorageService.upload(file);
RagDocument document = createDocument(file, storedFile, ...);
submitIndexingTask(document.getId()); // 异步
return DocumentResponse.from(document);
}
异步流水线有 5 个状态节点:
UPLOADED → PARSING → CHUNKING → INDEXING → INDEXED
↓ 任意节点失败
FAILED (记录 failureReason)
这里做了一个小但重要的设计:每个状态切换都实时写库。好处是前端轮询文档状态时能看到"进行到哪一步了",坏处是多了一次 UPDATE。在这个吞吐量级别下,多一次 UPDATE 完全不值得省。
Tika 解析阶段用的是 tika-parsers-standard-package,能处理 PDF、Word、PPT、HTML、纯文本等常见格式。解析完成后得到一个纯文本字符串,进入分块。
2. 分块:段落优先,不是无脑切
分块策略决定了检索质量的上限。这个项目用的是段落优先切分:
// ParagraphTextChunker.java —— 核心逻辑
for (String paragraph : normalizedText.split("\n\s*\n")) {
if (paragraph.length() > maxChars) {
splitLongParagraph(chunks, paragraph, maxChars, overlapChars); // 长段落滑窗切
} else if (current.length() + paragraph.length() + 2 > maxChars) {
flushCurrent(chunks, current); // 当前块满了,先归档
current.append(paragraph); // 新段落开新块
} else {
current.append(paragraph); // 段落加到当前块里
}
}
逻辑很直白:以段落为最小单元拼块,拼不下就开新块。只有单一段落超过 maxChars(默认 1000 字符)时才做滑窗切分,窗口重叠 150 字符。
为什么这么做?因为知识库文档的段落天然是有语义边界的。你把一个段落的最后两句和下一个段落的前两句硬拼到一起,生成的向量既不"像"上一段也不"像"下一段,检索时两头都不讨好。
3. Embedding:对接 OpenAI 兼容协议,模型随便换
Embedding 层没有任何自研逻辑,就是一个 HTTP 调用。接口兼容 OpenAI 的 /v1/embeddings 格式。你填 MODEL_EMBEDDING_BASE_URL 接 DeepSeek 的 API 也行,填 http://embedding:80 接本地 BGE 模型也行。
rag:
model:
embedding:
base-url: ${MODEL_EMBEDDING_BASE_URL:} # 支持任意兼容服务
model: ${MODEL_EMBEDDING_MODEL:text-embedding-3-large}
向量维度跟模型走:用 BGE base 就是 768 维,用 text-embedding-3-large 就是 3072 维。Qdrant 的 collection 创建时维度必须匹配,所以换模型 = 换 collection = 重建索引。这个约束是向量检索的本质决定的,跟架构无关。
4. 混合检索:向量 + 关键词 + 融合排序 + 可选 Rerank
这是整个项目最值得展开的部分。纯向量检索有一个致命问题:对专有名词、缩写、编号不敏感。比如你搜"HR-2024-003",向量空间里这条 chunk 跟你的 query 向量的余弦相似度可能并不高,因为 Embedding 模型不认识你公司内部的文档编号规则。
所以检索做了两条腿:
左腿——向量召回: 用 question 转 query vector → Qdrant 搜 topK(默认 20)。这是语义层面的召回,覆盖面广。
右腿——关键词召回: 从 question 里抽关键词 → MySQL 全文索引搜索 → 全文索引没命中就降级到 LIKE。这是字面层面的补召回,专门抓专有名词。
关键词抽取做了中文适配:
// 对包含中文的 token 做 4 字窗口切分
// "核心存储有哪些" → "核心存储"、"心存储有"、"存储有哪些" ...
if (containsCjk(token) && token.length() > CJK_NGRAM_LENGTH) {
for (int i = 0; i <= token.length() - CJK_NGRAM_LENGTH; i++) {
keywords.add(token.substring(i, i + CJK_NGRAM_LENGTH));
}
}
这个做法的动机是:中文不像英文有天然的空格分词边界,用户输"核心存储有哪些功能",如果不对它做 n-gram,全文索引可能一个字都匹配不上。
两路结果合并后,每路有自己的分数:向量结果带 cosine 分数,关键词结果给一个固定弱分数(0.2)。融合排序按总分降序取前 K。
如果开启了 Rerank(RAG_RERANK_ENABLED=true),融合后的候选集会再送一遍 Reranker 做精排。Reranker 是一个 Cross-Encoder,把 question 和每个候选 chunk 拼在一起打分,精度显著高于向量相似度。但它有一个风险——网络挂了怎么办?
if (Boolean.TRUE.equals(options.rerankEnabled()) && rerankService.available()) {
try {
return rerank(question, candidates, options); // 精排
} catch (RuntimeException ex) {
log.warn("Rerank failed, fallback to fused retrieval score"); // 自动降级到融合分
}
}
return sortByFusedScore(candidates, options); // 降级路径
关键设计:rerank 是增强路径,不是主链路。它挂了,系统回退到融合排序继续工作,不影响问答可用性。
5. 邻居 Chunk 补全与上下文组装
一段知识很可能跨了两个 chunk。比如"API 密钥获取方式如下:"在第 5 个 chunk,实际获取步骤在第 6 个 chunk。如果只送第 5 个进上下文,LLM 只能编一个答案。
所以检索结果出来之后,对每个命中的 chunk 向前后各扩展 N 个邻居(默认前后各 1 个):
int startIndex = Math.max(0, chunk.getChunkIndex() - options.neighborBefore());
int endIndex = chunk.getChunkIndex() + options.neighborAfter();
List<RagChunk> neighborChunks = chunkRepository
.findByDocumentIdAndChunkIndexBetweenOrderByChunkIndexAsc(
chunk.getDocument().getId(), startIndex, endIndex);
补全后的 chunk 列表再按 context-max-chars(默认 8000)截断——先到先得,超出就丢弃。这是为了避免上下文超出 LLM 窗口,同时确保最相关的 chunk 一定在前面。
上下文组装格式是这样的:
引用 1,文档:《员工手册 v3》,片段:5
(第 5 个 chunk 的完整内容)
引用 2,文档:《员工手册 v3》,片段:6
(第 6 个 chunk 的完整内容)
LLM 拿到的 prompt 里明确告诉它"引用来源怎么标注",回答里就能带引用编号。
6. 问答编排:两种模式 + 审计日志
ChatService 提供三种调用方式:
- 同步问答(
POST /api/chat):检索 → LLM 生成 → 一次性返回 answer + citations - 流式问答(
POST /api/chat/stream):SSE 推送 token,Java 21 虚拟线程跑,事务用 TransactionTemplate 手动管理避免 SSE 长事务 - 仅检索(
POST /api/chat/retrieve):只跑检索不调 LLM,返回命中了哪些 chunk
每次问答都会记一条审计日志(RagQaLog):question、answer、retrievedChunkIds、modelName、latencyMs。不记 IP,不记 user——中小企业的第一版不需要这些东西。
配置设计:全局默认 + 知识库覆盖 + 请求级覆盖
检索参数不是写死的。每个知识库可以有自己的默认参数,每次请求还可以临时覆盖。优先级是:
请求参数 > 知识库配置 > application.yml 全局默认
# application.yml —— 全局默认
rag:
retrieval:
final-top-k: 5
vector-top-k: 20
keyword-top-k: 20
rerank-enabled: false # 默认关闭,配了 rerank 服务再开
neighbor-enabled: true
neighbor-before: 1
neighbor-after: 1
context-max-chars: 8000
这个设计让"调参"这件事从"改代码→重新部署"变成了"在前端 Settings 页面调滑块→点保存→在 Chat 页面直接试效果"。评测闭环也因此成为可能——你可以在不同参数组合下跑同一套评测集,用数据说话,而不是凭感觉。
前端:不是单纯"套个壳"
前端用 Vue 3 + Naive UI + Pinia,Vite 构建完成后直接打到 src/main/resources/static/ 下面,Spring Boot 一个 JAR 就能同时服务前端静态资源和后端 API。不用额外部署 Nginx 托管前端。
几个值得提的点:
- ConsoleLayout 是单页面布局,不是多页面跳转。左侧菜单切换只是 router-view 的内容在变,知识库选择和健康状态始终可见。
- ChunkInspector 页面能按文档查看所有 chunk 的内容和索引位置——索引阶段出了什么问题,不是靠猜,是直接看。
- Settings 页面把所有检索参数做成了可视化控件(滑块、开关、下拉框),改完参数立刻生效,不需要重启。
当前状态和下一步
主链路已经全部跑通:
- ✅ 文档上传 → 解析 → 分块 → 嵌入 → 入库
- ✅ 混合检索(向量 + 关键词)
- ✅ Rerank 精排(可选,降级可用)
- ✅ 邻居 Chunk 上下文补全
- ✅ 同步 + 流式 LLM 问答
- ✅ 检索评测框架
- ✅ 知识库级参数配置
问答页面和评测页面还需要产品级重做——当前的功能可以跑,但在交互体验和数据可视化上还只是"能用"的水平。评测页面需要加更多的可视化呈现(比如 nDCG 曲线、参数对比视图),问答页面需要更好的引用展示和追问体验。
系列后续 11 篇文章会逐个展开:部署、文档入库、分块策略、Embedding 技术选型、向量库实战、混合检索细节、Rerank 部署与调优、上下文组装技巧、问答产品设计、评测体系搭建,以及最终的工程复盘。
写在最后
这篇文章想传达的核心观点其实就一个:企业级 RAG 的工程复杂度不在 LLM,在检索。大模型 API 是 RAG 链路的最后一公里,但你得先让前九十九公里跑通了,这一公里才有意义。前九十九公里包括:文档怎么进来、怎么被切、怎么被检索、检索结果怎么被精排和补全、整个链路怎么被观测和优化。
这个项目是为中小企业在"第一天就用正确架构"而准备的——不是 Demo,而是一条从 0 到 1 的工程基线。你可以在这条基线上换模型、换向量库、换前端框架,但链路骨架和模块拆分逻辑是可以复用的。