一句话摘要
这篇文章复盘了一套 RAG 知识库从分片、向量化、召回、重排到生成的落地过程,并整理了前后端技术选型、业务链路和实际踩坑。
目录
背景与问题
标准 RAG(检索增强生成)的核心流程可以概括为:分片 -> 索引 -> 召回 -> 重排 -> 生成。
在这个流程里,系统会先把文章或资料切成 Chunk,再通过 Embedding 模型将每个片段转换为向量,并存入向量数据库。用户提问时,问题本身也会被转换为向量,系统再从向量数据库中检索出语义最相近的 个片段。
接下来,系统会用 Cross-encoder 模型对召回结果进行重排,筛选出相关度最高的 个片段。最后,将用户问题和这些高相关片段一并输入给大模型,生成最终回答。
本章小结
RAG 的本质是把“可靠资料检索”接入大模型生成链路,本质就是上下文工程,给大模型提供参考资料。
关键思路
这次项目的后端用 Node.js / Hono,前端用 Vue3,向量数据库基于 Docker + PGVector 搭建,AI 协调控制技术栈选择 LangChain + Vercel AI SDK。
它们在系统中的职责划分如下:
- PGVector:作为向量数据库,利用 Docker 快速容器化部署 PostgreSQL,并加载 PGVector 插件,提供本地化向量存储和相似度匹配服务。
- LangChain.js:负责后端的数据加载、文本切片、文档对象构建,以及向向量数据库发起检索。
- Vercel AI SDK:负责大模型调用、流式数据通讯和前端 UI 状态接管,减轻前端处理流式响应的负担。
为什么需要 Vercel AI SDK
如果不使用 Vercel AI SDK,在 Vue3 中手工开发 AI 对话界面会遇到不少重复问题。
首先是 SSE 或 ReadableStream 的解析。后端返回的通常不是完整 JSON,而是连续的数据流,前端需要手动处理 Chunk 解码、字符串拼接和异常状态。
其次是打字机效果。每次收到数据碎片,都要手动更新消息数组;如果渲染节奏控制不好,很容易引发界面卡顿或频繁重绘。
最后是状态管理。isLoading、停止生成、重新生成、上下文消息数组等状态都需要手动维护,代码很容易变得松散。
Vercel AI SDK 在 Vue 项目中可以通过 @ai-sdk/vue 接管这些状态。UI 直接循环渲染 messages,输入框绑定 input,提交绑定 handleSubmit,加载状态绑定 isLoading,整体实现会轻很多。
它也支持生成式结构化输出(Generative UI)。当需要 AI 返回“目标、热量、餐次”等结构化 JSON 数据时,SDK 可以在接收碎片流期间逐步处理内容,甚至驱动前端生成动态交互组件。
本章小结
后端用 LangChain 管检索链路,底层数据的向量存储交由 PGVector,前端和模型流式交互交给 Vercel AI SDK。这样分工后,系统边界清晰,前后端都不用重复处理多余的组件底座工作。
实践过程
在具体落地时,LangChain 服务端的各个核心组件(Loaders、Splitters、Embeddings、Vector Stores)并没有通过 RunnableSequence 强行声明式串联,而是分散在导入脚本和 RAG 检索层中进行了灵活组装。
步骤1:业务链路与数据融合
在基础 RAG 问答能力之上,这个项目重点融合了用户本地数据。整体处理链路分为四步:
- 读取本地用户数据:通过 SQL 从浏览器 IndexedDB 中读取身高、体重、近期体重变化、热量摄入等属性数据。
- 知识库向量检索:将用户问题向量化,在 PGVector 中检索相关的专业知识内容。
- 融合提示词:把“用户本地私有数据”、“向量库返回的专业资料”和“用户原始问题”汇总并拼装成完整的 Prompt。
- 大模型生成:后端将组装好的 Prompt 发给模型,然后利用 Vercel AI SDK 将数据平滑流式地返回给前端展示。
这条链路的重点突破:本地系统数据解决“个性化背景限制”,知识库检索解决“干货专业依据缺乏”,这两只手抓稳后双管齐下交由大模型统筹整合。
步骤2:数据加载与切块(Loaders & Splitters)
首先,需要从源文件(例如 PDF)中提取文本,并使用 Document Loaders 转换为 LangChain 的 Document。在此步骤我们同时注入了页码、书名等 Metadata,为的是日后给解答提供清晰的引用原出处:
import { Document } from '@langchain/core/documents'
export const createKnowledgeDocumentsFromPages = ({ filePath, pages }: CreateKnowledgeDocumentsOptions) => { const bookTitle = inferBookTitleFromFileName(filePath)
return pages.flatMap(({ pageNumber, text }) => { const pageContent = cleanKnowledgeText(text)
return [ new Document({ metadata: { bookTitle, pageNumber, source: filePath }, pageContent, }), ] })}针对整块长文档,项目接着使用了分块器 RecursiveCharacterTextSplitter 进行更小粒度的切割(Splitters):
new RecursiveCharacterTextSplitter({ chunkOverlap: 300, chunkSize: 1500,})这里限制每个文本块约保留 1500 字符,冗余重叠 300 字符。保留重叠字符是常见但也特别必须的做法,能有效减少连续语义段落正好被“一刀两断”从而在检索中匹配度过低的问题。
步骤3:向量化与入库(Embeddings & Vector Stores)
切分完毕后,再次利用 OpenAIEmbeddings 以及 PGVectorStore 发起 Embedding 从而在向量库(Vector Stores)进行落库存储。
import { PGVectorStore } from '@langchain/community/vectorstores/pgvector'import { OpenAIEmbeddings } from '@langchain/openai'
export const createKnowledgeVectorStore = async () => { const { collectionName, connectionString, tableName } = getRagRuntimeConfig()
return PGVectorStore.initialize(new OpenAIEmbeddings({...}), { collectionName, collectionTableName: `${tableName}_collections`, postgresConnectionOptions: { connectionString }, tableName, })}实际通过脚本批量写入时,必须使用分块批处理循环写入以控制节奏:
for (let index = 0; index < chunks.length; index += batchSize) { await vectorStore.addDocuments(chunks.slice(index, index + batchSize))}避坑踩雷点:在起初导入时遇到了大量的超速失败返回,我最终把 batchSize 强行从官方默认经验值的 50 压到了 10 才安全导入完成避免接口速率限制断连。
步骤4:检索召回流(Retriever Pipeline)
这一步,当用户提问时,我们会将上面初始化的 PGVectorStore 实例转为正式的检索器(Retriever),并调用 .invoke 向模型发起近似语句匹配。这也是虽然没用显式 Runnable 管道串联,但这完全执行了相同手撕控制流的地方。
export const createKnowledgeRetriever = async () => { const { k } = getRagRuntimeConfig() const vectorStore = await createKnowledgeVectorStore()
return vectorStore.asRetriever({ k })}
export const getKnowledgeContext = async (question: string, {...}) => { const retriever = await createRetriever() const documents = await retriever.invoke(question)
return formatKnowledgeContext(documents)}代码中的 k 代表你要让他在库里回捞排名前几最相似的资料条(设为 5)。不能盲目求多:如果召回偏少固然会错失某些要点,但无限度扩大后更会引发严重的文不对题噪音,而且直接把一堆没用的废话喂给 LLM 会消耗大量不必要的 Token 计费。
这一个收尾逻辑,实现了真正的 RAG Pipeline 运作链路过程:
存入流:PDF原始数据 -> Loaders转为Document -> Splitters分块切分 -> Embeddings化 -> PGVector存储检索流:提问触发请求 -> retriever召回最相似片段 -> 附并私有数据合并出终极Prompt -> 交给聊天大模型 -> 前端流式收尾展示本章小结
回顾这个实践脉络会发现:数据层囊括了原文档解析与 Embedding 赋能,检索层揽收下了文本找回与大锅烩操作,再借由大模型与 Vercel 的生成能力构成生成展示层,由此跑通了一个有逻辑的完整 RAG 工作流架构。
结果与复盘
PDF 解析问题
一开始下的书籍是 PDF 版本,用 pdf-parse 转文本后发现很多书是空页。问ai才知道需要 OCR 处理的版本。然后又去网上找了 OCR 版本的 PDF 才成功导入。
批量导入过大
向量化导入数据库时,把每次导入的 Batch Size 从默认的 50 调小到 10,才没有触发 Embedding 接口速率限制。
结构化输出
最初允许 AI 一次生成任意时长的计划,但后来发现当计划超过 14 天时,输出规模会变得过大,后段很容易遭遇内容截断或 JSON 格式错误。
解决方式有三点:
- 从产品侧限制单次最多生成 1-14 天的计划。
- 使用 JSON output 属性,增加预置 JSON 结构的 Prompt 要求。
- 增加强验证机制。如果系统校验到错乱 JSON,就拒绝输出结果,并把错误输出和标准示例再次交给 AI 重整。
TDD
这次实践也让我更深刻地认识到 TDD(测试驱动开发)的重要性。现在已经通过提示词限制,例如 Codex 的 agent.md,强制要求 AI 每次动及源码时都遵循 TDD 模式开发。
也就是:先写预期失败的前置测试 -> 编写业务逻辑 -> 本地测试通过后才视为完成。
这种约束方式配合全量回归测试,可以给后续迭代划定明确边界,也能最大程度降低 AI 修改新逻辑时悄悄破坏老功能的风险。
部分信息可能已经过时




