这篇文章基于一个真实前后端项目当前代码状态整理而成。文中的接口、目录、模块划分、调试字段、已完成能力与未完成边界,都以真实实现为准。所有配置、地址、密钥和环境信息均已脱敏处理。

摘要

过去一年里,很多团队做“智能问答”时,第一反应都是先把大模型接进来。但一旦问题从“会不会说”变成“能不能基于我的文档说对”,事情就完全不一样了。

这篇文章想讲的,不是“RAG 是什么”这种泛泛而谈的概念科普,而是一个更工程化的话题:

如果你真的要在项目里落地一套可用的 RAG,第一步应该怎么做?

我这次复盘的项目,没有一上来就把 RAG 塞进一个“大而全”的问答闭环,而是先单独搭了一条 KB-RAG 基座

  • 文档可以真实上传、解析、切片、向量化、建索引
  • 检索可以独立验证,不依赖答案生成链路
  • 前端有专门的 /kb/retrieve 调试页
  • Markdown / Mermaid 渲染也提前做了独立验证

换句话说,这不是“完整企业级问答产品”,而是一套先把检索基座和验证链路打稳的 RAG 工程。


1. 为什么今天还需要认真做 RAG

如果只是做一个“能聊天”的产品,直接调用大模型 API 往往已经够了。

但一旦进入真实业务场景,很快就会遇到几个问题:

  • 模型不知道你的私有文档、PRD、技术方案、会议纪要
  • 模型会“说得像真的”,但你很难知道它依据了什么
  • 同一个问题,不同时间问,答案可能飘
  • 你很难评估:到底是模型不行,还是检索没找到,还是文档根本没入库

这时候,RAG 的价值就出来了。

RAG 不是为了让模型“更聪明”,而是为了让系统在回答前,先从你的知识库里取到更靠谱的上下文

说得更通俗一点:

  • 没有 RAG,大模型像一个记忆力不错但不了解你公司资料的顾问
  • 有了 RAG,大模型像一个会先翻资料、再回答问题的顾问

这个“先翻资料”的过程,才是真正决定系统可用性的关键。


2. 先讲明白:什么是 RAG

RAG,全称是 Retrieval-Augmented Generation,中文一般叫“检索增强生成”。

它不是一个单点技术,而是一条完整链路:

  1. 把知识变成系统能检索的形式
  2. 根据用户问题召回相关片段
  3. 再把这些片段喂给模型生成答案

