前段时间遇到一个小问题,后来发现这是个挺常见的坑,顺手整理一篇笔记。
如果你翻过市面上关于 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文档上传、异步索引、状态机管理DocumentIndexServiceparser文件格式解析(Tika)TikaDocumentParserServicechunk段落优先切分,支持长段落滑窗ParagraphTextChunkerembedding调用 OpenAI 兼容 Embedding APIOpenAiCompatibleEmbeddingServicevectorQdrant 向量库读写QdrantVectorStoreServiceretrieval混合检索编排:向量+关键词+融合RetrievalServicererank精排服务调用,挂了自动降级HttpRerankServicechat问答编排:检索→上下文→LLM→引用ChatServiceevaluation检索评测:用例管理+跑分RagEvaluationServiceknowledgebase知识库及其参数配置KnowledgeBaseServiceaudit问答日志持久化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 不会做的事":
1. 端口非标:MySQL 不用 3306,换成 23306;Qdrant 不用 6333,换成 26333。减少端口冲突和被扫的概率。
2. 模型容器不直接暴露:reranker-model 和 embedding-model 容器本身没有网络暴露,外面套了一层 Nginx 做 Authorization: Bearer 校验。
3. 数据持久化到 compose file 所在目录:./data/mysql、./data/qdrant、./data/minio——你删容器、重建容器,数据不丢。
4. 所有敏感信息走 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 知识库配置 > 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 托管前端。
几个值得提的点:
1. ConsoleLayout 是单页面布局,不是多页面跳转。左侧菜单切换只是 router-view 的内容在变,知识库选择和健康状态始终可见。
2. ChunkInspector 页面能按文档查看所有 chunk 的内容和索引位置——索引阶段出了什么问题,不是靠猜,是直接看。
3. Settings 页面把所有检索参数做成了可视化控件(滑块、开关、下拉框),改完参数立刻生效,不需要重启。
当前状态和下一步
主链路已经全部跑通:
- ✅ 文档上传 → 解析 → 分块 → 嵌入 → 入库
- ✅ 混合检索(向量 + 关键词)
- ✅ Rerank 精排(可选,降级可用)
- ✅ 邻居 Chunk 上下文补全
- ✅ 同步 + 流式 LLM 问答
- ✅ 检索评测框架
- ✅ 知识库级参数配置
系列后续 11 篇文章会逐个展开:部署、文档入库、分块策略、Embedding 技术选型、向量库实战、混合检索细节、Rerank 部署与调优、上下文组装技巧、问答产品设计、评测体系搭建,以及最终的工程复盘。
这篇文章想传达的核心观点其实就一个:企业级 RAG 的工程复杂度不在 LLM,在检索。大模型 API 是 RAG 链路的最后一公里,但你得先让前九十九公里跑通了,这一公里才有意义。前九十九公里包括:文档怎么进来、怎么被切、怎么被检索、检索结果怎么被精排和补全、整个链路怎么被观测和优化。
这个项目是为中小企业在"第一天就用正确架构"而准备的——不是 Demo,而是一条从 0 到 1 的工程基线。你也能在这条基线上换模型、换向量库、换前端框架,但链路骨架和模块拆分逻辑是可以复用的。
就写这么多吧,内容比较基础,适合入门回顾。有补充的地方欢迎留言一起完善。
评论 (0)
暂无评论