用 Node.js + Milvus + 大模型,搭一个基于《红楼梦》小说的问答助手。
什么是 RAG ?
RAG 是 Retrieval-Augmented Generation(检索增强生成) ,是一种将大语言模型(LLM)与外部知识检索结合起来的 AI 技术。它的核心思想是:
先查资料,再回答问题。
这样模型不仅依赖训练时学到的知识,还可以利用最新或企业私有的数据。
为什么需要 RAG?
目的只有一个:让大模型回答地更准确。
因为大语言模型的知识可能过时、不了解私有数据,容易产生幻觉。
所以需要 RAG ,让大语言模型基于最新的知识或私有数据进行回答,解决大语言模型的幻觉问题。
所谓幻觉,就是大语言模型不知道答案时,可能不会说"不知道",而是一本正经地编一个答案。
RAG 的工作流程
可以把 RAG 理解成检索、增强和生成 3 个步骤:
-
检索:根据用户提问,在向量数据库中检索找到最相关的文档。
-
增强:将用户问题 + 检索结果一起组成 Prompt
-
生成:将 Prompt 传给 LLM,并得到答案。
例如:有一个由《员工手册》切分成的向量数据库
用户提问:公司年假是多少天?
RAG 会:
-
在员工手册中找到"年假政策"
-
把相关几段内容发给 LLM
-
LLM 根据这些内容生成答案
这肯定比大模型仅根据训练数据来回答准确。
如何构建 RAG ?
构建一个 RAG 系统可以分为 7 步:
-
准备知识库:收集 PDF、Word、Markdown、网页等资料。
-
文本切分:把长文档切成一个个较小的文本片段(Chunk)。
-
文本向量化:使用 Embedding 模型把每个 Chunk 转换成向量。
-
存入向量数据库:将文本和对应的向量保存到 Milvus、Chroma 等向量数据库中。
-
用户提问:用户输入一个问题,并使用同一个 Embedding 模型将问题转换成向量。
-
相似度检索:在向量数据库中搜索与问题最相关的若干文本片段。
-
交给大语言模型生成答案:将
用户问题 + 检索到的文本一起发送给 LLM,由 LLM 根据这些资料生成最终答案。
上述 7 步整体上分为两个阶段:
-
知识库构建阶段:1 ~ 4
-
用户回答阶段:5 ~ 7
什么是 Milvus ?
Milvus 是一个开源的向量数据库,专门用于存储、检索和管理向量数据,是构建 RAG、AI 搜索、推荐系统等 AI 应用的重要基础设施。
Milvus 针对海量向量数据进行了索引和检索优化,可以快速找到相似向量,支持从百万级到十亿级甚至更大规模的向量数据。适合用于构建企业级知识库。
具体实战
下面来具体实现《红楼梦》问答助手。
具体的技术栈如下:
-
Milvus 向量数据库:存文本 + 搜文本
-
阿里云 text-embedding-v4:emmbedding 模型 ,用于给给文本"打标签"(向量化)
-
阿里云 qwen-plus 大模型:大语言模型,用于回答用户问题
-
Express:Node.js 后端框架
-
Vue 3 + Element Plus:实现前端界面
-
SSE 流式传输:让答案逐字出现,实现"打字机"效果,减少用户等待时间
如上所述,整个系统分为两步:知识库构建阶段 和用户回答阶段。
知识库构建阶段:把《红楼梦》存进数据库
具体步骤为:
-
把《红楼梦》全文切成小块,每块 1000 字
-
用 Embedding 模型把每块变成一串数字(向量)
-
把每块的向量和内容存入 Milvus
为什么要把文本变成向量?因为计算机不认识中文,但能算两串数字有多"像"。两段意思相近的文字,它们的向量也相近。
把《红楼梦》全文切成小块,每块 1000 (chunkSize 大小)字,这里的 1000 是上限,不是每块都是 1000 字。
chunkSize 太大或太小都不好,这里的 1000 是经验值。
具体的切分策略是:尽量在句号、逗号等自然边界切断,只要不超过 1000 字就行。
typescript
// 把 86 万字切成 1000 字一块,块之间重叠 200 字
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 1000,
chunkOverlap: 200,
separators: ["\n\n", "\n", "。", "!", "?", ";", ",", " "],
});
同时 chunkSize 一定要配合 chunkOverlap 使用。
chunkOverlap 是指块与块之间重叠的数字。切分文本的时候,块与块有适当重叠,才能保证 LLM 回答地正确性。
如果在切分文本的时候,如果块与块之间没有重叠,万一关键内容恰好卡在切缝上:
text
块1结尾:......宝玉见黛玉进来,连忙起身让座。黛玉只觉他"面若中秋之月,
块2开头:色如春晓之花,鬓若刀裁,眉如墨画......
用户问"贾宝玉的外貌如何?",这句外貌描写的前半句在块 1 结尾,后半句在块 2 开头:
-
向量检索命中了块 1,但块 1 里外貌描写只有一半,大模型信息不全
-
向量检索命中了块 2,但块 2 开头突然冒出"色如春晓之花",没有主语,大模型可能理解错
同时会有丢失跨块语义的问题。《红楼梦》里大量使用前后呼应、指代:
text
"......宝玉听了,登时发作起痴狂病来"(块1结尾)
"他把通灵玉摘下来,狠命摔去"(块2开头)
没有重叠时,"他"指谁?块 2 单独看语义残缺。有重叠后,"他"的上下文在块内就能找到。
用户回答阶段
具体步骤为:
-
用户提问:"贾宝玉的外貌如何?" ,将该问题同样转为向量
-
在 Milvus 里找和它最接近的向量,将对象对应的原文取出
-
把原文 + 问题拼成 Prompt 发给大模型
-
大模型流式输出回答问题
代码核心部分
连接 Milvus
typescript
// 先确保数据库里有这张"表"(Milvus 叫集合)
await milvusClient.createCollection({
collection_name: "hongloumeng",
fields: [
{ name: "id", data_type: "VarChar", is_primary_key: true },
{ name: "text", data_type: "VarChar" }, // 存原文
{ name: "vector", data_type: "FloatVector", dim: "1024" }, // 存向量
],
});
// 创建索引,加速搜索
await milvusClient.createIndex({
field_name: "vector",
index_type: "IVF_FLAT",
metric_type: "COSINE", // 用余弦相似度衡量两个向量"有多像"
});
// 加载到内存,不加载没法搜
await milvusClient.loadCollectionSync({ collection_name: "hongloumeng" });
在 createIndex 时,有 3 个字段
-
field_name:指定在哪个字段上创建索引 -
index_type:指定索引类型,这里用的是 IVF_FLAT -
metric_type:衡量两个向量"有多像"的方式,这里用余弦相似度
文本切割 + 入库
typescript
// 把 86 万字切成 1000 字一块,块之间重叠 200 字
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 1000,
chunkOverlap: 200,
separators: ["\n\n", "\n", "。", "!", "?", ";", ",", " "],
});
const docs = await splitter.createDocuments([rawText]);
// 给每块加上 id
for (let i = 0; i < docs.length; i++) {
docs[i].metadata.id = `hlm_${String(i + 1).padStart(6, "0")}`;
}
// 存入 Milvus(LangChain 会自动调 Embedding 模型把文字变成向量)
await vectorStore.addDocuments(docs);
86 万字 → 1160 块,每块约 1000 字,相邻块有 200 字重叠。
检索 + 问答
typescript
async function* streamChatWithHongLouMeng(
query: string
): AsyncGenerator<string> {
console.log(`\n🔍 正在检索关于 "${query}" 的内容...`);
// 1. 搜出最相关的 4 段原文
const retriever = vectorStore.asRetriever({
searchType: "similarity",
k: 4,
});
const docs = await retriever.invoke(query);
// 2. 组装提示词:告诉大模型它是谁 + 给它参考资料 + 用户问题
const chain = prompt.pipe(model).pipe(new StringOutputParser());
// 3. 流式生成:每出一个字就 yield 出去
const stream = await chain.stream({
context: formatContext(docs),
input: query,
});
for await (const chunk of stream) {
if (chunk) {
yield chunk; // "贾" → "宝玉" → "的外貌" → ...
}
}
}
function formatContext(docs: Array<{ pageContent: string }>): string {
return docs.map((doc) => doc.pageContent).join("\n\n");
}
提示词长这样:
text
你是一个精通《红楼梦》的文学助手。
上下文信息:
(这里填搜出来的4段原文)
用户问题:贾宝玉的外貌如何?
回答:
SSE 流式推送
typescript
// 告诉浏览器:我要用 SSE 协议,边生成边发
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
// 每生成一个字,就包装成 SSE 事件发出去
for await (const chunk of streamChat(question)) {
res.write(`event: chunk\n`);
res.write(`data: {"content":"${chunk}"}\n\n`);
}
Content-Type 设置为 text/event-stream,告诉浏览器这不是普通响应,是事件流。
Cache-Control 设置为 no-cache,告诉浏览器不要缓存,导致用户获得旧的回答。
前端打字机
前端收到 SSE 事件后,不是一次性显示,而是逐个字符打到屏幕上:
typescript
// 收到一个字 → 拆成打字单元 → 塞进队列
function push(text) {
queue.push(...splitIntoTypingUnits(text)); // "贾宝玉" → ["贾宝", "玉"]
}
// 打字泵:从队列取字 → 显示 → 停顿 → 再取
async function pump() {
while (active) {
const unit = queue.shift();
message.content += unit; // 追加到界面
await sleep(getTypingDelay(unit)); // 句号停240ms,逗号140ms,中文70ms
}
}
splitIntoTypingUnits 函数把一段流式文本拆成打字单元(每次显示的最小单位),让打字机节奏自然。
getTypingDelay 函数根据打字单元的内容类型,返回不同的停顿毫秒数,制造自然的人工打字节奏。
splitIntoTypingUnits 函数和 getTypingDelay 函数的具体实现,可在下文给出的代码仓库中查看。
效果演示
先执行 pnpm ingest 命令,把《红楼梦》存入数据库,这个命令只需跑一次。
然后执行 pnpm dev 命令,启动后端服务;执行 pnpm web:dev 命令,启动前端端服务。
浏览器打开 http://localhost:5173,输入问题即可体验。

完整代码实现已上传 github 仓库:github.com/Panda-plus5...
总结
本文以《红楼梦》问答助手为例,使用 Node.js + Milvus + 大模型实现了 RAG 系统。
用文本切分(1000 字块 + 200 字重叠)将 86 万字《红楼梦》小说向量化存入 Milvus,检索 Top-4 片段组装 Prompt 交给大模型,再通过 SSE 流式返回配合前端打字机效果,从而让 LLM 基于真实原著回答问题、有效抑制大语言模型的幻觉问题。
有关 Milvus 的更多知识可猛戳读者的另一篇文章:从 0 到实战:掌握向量数据库 Milvus,构建 AI 应用的核心能力