很多人第一次接触 RAG,最容易形成一种错觉:只要把文档切块、算向量、丢进向量数据库,再接一个大模型,事情就结束了。
但真正做过项目的人通常很快会发现,问题并不在“能不能把向量存进去”,而在于这条链路是否设计得足够清晰:文档该怎么加载、块该怎么切、元数据该保留什么、检索命中的片段该怎么组织给模型、哪些参数是为了召回质量,哪些参数是为了工程可维护性。
这篇文章我想用一个很直观的案例来讲清这件事:做一个“电子书语义检索助手”。表面上它是一个 EPUB 小说问答 demo,本质上它和企业知识库、产品手册问答、内部文档检索,是同一类问题。
我的核心结论先放在前面:
RAG 的关键从来不是“接上 Milvus”,而是把 Loader、Splitter、Embedding、Retriever、Prompt 和 LLM 组织成一条职责明确、参数可解释、边界清楚的检索增强链路。Milvus 是其中负责高效召回的基础设施,不是整个系统本身。
为什么这个问题不能靠 MySQL 关键词搜索解决
假设我有一本《天龙八部》的 EPUB 电子书,现在想问:
段誉会什么武功?
如果你把它当成传统数据库查询问题,会很自然地想到全文检索、倒排索引,甚至 LIKE '%段誉%' 这类关键词方案。但这里的难点在于,用户的问题和原文表述未必完全一致。
比如原文里可能写的是:
- 六脉神剑
- 凌波微步
- 北冥神功
而用户问的是“会什么武功”。这不是一次简单的字符串匹配问题,而是一次语义召回问题。
关键词检索擅长解决“你知道要搜什么词”的场景,比如查订单号、查固定字段、查报错关键字。它并不擅长回答“语义相近但表达不同”的问题,更不擅长在多个片段之间做归纳。
这就是 RAG 适合出场的地方。
先把整条链路建立起来:RAG 里每个组件到底干什么
很多教程喜欢上来就贴代码,但如果读者脑子里没有完整链路,后面看到的每个 API 都像孤立动作。先把整体认知建立起来更重要。
在这个案例里,核心链路可以拆成六个角色:
Loader:负责把原始文档读进来。Splitter:负责把长文本切成适合向量检索的片段。Embedding:把自然语言文本映射成高维向量。Vector Database:存储向量和元数据,并执行相似度检索。Retriever:根据问题召回最相关的若干片段。LLM + Prompt:基于召回片段组织上下文并生成答案。
这条链路里最容易混淆的有两点:
- Embedding 不是回答问题的模型,它负责把“文本”和“问题”放进同一个向量空间,便于比较相似度。
- Milvus 不是大模型,也不是知识理解模块,它负责高效检索,解决的是“从海量片段里先找谁更像”。
查询时的本质过程是这样的:
- 用户问题先被向量化。
- 在 Milvus 中按相似度找出最接近的问题片段。
- 把这些片段连同问题一起交给大模型。
- 大模型再基于这些上下文生成回答。
如果只做到第 2 步,那叫语义检索;做到第 4 步,才是完整的 RAG。
为什么这个案例里需要 Milvus,而不是直接把整本书交给大模型
这是很多初学者的另一个典型误区。
“既然大模型能理解文本,为什么不直接把整本电子书喂进去?”
原因很现实:
1. 上下文窗口不是无限的
即便模型上下文越来越大,也不意味着你应该把整本书原样塞进去。长上下文会带来更高成本、更慢延迟,而且注意力会被大量无关内容稀释。
2. 大模型擅长生成,不擅长在海量原文中做高效粗召回
让模型直接在一整本书里“自己找答案”,本质上是在把检索问题硬塞给生成模型。这个做法能跑,但通常既贵又不稳。
3. 工程系统需要“先缩小范围,再做理解”
RAG 的正确拆法通常是:
- 先用向量检索把候选范围缩小到几个高相关片段。
- 再让大模型基于这些片段生成答案。
这其实和传统搜索系统“召回 -> 排序”的思维很像,只不过这里的召回是向量语义召回,最终输出由 LLM 完成。
从 EPUB 到 Milvus:一个更像真实知识库系统的入库思路
这个案例的数据源是 EPUB 电子书,但不要被“电子书”三个字限制住。换个视角看,它只是一个非结构化文档源。
同样的方法,可以迁移到:
- 企业内部规章制度
- 产品说明书
- 售后知识库
- 培训手册
- 项目文档
也正因为如此,我们在写 demo 时,不应该把目标理解成“能把小说塞进向量库就行”,而应该把它理解成:
如何把一份长文档拆成可检索、可追踪、可解释的知识片段。
第一步:按文档结构加载,而不是一口气读成一个大字符串
对于 EPUB,比较合适的做法是使用 EPubLoader,并开启按章节拆分。这样做的好处不是“更省代码”,而是能保留原始文档的自然结构。
原始素材里用的是按章节加载,这个思路是对的。我会把代码整理成更清晰的版本:
import { EPubLoader } from "@langchain/community/document_loaders/fs/epub";
async function loadBook(filePath) {
const loader = new EPubLoader(filePath, {
splitChapters: true,
});
return loader.load();
}
这段代码的作用很简单:把 EPUB 转成一组 Document。但它在整条链路里的意义非常重要,因为它决定了后续切块不是从“无结构纯文本”开始,而是从“按章节组织的内容单元”开始。
如果换成一次性读整本书,也不是不能做,但你会失去天然章节边界,后续切块更容易把上下文切得零散。
第二步:不要只切一次,推荐“结构切分 + 窗口切分”两段式
很多人切块只盯着 chunkSize,这是不够的。更合理的思路是两阶段:
- 先按文档天然结构切,比如章、节、标题。
- 再在每一章内部按固定窗口二次切块。
这个案例里,第一层已经由 EPUB 的章节结构完成,第二层再交给 RecursiveCharacterTextSplitter。
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 500,
chunkOverlap: 50,
});
async function splitChapter(chapterText) {
return splitter.splitText(chapterText);
}
这段代码在链路中的位置,是把“适合人阅读的长段落”转换成“适合向量召回的检索单元”。
这里几个参数必须讲清楚,不然很容易沦为背配置:
chunkSize = 500这不是神奇数字,而是一个工程折中。太大,一个 chunk 里主题容易混杂,向量表示会变钝;太小,语义容易碎裂,检索命中后又缺上下文。对于中文长文本 demo,500 字符是一个相对稳妥的起点。chunkOverlap = 50它的价值在于缓解边界断裂。比如一句关键描述刚好落在两个 chunk 的交界处,没有 overlap 时很容易两边都不完整。50 不是唯一答案,但“保留一定重叠”是默认更稳的做法。RecursiveCharacterTextSplitter它不是最聪明的语义切分器,但它足够通用、简单、稳定,适合作为大多数入门和中小规模项目的默认方案。
第三步:向量库里存的不只是向量,还要存“可解释的元数据”
一个常见低级错误是,向量库里只存 content + vector,检索回来以后发现你不知道它来自哪里。
真正能落地的知识库系统,必须保留足够的元数据。这个案例里比较合理的字段包括:
id:片段唯一标识book_id:文档主键,可与业务库关联book_name:文档名,便于展示chapter_num:章节号,便于追踪来源index:章节内片段序号content:原始片段内容vector:嵌入向量
集合定义可以整理成下面这样:
import {
MilvusClient,
DataType,
IndexType,
MetricType,
} from "@zilliz/milvus2-sdk-node";
const COLLECTION_NAME = "ebook_collection";
const VECTOR_DIM = 1024;
async function ensureCollection(client) {
const { value: exists } = await client.hasCollection({
collection_name: COLLECTION_NAME,
});
if (!exists) {
await client.createCollection({
collection_name: COLLECTION_NAME,
fields: [
{ name: "id", data_type: DataType.VarChar, is_primary_key: true, max_length: 100 },
{ name: "book_id", data_type: DataType.VarChar, max_length: 100 },
{ name: "book_name", data_type: DataType.VarChar, max_length: 200 },
{ name: "chapter_num", data_type: DataType.Int32 },
{ name: "chunk_index", data_type: DataType.Int32 },
{ name: "content", data_type: DataType.VarChar, max_length: 10000 },
{ name: "vector", data_type: DataType.FloatVector, dim: VECTOR_DIM },
],
});
await client.createIndex({
collection_name: COLLECTION_NAME,
field_name: "vector",
index_type: IndexType.IVF_FLAT,
metric_type: MetricType.COSINE,
params: { nlist: 1024 },
});
}
await client.loadCollection({ collection_name: COLLECTION_NAME });
}
这段代码做了三件事:
- 确保集合存在。
- 为向量字段创建索引。
- 把集合加载到内存,准备检索。
它背后的设计原因分别是:
schema决定你未来能不能解释检索结果。index决定你未来能不能在数据规模增长后仍然有可接受的查询速度。loadCollection决定检索是否能以在线方式快速执行。
第四步:写入时要把“向量化”和“业务标识”一起完成
入库的本质不是“插记录”,而是把每个 chunk 变成一个可检索知识单元。
import { OpenAIEmbeddings } from "@langchain/openai";
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
model: process.env.EMBEDDINGS_MODEL_NAME,
dimensions: VECTOR_DIM,
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
},
});
async function buildChunkRows(chunks, bookId, bookName, chapterNum) {
return Promise.all(
chunks.map(async (content, chunkIndex) => ({
id: `${bookId}_${chapterNum}_${chunkIndex}`,
book_id: String(bookId),
book_name: bookName,
chapter_num: chapterNum,
chunk_index: chunkIndex,
content,
vector: await embeddings.embedQuery(content),
}))
);
}
这段代码有两个值得强调的点。
第一,id 不是随便生成的。把 bookId + chapterNum + chunkIndex 组合起来,本质上是在让 chunk 来源天然可追踪。对于 demo 这是一个很实用的做法。
第二,向量生成发生在写入链路中,这意味着离线入库成本和在线查询成本是分开的。文档内容只需要在入库时向量化一次,用户提问时只需要向量化问题本身。
查询链路:Milvus 负责召回,大模型负责回答
入库只是准备阶段。对用户真正有价值的是查询时这一段。
第一步:把问题向量化,然后去 Milvus 做近邻搜索
import { MetricType } from "@zilliz/milvus2-sdk-node";
async function retrieveRelevantChunks(client, question, topK = 3) {
const questionVector = await embeddings.embedQuery(question);
const result = await client.search({
collection_name: COLLECTION_NAME,
vector: questionVector,
limit: topK,
metric_type: MetricType.COSINE,
output_fields: ["book_name", "chapter_num", "chunk_index", "content"],
});
return result.results;
}
这段代码的作用是“召回候选片段”,而不是直接回答用户。
这里几个参数要有工程判断:
metric_type = COSINE对文本 embedding 来说,余弦相似度通常是一个稳妥默认值,因为它更关注方向相似性,而不是绝对长度。limit = topK这个值不是越大越好。取太少,信息可能不足;取太多,会把低相关噪音一并塞进 Prompt。对 demo 来说,3 到 5 通常是比较合适的起点。output_fields只返回真正要用到的字段,不要无脑全取。这不仅影响传输成本,也影响后续 Prompt 组织的清晰度。
第二步:把检索结果组织成模型真正能利用的上下文
检索结束后,不是简单地把三个 chunk 拼起来就万事大吉。你需要显式告诉模型这些内容是什么、来自哪里、应该如何回答。
function buildContext(chunks) {
return chunks
.map((item, idx) => {
return [
`[片段 ${idx + 1}]`,
`章节: 第 ${item.chapter_num} 章`,
`内容: ${item.content}`,
].join("\n");
})
.join("\n\n---\n\n");
}
function buildPrompt(question, context) {
return `
你是一名基于电子书原文回答问题的助手。
请严格依据提供的片段回答,不要补造书中不存在的情节。
已检索到的片段如下:
${context}
用户问题:${question}
回答要求:
1. 优先基于片段直接作答;
2. 如果多个片段可以互相补充,请整合后回答;
3. 如果证据不足,要明确说明“不足以判断”;
4. 尽量保留人物、情节和武功名称等关键信息。
`.trim();
}
这里最重要的不是字符串拼接本身,而是 Prompt 设计思路:
- 告诉模型“只能基于片段回答”,是在压缩幻觉空间。
- 保留章节信息,是为了让答案更可解释。
- 明确要求“证据不足时直说”,是在控制错误自信。
如果你直接写一个宽泛 Prompt,比如“请回答下面问题”,模型很可能把通用知识和检索上下文混在一起,结果看起来流畅,但不一定忠于原文。

