AI 应用工程入门(七):RAG 的代码结构应该怎么拆?
用普通人能听懂的方式解释向量数据库存什么,以及 RAG 入库和查询两条链路怎么组织。
上一篇讲清了 RAG 的概念,这一篇把它落到工程结构上。RAG 不是一个接口里写完所有逻辑,而是至少要拆成入库和查询两条链路。
题目 1:向量数据库到底存什么?
标准答案:向量数据库通常同时存 id、content、embedding 和 metadata。content 是原始文本片段,embedding 用来相似度检索,metadata 用来记录来源和权限。
工程要点:只存向量不够。最终塞进 Prompt 的是 content,展示引用需要 metadata,追踪具体片段需要 id。一个 chunk 没有来源信息,后期会很难维护。
题目 2:RAG 最少需要哪两个流程?
标准答案:入库流程和查询流程。入库是文档解析、切片、生成 embedding、存储;查询是问题 embedding、检索 chunk、拼 Prompt、生成答案。
工程要点:很多新手只关注问答接口,但文档入库质量决定问答质量上限。文档解析不干净、chunk 切得差、metadata 丢失,后面的模型很难补救。
题目 3:目录结构可以怎么设计?
标准答案:可以把 AI 能力拆成 embeddings、llm、prompts,把 RAG 拆成 chunk、ingest、retrieve、answer,再把向量存储封装成 vector-store。
工程要点:不要把所有逻辑都写在 /api/chat 里。拆开之后才能单独调试:是切片问题、检索问题、Prompt 问题,还是模型生成问题。
一个入门项目可以先拆成这样的目录:
src/ai/
llm.ts # 封装模型调用
embeddings.ts # 封装向量生成
prompts/rag.ts # RAG Prompt 模板
rag/chunk.ts # 文档切片
rag/ingest.ts # 文档入库
rag/retrieve.ts # 相似 chunk 检索
rag/answer.ts # 拼 Prompt 并生成答案
vector-store.ts # 向量数据库适配层
对应的数据结构至少要保留这些字段:
type RagChunk = {
id: string;
content: string;
embedding: number[];
metadata: {
documentId: string;
title: string;
section?: string;
page?: number;
permission?: "public" | "internal";
};
};
这样拆开以后,检索错了可以单独查 retrieve,格式乱了可以改 prompt,模型超时可以看 llm,维护成本会低很多。
题目 4:文档入库代码大概做什么?
标准答案:接收文档,切成 chunks,为每个 chunk 生成 embedding,再把 content、embedding 和 metadata 存入向量库。
工程要点:入库要考虑文档解析是否干净、chunk 是否太长或太短、是否保留标题页码、embedding 失败如何重试、重复上传同一文档如何更新。
题目 5:查询流程代码大概做什么?
标准答案:把用户问题转成 embedding,向量检索 topK 个相关 chunk,构造 RAG Prompt,调用模型生成答案,并返回引用来源。
工程要点:topK 不是越大越好。太小可能漏资料,太大可能带来噪音和成本。可以先从 3 到 8 之间试,再用评测数据调整。
完整示例:RAG 代码要拆成入库、检索、回答三层
RAG 代码最常见的坏味道,是把上传文档、切片、生成 embedding、检索、拼 Prompt、调用模型全塞进一个接口。这样一出错就不知道是文档解析错、切片错、检索错,还是模型生成错。课程里的拆法,是把入库、检索、回答拆开。
src/ai/
llm.ts
embeddings.ts
vector-store.ts
rag/chunk.ts
rag/ingest.ts
rag/retrieve.ts
rag/answer.ts
prompts/rag.ts
入库函数只负责把文档变成可检索的 chunk,不负责回答问题:
async function ingestDocument(document: RawDocument) {
const cleanText = cleanDocumentText(document.text);
const chunks = splitIntoChunks(cleanText, { targetSize: 800 });
for (const chunk of chunks) {
const embedding = await createEmbedding(chunk.content);
await vectorStore.upsert({
id: chunk.id,
content: chunk.content,
embedding,
metadata: {
documentId: document.id,
title: document.title,
section: chunk.section,
},
});
}
}
查询函数只负责找资料和生成答案:
async function answerQuestion(question: string) {
const queryEmbedding = await createEmbedding(question);
const chunks = await vectorStore.search(queryEmbedding, { topK: 5 });
const prompt = buildRagPrompt({ question, chunks });
const answer = await callModel(prompt);
return {
answer,
sources: chunks.map((chunk) => chunk.metadata),
};
}
topK 不是越大越好。topK 太小会漏资料,太大会带入噪音、增加 token 成本,还可能让模型被无关内容带偏。入门可以从 3 到 8 之间试,然后用评测集调。
这一课的完整答案是:向量数据库只是存储和检索组件。真正的 RAG 工程能力,是文档清洗、chunk 设计、metadata、检索调参、Prompt 约束和引用返回的组合。
这一篇最该带走的结论是:RAG 工程能力等于入库质量、检索质量、Prompt 约束和答案引用。向量数据库只是其中一环,不是全部答案。