从 0 到 1 构建企业级 RAG:一个中小企业可落地版本的完整架构

0 阅读11分钟

如果你翻过市面上关于 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 APIOpenAiCompatibleEmbeddingService
vectorQdrant 向量库读写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 不会做的事":

  1. 端口非标:MySQL 不用 3306,换成 23306;Qdrant 不用 6333,换成 26333。减少端口冲突和被扫的概率。
  2. 模型容器不直接暴露:reranker-model 和 embedding-model 容器本身没有网络暴露,外面套了一层 Nginx 做 Authorization: Bearer <token> 校验。
  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 <= 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 托管前端。

几个值得提的点:

  1. ConsoleLayout 是单页面布局,不是多页面跳转。左侧菜单切换只是 router-view 的内容在变,知识库选择和健康状态始终可见。
  2. ChunkInspector 页面能按文档查看所有 chunk 的内容和索引位置——索引阶段出了什么问题,不是靠猜,是直接看。
  3. Settings 页面把所有检索参数做成了可视化控件(滑块、开关、下拉框),改完参数立刻生效,不需要重启。

当前状态和下一步

主链路已经全部跑通:

  • ✅ 文档上传 → 解析 → 分块 → 嵌入 → 入库
  • ✅ 混合检索(向量 + 关键词)
  • ✅ Rerank 精排(可选,降级可用)
  • ✅ 邻居 Chunk 上下文补全
  • ✅ 同步 + 流式 LLM 问答
  • ✅ 检索评测框架
  • ✅ 知识库级参数配置

问答页面和评测页面还需要产品级重做——当前的功能可以跑,但在交互体验和数据可视化上还只是"能用"的水平。评测页面需要加更多的可视化呈现(比如 nDCG 曲线、参数对比视图),问答页面需要更好的引用展示和追问体验。

系列后续 11 篇文章会逐个展开:部署、文档入库、分块策略、Embedding 技术选型、向量库实战、混合检索细节、Rerank 部署与调优、上下文组装技巧、问答产品设计、评测体系搭建,以及最终的工程复盘。

写在最后

这篇文章想传达的核心观点其实就一个:企业级 RAG 的工程复杂度不在 LLM,在检索。大模型 API 是 RAG 链路的最后一公里,但你得先让前九十九公里跑通了,这一公里才有意义。前九十九公里包括:文档怎么进来、怎么被切、怎么被检索、检索结果怎么被精排和补全、整个链路怎么被观测和优化。

这个项目是为中小企业在"第一天就用正确架构"而准备的——不是 Demo,而是一条从 0 到 1 的工程基线。你可以在这条基线上换模型、换向量库、换前端框架,但链路骨架和模块拆分逻辑是可以复用的。