第三步:让 LLM 完成归纳,而不是让它做检索
完整问答逻辑可以整理成下面这样:
import { ChatOpenAI } from "@langchain/openai";
const chatModel = new ChatOpenAI({
model: process.env.MODEL_NAME,
temperature: 0.2,
apiKey: process.env.OPENAI_API_KEY,
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
},
});
async function answerQuestion(client, question) {
const chunks = await retrieveRelevantChunks(client, question, 5);
if (!chunks.length) {
return "没有检索到足够相关的原文片段,暂时无法可靠回答这个问题。";
}
const context = buildContext(chunks);
const prompt = buildPrompt(question, context);
const response = await chatModel.invoke(prompt);
return response.content;
}
在这个链路里,职责边界很清楚:
- Milvus 负责“找相关内容”。
- LLM 负责“读懂这些内容并生成自然语言回答”。
这个边界非常重要。很多系统做坏,往往就是因为把检索和生成混成一团,结果出了问题以后根本不知道是召回不准,还是 Prompt 不行,还是模型在胡说。
这套方案里,哪些参数最值得认真理解
技术文章如果只把参数列出来,读者很难真正形成判断。下面我只讲几个最值得在工程里认真理解的参数。
chunkSize
它决定一个 chunk 包含多少文本。这个参数影响的是语义密度和召回颗粒度。
- 太大:一个 chunk 可能混入多个主题,向量不够聚焦。
- 太小:信息碎片化,召回回来也不够支撑回答。
如果你的文档是说明书、制度文档、FAQ,推荐先用中等粒度起步,再根据召回效果微调,而不是一开始就极端追求“小而精”。
chunkOverlap
它不是为了“多存点内容”,而是为了降低切分断点带来的语义损失。
在中文长文本里,适度 overlap 往往比“纯干净切块”效果更稳。代价是存储量会增加一点,但这通常是值得的。
k / topK
它控制召回多少片段进入 Prompt。
k太小,模型拿不到足够证据。k太大,Prompt 容易被弱相关内容污染。
多数业务里,3~8 是一个比较合理的搜索起点。不是因为这个数字神秘,而是因为它通常能在“信息充分”和“上下文干净”之间取得平衡。
MetricType.COSINE
对文本 embedding,余弦相似度通常是默认首选,因为文本向量更看重语义方向一致性。除非你非常明确自己的 embedding 模型和索引策略更适合其他度量,否则没有必要一上来就换。
IndexType.IVF_FLAT 和 nlist
这两个参数更偏向 Milvus 本身的检索性能配置。
IVF_FLAT可以理解为一种常见、可用、便于入门的近似索引方式。nlist影响聚类分桶的粒度,进而影响检索速度和召回表现。
教学 demo 用它完全没问题,但如果你的数据量继续增长,后续还需要结合实际延迟和召回率评估索引策略,而不是照着示例永久不变。
VECTOR_DIM
向量维度必须和 embedding 模型输出一致。这个参数不是“越大越高级”,而是“必须和模型约定对齐”。一旦不一致,轻则报错,重则整条链路不可用。
常见误区:很多 RAG demo 能跑,但不适合作为默认工程方案
误区一:把“检索到了相似片段”当成“答案一定正确”
召回只是第一步。召回的片段可能相关但不完整,也可能局部相似却不直接回答问题。真正可靠的系统,需要把“召回质量”和“回答质量”拆开看。
误区二:Chunk 越小越精确
这是一种很常见但很危险的直觉。太小的 chunk 虽然局部更聚焦,但也可能失去回答问题必需的上下文,最后模型拿到的只是“半句话”。
误区三:向量库可以替代关系型数据库
不能。
Milvus 适合做相似度检索,不适合承载复杂事务、强约束关联和核心业务状态。这个案例里保留 book_id,本质上就是在提醒你:向量库和业务库通常是协同关系,不是替代关系。
误区四:Prompt 只要把片段塞进去就行
如果没有明确约束模型回答边界,模型很容易把自身知识和检索片段混在一起。对于知识库问答系统,这会直接伤害可控性和可信度。
误区五:demo 跑通了,就等于可以上线
从 demo 到生产,中间至少还差这些能力:
- 批量写入与失败重试
- 文档更新与增量重建
- 检索日志与评估集
- 权限控制
- 引用来源展示
- 监控和成本控制
如果把这个 demo 升级成真实知识库系统,我会优先补什么
这部分往往比“怎么把代码跑起来”更有工程价值。
1. 先补可观测性,而不是先补花哨功能
最应该先看到的是:
- 用户问了什么
- 召回了哪些 chunk
- 每个 chunk 的相似度是多少
- 最终回答引用了哪些证据
没有这些信息,你几乎无法调优。
2. 给检索结果保留来源信息
在小说场景里是“第几章第几个片段”,在企业场景里可以是“文档标题 + 段落编号 + 更新时间”。这不仅有助于调试,也决定了用户是否信任答案。
3. 先把召回做稳,再考虑复杂链路
很多团队一上来就想加 rerank、多路检索、Agent 编排,但基础召回都没打稳。更现实的路径通常是:
- 先让 chunk 设计合理。
- 再让召回质量可评估。
- 再考虑 rerank、过滤、混合检索。
4. 保持向量库 schema 的业务可扩展性
即便当前只是一个 demo,也建议元数据字段按“未来可能筛选和展示”的思路来设计。比如后续你很可能想按文档类型、作者、时间、权限标签做过滤,那现在就不要把 schema 设计得太死。
一个更务实的选型结论
如果你正在做的是中文文档问答、知识库检索、内部资料问答,这套方案的默认推荐是:
- 文档加载阶段尽量保留原始结构信息。
- 切块阶段采用“两阶段切分”,而不是简单粗暴整本硬切。
- 向量库里同时保存向量和可解释元数据。
- 检索阶段先控制好
topK和上下文质量,再谈更复杂优化。 - Prompt 要明确要求模型基于证据回答,并在证据不足时承认不确定。
反过来说,不太推荐把以下做法当默认方案:
- 不保留任何元数据,只存文本和向量。
- 为了追求“精确”把 chunk 切得极碎。
- 把十几二十个弱相关片段一股脑塞进 Prompt。
- 指望 LLM 在没有可靠召回的前提下自己“想明白”。
总结
“电子书语义检索助手”这个案例的价值,不在于它能回答《天龙八部》里的问题,而在于它把一个完整 RAG 系统的核心骨架暴露得非常清楚:
- Loader 解决的是“文档怎么进来”。
- Splitter 解决的是“知识单元怎么切”。
- Embedding 解决的是“文本如何进入可比较的向量空间”。
- Milvus 解决的是“如何从大量片段里高效召回候选内容”。
- Prompt 和 LLM 解决的是“如何把候选内容转成对用户有意义的答案”。
真正值得记住的不是某个 SDK 的写法,而是这条链路里的职责边界和工程判断。
当你理解了这一点,这个 demo 就不再只是“小说问答小练习”,而会变成一个可以迁移到企业知识库、产品文档检索、内部助手系统的通用原型。
而 Milvus 的真正价值,也不只是“能存向量”,而是在 RAG 系统中承担起高效语义召回这一层稳定、明确、可扩展的基础设施职责。