很多人一提 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:异步入库 worker
  • src/rag/*:解析、chunk、embedding、向量存储、retrieve
  • src/settings/*:系统设置
  • 前端的 /kb/kb/[id]/kb/retrieve:独立验证 KB-RAG
  • 前端的 /markdown:独立验证 Markdown / Mermaid 渲染

整体架构图

基础设施

NestJS 后端

Frontend

用户

Next.js 前端

/kb 列表与上传

/kb/[id] 文档详情

/kb/retrieve 检索验证页

/markdown Markdown/Mermaid 验证页

KB API / Retrieve API

KbService

IngestionProcessor

RagService

DocumentParserService

ChunkingService

EmbeddingService

VectorRepository

PostgreSQL + pgvector + pg_trgm

Redis + BullMQ

一个很重要的工程细节

Prisma schema 里有:

  • KnowledgeBase
  • Document
  • DocumentChunk
  • IngestionJob

DocumentChunkEmbedding 向量表不是 Prisma schema 里的 model,而是启动期用 SQL 保证存在并建立索引。

这是个很现实的取舍:
结构化业务数据用 Prisma,向量列和 HNSW / trigram 索引用 SQL 管。

在真实项目里,这种“ORM + 原生 SQL”的混合方案其实很常见,也很实用。


5. 文档是怎么进入 RAG 的

这个项目已经把文档入库链路做成了真实可用的异步流程,支持:

  • md
  • txt
  • pdf
  • docx

文档入库流程图

用户上传文件

保存原始文件

创建 Document

创建 IngestionJob

入 BullMQ 队列

Worker 开始处理

parse 解析文档

写回 previewText / pageCount

chunk 切片

embed 生成向量

index 建立 chunk + 向量索引

Document.status = ready

异常

清理旧索引

Document.status = failed

写回 errorMessage / previewText

真实实现里的关键步骤

当前上传入口在 src/kb/kb.service.ts,流程是:

  1. 接收文件
  2. 保存到 uploads/ 目录
  3. 创建 Document
  4. 创建 IngestionJob
  5. 投递 BullMQ 队列
  6. 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:把长文档切成可检索的 chunk
  • embed:把 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

query 分析

short 还是 long

lexical recall

query embedding

dense recall

merge candidates

exact / phrase / lexical / dense / hybrid 打标

rerank + diversity

low-confidence 判定

生成 snippet

返回 hits + 调试字段

当前为什么不是“只做向量检索”

因为纯向量检索在很多真实场景下会出现两种问题:

  1. 语义上“有点像”,但并不是用户真正想找的内容
  2. 对短 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;
}

也就是说:

  1. 先用原 query 检索
  2. 若弱命中,再试 normalized query
  3. 若还弱,再试 core term
  4. 多轮之后仍不靠谱,才触发 lowConfidenceTriggered

这背后的设计原则很朴素:

先尽量找准,再决定返回空;不要第一轮就放弃,也不要为了“有结果”硬塞假相关。

query-aware snippet 和调试字段

当前 /api/kb/retrieve 返回的不只是结果列表,还会返回很多调试字段:

  • queryType
  • matchType
  • retrievalConfidence
  • denseScore
  • lexicalScore
  • baseScore
  • rerankedScore
  • finalScore
  • exactMatchedTerms
  • lowConfidenceTriggered
  • originalQuery
  • normalizedQuery
  • fallbackQueries
  • fallbackTriggered
  • fallbackStageUsed
  • timings

这些字段非常关键,因为它们让 retrieve 不再是一个黑盒。

当前前端 /kb/retrieve 页也会把这些信息展示出来,并支持:

  • KB 选择
  • ready 文档筛选
  • TopK / 相似度阈值调参
  • snippet 高亮
  • 按文档聚合结果
  • mock / real backend 模式可见化

7. 从用户 query 到命中结果:真实时序是怎样的

调试结果面板 PostgreSQL EmbeddingService RagService POST /api/kb/retrieve /kb/retrieve 用户 调试结果面板 PostgreSQL EmbeddingService RagService POST /api/kb/retrieve /kb/retrieve 用户 输入 query kbId + query + docIds + topK retrieve(dto) 分析 short/long + 中文归一化 lexical recall embedQuery query embedding dense recall merge / rerank / low-confidence / snippet hits + timings + debug fields JSON 展示结果、高亮 snippet、显示 queryType 等调试信息

8. 做 RAG 时踩过哪些真实坑

RAG 真正难的地方,很多时候不在算法,而在工程细节。

8.1 embedding provider 配置错误,不是“小问题”

这一类问题在真实项目里特别常见:

  • 404
  • 401
  • fetch failed
  • model 不存在
  • 维度不匹配

这个项目里 EmbeddingService 对这类错误做了显式分类,比如:

  • EmbeddingUnauthorized
  • EmbeddingEndpointNotFound
  • EmbeddingModelInvalid
  • EmbeddingNetworkError

而且 worker 失败时会把错误写回 Document.errorMessage,前端可以直接展示失败原因,并停止轮询。

这很重要。
因为“文档 failed 了”远远不够,真正有用的是:为什么 failed。

8.2 pgvector 不是“装了数据库就自然有”

如果 PostgreSQL 没有 pgvectorpg_trgm,系统以前可能要等到 index 阶段才爆炸。用户上传成功、parse 成功、chunk 成功,最后到建索引才发现环境不对,体验很差。

现在的做法更靠谱:

  • 服务启动时检查 vectorpg_trgm
  • 检查向量表和关键索引
  • 检查 embedding 维度
  • 默认 fail-fast
  • 如果关闭 fail-fast,也会在 health 里标出 degraded

这让环境问题能在启动期暴露,而不是在业务高峰期才发现。

8.3 基础设施初始化并发竞争

RAG 链路里常见一个坑:
多个请求同时进来时,如果每条业务路径都顺手 CREATE EXTENSION IF NOT EXISTSCREATE 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 处理细节没做好。

优化前

query: 打断是什么

按原句做 short query 检索

文档中没有完整短语

lexical 很弱

low-confidence 过早触发

返回空结果

优化后

query: 打断是什么

识别为中文问句

normalizedQuery = 打断

original -> normalized -> coreTerm 多阶段召回

merge + rerank + 去重

若仍弱相关才返回空

这个优化不依赖 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,我会非常推荐从这条路径开始:

  1. 先把 ingestion 做成真实链路
  2. 再把 retrieve 做成独立验证入口
  3. 把调试字段暴露出来
  4. 先把“找得到”做对
  5. 最后再去优化“答得好”

很多时候,RAG 成败的分水岭,不在模型,而在这一步。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