从零搭建可落地的 RAG 基座:概念、架构设计、工程实现与实践复盘
本文基于真实项目经验,提出了一种工程化的RAG(检索增强生成)落地策略。不同于直接构建完整问答系统,作者主张优先搭建独立的KB-RAG基座,将文档上传、解析、切片、向量化、检索等核心环节拆解为可独立验证的模块。项目采用NestJS+Next.js架构,通过异步工作流处理文档入库,并设计了专门的调试页面验证检索效果。文章强调RAG的真正价值在于让系统基于私有知识库生成可靠答案,而非简单接入大模型。文
这篇文章基于一个真实前后端项目当前代码状态整理而成。文中的接口、目录、模块划分、调试字段、已完成能力与未完成边界,都以真实实现为准。所有配置、地址、密钥和环境信息均已脱敏处理。
摘要
过去一年里,很多团队做“智能问答”时,第一反应都是先把大模型接进来。但一旦问题从“会不会说”变成“能不能基于我的文档说对”,事情就完全不一样了。
这篇文章想讲的,不是“RAG 是什么”这种泛泛而谈的概念科普,而是一个更工程化的话题:
如果你真的要在项目里落地一套可用的 RAG,第一步应该怎么做?
我这次复盘的项目,没有一上来就把 RAG 塞进一个“大而全”的问答闭环,而是先单独搭了一条 KB-RAG 基座:
- 文档可以真实上传、解析、切片、向量化、建索引
- 检索可以独立验证,不依赖答案生成链路
- 前端有专门的
/kb/retrieve调试页 - Markdown / Mermaid 渲染也提前做了独立验证
换句话说,这不是“完整企业级问答产品”,而是一套先把检索基座和验证链路打稳的 RAG 工程。
1. 为什么今天还需要认真做 RAG
如果只是做一个“能聊天”的产品,直接调用大模型 API 往往已经够了。
但一旦进入真实业务场景,很快就会遇到几个问题:
- 模型不知道你的私有文档、PRD、技术方案、会议纪要
- 模型会“说得像真的”,但你很难知道它依据了什么
- 同一个问题,不同时间问,答案可能飘
- 你很难评估:到底是模型不行,还是检索没找到,还是文档根本没入库
这时候,RAG 的价值就出来了。
RAG 不是为了让模型“更聪明”,而是为了让系统在回答前,先从你的知识库里取到更靠谱的上下文。
说得更通俗一点:
- 没有 RAG,大模型像一个记忆力不错但不了解你公司资料的顾问
- 有了 RAG,大模型像一个会先翻资料、再回答问题的顾问
这个“先翻资料”的过程,才是真正决定系统可用性的关键。
2. 先讲明白:什么是 RAG
RAG,全称是 Retrieval-Augmented Generation,中文一般叫“检索增强生成”。
它不是一个单点技术,而是一条完整链路:
- 把知识变成系统能检索的形式
- 根据用户问题召回相关片段
- 再把这些片段喂给模型生成答案
很多人一提 RAG,就想到“向量库”。但真正做起来会发现,RAG 远不只是“加一个向量库”。
几种常见方案的区别
| 方案 | 怎么做 | 优点 | 缺点 |
|---|---|---|---|
| 纯聊天 | 只把用户问题发给大模型 | 接入快 | 不知道私有知识,容易幻觉 |
| 纯关键词搜索 | 用关键词匹配文档 | 可解释、便宜 | 语义弱,措辞一变就容易搜不到 |
| 纯向量检索 | 文档和问题都做 embedding | 语义更强 | 容易“看起来像相关”,但不够准 |
| 混合检索 | 关键词 + 向量一起召回 | 准确率和召回率更平衡 | 工程复杂度更高 |
| RAG | 检索 + 上下文组装 + 生成 | 能做私有知识问答 | 需要完整工程闭环 |
RAG 的难点,从来都不只是检索到结果,而是:
- 文档怎么解析
- chunk 怎么切
- metadata 怎么保留
- short query 和 long query 怎么区分
- 低相关时是返回空,还是硬给一条“像相关”的结果
- 调试字段是否足够解释系统行为
这也是为什么我更愿意把它叫做“RAG 基座”,而不是“向量检索功能”。
3. 这个项目里的真实目标:先做独立 KB-RAG 基座
这个项目当前的工程策略很明确:
先把独立 KB-RAG 链路打通,再补评测、观测与工程稳定性。
这背后的工程价值很大。
如果一开始就把文档上传、解析、chunk、embedding、检索、结果展示全部揉在一起,排查问题会非常痛苦。你很难知道:
- 是文档没入库成功
- 还是 chunk 切坏了
- 还是 embedding 配错了
- 还是 retrieve 没命中
- 还是前端把结果解释错了
所以当前项目的思路是:
- 先把 ingestion 做实
- 再把 retrieve 做成独立验证入口
- 把调试字段暴露出来
- 先观察系统真实行为
这是一条很“工程化”的路径:先把不确定性拆开,再逐步闭环。
4. 当前项目的真实落地架构
后端是 NestJS,前端是 Next.js。围绕独立 KB-RAG,相关能力被拆成几个相对清晰的模块:
src/kb/*:知识库、文档、上传、重建索引、删除src/workers/ingestion.processor.ts:异步入库 workersrc/rag/*:解析、chunk、embedding、向量存储、retrievesrc/settings/*:系统设置- 前端的
/kb、/kb/[id]、/kb/retrieve:独立验证 KB-RAG - 前端的
/markdown:独立验证 Markdown / Mermaid 渲染
整体架构图
一个很重要的工程细节
Prisma schema 里有:
KnowledgeBaseDocumentDocumentChunkIngestionJob
但 DocumentChunkEmbedding 向量表不是 Prisma schema 里的 model,而是启动期用 SQL 保证存在并建立索引。
这是个很现实的取舍:
结构化业务数据用 Prisma,向量列和 HNSW / trigram 索引用 SQL 管。
在真实项目里,这种“ORM + 原生 SQL”的混合方案其实很常见,也很实用。
5. 文档是怎么进入 RAG 的
这个项目已经把文档入库链路做成了真实可用的异步流程,支持:
mdtxtpdfdocx
文档入库流程图
真实实现里的关键步骤
当前上传入口在 src/kb/kb.service.ts,流程是:
- 接收文件
- 保存到
uploads/目录 - 创建
Document - 创建
IngestionJob - 投递 BullMQ 队列
- worker 异步处理
对应的 worker 在 src/workers/ingestion.processor.ts 里,核心流程非常清晰:
await this.updateProgress(jobId, documentId, "parse", 5);
const parsed = await this.parserService.parse({
filePath: document.storagePath,
fileName: document.filename,
mimeType: document.mimeType,
});
await this.updateProgress(jobId, documentId, "chunk", 35);
const chunks = this.chunkingService.chunkDocument(parsed);
await this.updateProgress(jobId, documentId, "embed", 60);
const embeddings = await this.embeddingService.embedDocuments(
chunks.map((chunk) => chunk.embeddingText),
);
await this.updateProgress(jobId, documentId, "index", 85);
await this.vectorRepository.storeDocumentIndex({
kbId: document.kbId,
documentId: document.id,
fileName: document.filename,
chunks,
embeddings,
});
这段代码背后做的事情,正是典型的 RAG ingestion 流程:
parse:把原始文档变成干净、结构化的文本段chunk:把长文档切成可检索的 chunkembed:把 chunk 变成向量index:把文本和向量都落到数据库里
解析器不是“一把梭”
项目里对不同文档格式用了不同 parser:
- Markdown / TXT:结构化处理标题、列表、引用、代码块
- PDF:用
pdfjs-dist提取文本 - DOCX:用
mammoth转 HTML,再提取标题层级
这里有一个非常真实的细节:
Markdown 里的代码块和 Mermaid 代码块没有被直接粗暴地扔进 embedding。
当前实现会对这类块做更轻量的摘要式保留,比如把 Mermaid 代码块当成“图表代码块”的语义描述处理。这种做法很重要,因为大量原始代码文本往往会拉低向量质量。
previewText / chunkCount / indexedAt / errorMessage 为什么重要
这四个字段看起来很普通,但其实是工程体验的关键:
previewText:让前端能在文档列表里快速看到解析结果,不需要先打开详情chunkCount:能知道索引规模是否合理indexedAt:能判断当前文档是否真的完成建索引errorMessage:失败时能定位问题,而不是只看到一个“failed”
一个真正可维护的 RAG 系统,不应该把所有状态都藏在日志里。
6. 检索链路是怎么设计的
这套系统当前不是纯向量检索,而是 hybrid retrieval。
也就是说,它会同时走两条路:
- dense recall:基于 embedding 的语义召回
- lexical recall:基于关键词 / trigram / phrase 的词面召回
最后再 merge、rerank、去重,并根据 query 类型做不同策略。
检索流程图
当前为什么不是“只做向量检索”
因为纯向量检索在很多真实场景下会出现两种问题:
- 语义上“有点像”,但并不是用户真正想找的内容
- 对短 query、专有名词、缩写、标题词非常不稳定
这个项目里的 lexical recall 使用了 PostgreSQL 的 pg_trgm,配合内容、section 和文件名三类信号来算词面分数。
src/rag/vector.repository.ts 里这段就很有代表性:
(
CASE WHEN lower(c.content) LIKE ${likePattern} THEN 0.62 ELSE 0 END +
CASE WHEN lower(COALESCE(c.section, '')) LIKE ${likePattern} THEN 0.24 ELSE 0 END +
CASE WHEN lower(d.filename) LIKE ${likePattern} THEN 0.18 ELSE 0 END +
GREATEST(
similarity(lower(c.content), ${normalizedQuery}),
similarity(lower(COALESCE(c.section, '')), ${normalizedQuery}),
similarity(lower(d.filename), ${normalizedQuery})
) * 0.26
) AS "lexicalScore"
可以看到,它不是简单 LIKE 一下就完了,而是把:
- 内容命中
- section 命中
- 文件名命中
- trigram 相似度
组合成了一个更细的 lexical score。
short query 和 long query 不同对待
当前 RagService 会先分析 query 类型。短 query 会更偏向:
- exact / phrase / lexical 信号
- 更严格的低相关过滤
- 避免“假相关”结果混进来
长 query 则相对更依赖 dense recall。
这非常合理,因为:
- “SSE”“Mermaid”“打断”这类短 query,需要更看重词面和标题
- “如何在实时语音场景里处理打断与重连”这类长 query,更需要语义召回
中文问句归一化:一个很真实的坑
这个项目最近刚修过一个典型问题:
- 搜“打断”,能命中
- 搜“打断是什么”,却可能空结果
根因并不是“long query 分流错了”,而是:
- 系统把“打断是什么”仍当成 short query
- lexical recall 更容易按整句匹配
- 文档里通常有“打断”,但不一定有完整短语“打断是什么”
- low-confidence 在第一轮就可能判空
为了解这个问题,现在检索前加了一个轻量的中文问句归一化:
export function normalizeRetrieveQueryIntent(query: string) {
const originalQuery = normalizeInlineWhitespace(query);
const questionCandidate = stripQuestionPunctuation(originalQuery);
const normalizedQuery =
extractChineseQuestionSubject(questionCandidate) ||
questionCandidate ||
originalQuery;
const coreTerms = extractIntentCoreTerms(normalizedQuery, originalQuery);
return {
originalQuery,
normalizedQuery,
coreTerms,
fallbackQueries: uniqueNonEmpty([normalizedQuery, ...coreTerms]),
normalizationApplied: normalizeSearchText(normalizedQuery) !== normalizeSearchText(originalQuery),
};
}
它会把这类问句尽量归一化成更适合检索的核心表达:
打断是什么→打断什么是打断→打断打断是什么意思→打断语音打断是什么→语音打断
检索不再只试一轮
更关键的是,当前 short query 不是一轮没命中就直接空了,而是做了多阶段降级召回:
const recallStages = this.buildRecallStages(queryNormalization);
// original -> normalized -> coreTerm
for (const stage of recallStages) {
const stageRows = await this.recallStage(...);
denseRows.push(...stageRows.denseRows);
lexicalRows.push(...stageRows.lexicalRows);
const interim = this.evaluateCandidates(...);
if (!shouldTryNext) break;
}
也就是说:
- 先用原 query 检索
- 若弱命中,再试 normalized query
- 若还弱,再试 core term
- 多轮之后仍不靠谱,才触发
lowConfidenceTriggered
这背后的设计原则很朴素:
先尽量找准,再决定返回空;不要第一轮就放弃,也不要为了“有结果”硬塞假相关。
query-aware snippet 和调试字段
当前 /api/kb/retrieve 返回的不只是结果列表,还会返回很多调试字段:
queryTypematchTyperetrievalConfidencedenseScorelexicalScorebaseScorererankedScorefinalScoreexactMatchedTermslowConfidenceTriggeredoriginalQuerynormalizedQueryfallbackQueriesfallbackTriggeredfallbackStageUsedtimings
这些字段非常关键,因为它们让 retrieve 不再是一个黑盒。
当前前端 /kb/retrieve 页也会把这些信息展示出来,并支持:
- KB 选择
- ready 文档筛选
- TopK / 相似度阈值调参
- snippet 高亮
- 按文档聚合结果
- mock / real backend 模式可见化
7. 从用户 query 到命中结果:真实时序是怎样的
8. 做 RAG 时踩过哪些真实坑
RAG 真正难的地方,很多时候不在算法,而在工程细节。
8.1 embedding provider 配置错误,不是“小问题”
这一类问题在真实项目里特别常见:
404401fetch failed- model 不存在
- 维度不匹配
这个项目里 EmbeddingService 对这类错误做了显式分类,比如:
EmbeddingUnauthorizedEmbeddingEndpointNotFoundEmbeddingModelInvalidEmbeddingNetworkError
而且 worker 失败时会把错误写回 Document.errorMessage,前端可以直接展示失败原因,并停止轮询。
这很重要。
因为“文档 failed 了”远远不够,真正有用的是:为什么 failed。
8.2 pgvector 不是“装了数据库就自然有”
如果 PostgreSQL 没有 pgvector 或 pg_trgm,系统以前可能要等到 index 阶段才爆炸。用户上传成功、parse 成功、chunk 成功,最后到建索引才发现环境不对,体验很差。
现在的做法更靠谱:
- 服务启动时检查
vector和pg_trgm - 检查向量表和关键索引
- 检查 embedding 维度
- 默认 fail-fast
- 如果关闭 fail-fast,也会在 health 里标出 degraded
这让环境问题能在启动期暴露,而不是在业务高峰期才发现。
8.3 基础设施初始化并发竞争
RAG 链路里常见一个坑:
多个请求同时进来时,如果每条业务路径都顺手 CREATE EXTENSION IF NOT EXISTS、CREATE INDEX IF NOT EXISTS,理论上“幂等”,实践里还是容易出竞争问题。
这个项目现在把这部分职责收敛到了启动期,并通过 advisory lock 保证初始化串行化。
这是一个典型的工程经验:
DDL 应该尽量离开业务请求路径。
8.4 BullMQ 队列隔离
多环境共用 Redis 时,如果队列没有隔离,最容易出现这种诡异问题:
- A 环境创建的 job
- 被 B 环境 worker 消费
- 当前环境看起来 job 一直 queued
现在的实现会统一 producer 和 consumer 两端的 queue prefix,并支持环境级配置。默认 prefix 甚至会带数据库指纹,尽量避免串队列。
这类问题平时很少写进技术文章,但在真实部署里非常致命。
8.5 文件名乱码、Markdown 代码块、PDF 扫描件
这些也都是很真实的坑:
- 文件名可能出现 mojibake,需要做规范化
- Markdown 里可能有大量代码块、Mermaid、表格、链接
- PDF 可能是扫描版,没有 OCR 就几乎拿不到文本
- DOCX 标题层级未必稳定
这个项目里:
- 文件名会经过
normalizeDocumentFilename - Markdown parser 会对结构和特殊块做更细处理
- PDF 如果抽不出文本,会明确报“不支持扫描版 PDF”
- chunking 会尽量按标题、页面、段落和自然边界切,不是单纯按字符硬切
8.6 “打断”能搜到,“打断是什么”搜不到
这是最有代表性的一个坑。
它说明一个事实:
retrieve 的问题,经常不是“大方向错了”,而是 query 处理细节没做好。
优化前
优化后
这个优化不依赖 LLM 改写,也没有把 query 暴力拆成单字,而是用一层更可控、可维护的规则化归一化和 fallback 策略,把 short query 检索补得更稳。
9. 做了哪些优化,效果如何
这个项目当前的优化,基本都围绕两个目标:
- 让 RAG 更稳
- 让 RAG 更可解释
当前已经落地的优化点
| 优化点 | 真实实现 | 工程价值 |
|---|---|---|
| 文件名规范化 | normalizeDocumentFilename |
避免乱码污染 UI 和检索 |
| Markdown 结构化解析 | 标题、列表、代码块、Mermaid 分开处理 | 降低脏文本对 embedding 的伤害 |
| parser-aware chunking | 标题、页面、段落、overlap 联合切分 | 比纯字符切片自然 |
| hybrid retrieval | pg_trgm + dense recall |
提升短 query 和专有名词命中率 |
| query 调试字段 | queryType / matchType / retrievalConfidence 等 |
让检索不再是黑盒 |
| 中文问句归一化 | 什么是X / X是什么 / X是什么意思 等规则 |
修复 short query 问句表现 |
| short query 降级召回 | original → normalized → coreTerm | 减少“有词但搜不到”的断层 |
| low-confidence 兜底 | 多轮后才判空 | 保持“宁可空,不要假相关” |
| 失败态 / 重试 / 停轮询 | /kb + useJobPoller |
把 ingestion 过程做实 |
| 批量写入 | createMany + 批量 insert embeddings |
中等文档入库更稳 |
| 检索耗时观测 | tookMs + stage timings |
更容易定位性能瓶颈 |
| mock/real 环境可见化 | /kb/retrieve 页面 badge |
减少“以为自己在联调真实后端”的误判 |
一个很喜欢的细节:前端不是“薄壳”
很多 RAG 项目把前端当成一个纯展示层,但这个项目里的前端其实承担了很重要的验证职责:
/kb:上传、轮询活动 job、失败态、重试、删除/kb/[id]:查看文档详情、重建索引、删除/kb/retrieve:独立检索验证和调试/markdown:独立验证 Markdown / Mermaid 渲染
尤其是 /markdown 这页很值得一提。
当前 MarkdownMessage 组件已经支持:
- Markdown 渲染
- 代码高亮
- Mermaid 图表渲染
- Mermaid 渲染失败时回退为源码展示
- 引用角标跳转和 source list 展示
虽然它当前只是一个独立渲染验证页,但这个底座本身非常有价值。
这就是一种很健康的工程节奏:先验证底层能力,再考虑更大的功能闭环。
10. 当前阶段的成果边界
这一段必须说实话。
已经真实可用的部分
当前项目已经完成了这些事情:
- 独立 KB ingestion / retrieve 链路真实可用
- 支持
md / txt / pdf / docx入库 - 真实链路是
upload -> parse -> chunk -> embed -> index -> ready - 使用 PostgreSQL +
pgvector - 使用
pg_trgm + dense recall做 hybrid retrieval - 检索返回丰富调试字段
- 前端有
/kb/retrieve独立验证页 - 前端有
/markdown独立验证 Markdown / Mermaid
当前阶段明确的边界
当前阶段仍然有这些明确边界:
- 当前聚焦的是独立 KB ingestion / retrieve,不是完整问答系统
- 当前还没有 OCR,所以扫描版 PDF 不在支持范围内
- 当前还没有 reranker / cross-encoder / synonym alias 这类更强增强能力
- 当前更强调“检索结果是否可信、是否可解释”,而不是“最终答案生成效果”
所以,如果要给当前状态一个准确定位,我会这样描述:
这套系统已经不是“概念验证”,但也不是“最终形态”。
它更准确的身份,是一套可落地、可验证、可继续演进的独立 RAG 基座。
11. 后续如何继续演进
如果下一阶段继续推进,我会建议按下面顺序做,而不是一口气同时开很多战线。
1. 先补自动化评测和回归数据集
当前 retrieve 已经有丰富调试字段,但如果没有稳定的回归样例,后续优化仍然容易“修了一个 query,退化另一批 query”。
更适合下一步补齐的是:
- 固定 fixture 文档
- 短 query / 长 query 回归集
- 无命中 / 低相关兜底验证
- reindex / delete 清理验证
2. 继续强化 query rewrite 的轻量能力
当前已经有中文问句归一化和 short query fallback,但还可以继续增强:
- synonym / alias 词表
- 中英混合 query 归一化
- 专有名词缩写映射
- 标题词优先召回
3. 再考虑 OCR、reranker、synonym / alias 之外的增强
这些都很有价值,但不适合作为第一阶段的重点。
更合理的顺序是:
- 先把可观测性和基线打稳
- 再做更强的召回与排序增强
12. 总结
RAG 这件事,最容易被低估的地方,是它看起来像一个“模型功能”,其实更像一个“系统工程”。
真正能落地的 RAG,绝不是:
- 接个 embedding API
- 建个向量库
- 再让大模型“参考一下”
而是要把下面这些东西一并做好:
- 文档解析
- chunk 切分
- metadata 保留
- 索引存储
- hybrid retrieval
- 低相关兜底
- 调试字段
- 失败态处理
- 可视化验证入口
- 部署期基础设施检查
这个项目当前最有价值的地方,不是“它已经做完了一个大而全的系统”,而是它选择了一个更健康的工程路径:
先把独立 RAG 基座打稳,再做更复杂的能力叠加。
这条路看起来慢一点,但从长期看,通常更快,也更靠谱。
如果你也准备在项目里真正落地 RAG,我会非常推荐从这条路径开始:
- 先把 ingestion 做成真实链路
- 再把 retrieve 做成独立验证入口
- 把调试字段暴露出来
- 先把“找得到”做对
- 最后再去优化“答得好”
很多时候,RAG 成败的分水岭,不在模型,而在这一步。
更多推荐


所有评论(0)