在当前这个大模型(LLM)狂飙的时代,我们正经历着从传统软件向 Agentic AI(智能体 AI) 的范式演进。几乎所有主流的 AI Agent 产品,底层都离不开一项核心基础设施------向量数据库(Vector Database) 。
这篇文章将带你从零开始,理清传统数据库与向量数据库的核心差异,并通过 Milvus 的日记检索 Demo,一步步拆解如何为一本几十万字的《天龙八部》电子书构建一套完整的 RAG(检索增强生成) 系统。
一、从传统 CRUD 到语义检索:为什么我们需要向量数据库?
1.1 架构视角的转变
在传统的业务开发中,前后端的工作模式非常固定:
- 前端:展示文章列表页、详情页、表单页。
- 后端(MySQL / PostgreSQL) :围绕着业务实体进行经典的 CRUD(增删改查) 操作。
而在 Agentic AI 的场景下,这种模式被彻底颠覆:
- 前端:演变成了 Chatbot 对话框、智能搜索页面。
- 后端(Milvus 等向量数据库) :业务核心变成了 Embedding(向量化嵌入) 和 Retriever(检索) 。数据的录入变成了文本向量化后的"新增",查询则变成了基于多维浮点数组的"相似度检索"。
1.2 文本匹配 vs 语义搜索
我们熟悉的关系型数据库擅长精确匹配 :主键匹配、等值查询、范围筛选,或者使用 LIKE / 正则表达式进行模糊搜索(例如 LIKE '%段誉%')。
但这种基于关键词匹配(Lexical Search)的手段,在面对人类复杂的自然语言时显得非常捉襟见肘。它只能实现"按字面找",无法"按意思找"。
- 场景痛点:用户问「有没有写户外、爬山、放松心情的日记?」。如果日记的正文是「周末和几个老友去郊外走走,在山顶吹风,享受大自然」,传统数据库因为没有命中"户外"、"爬山"等精确词汇,就会将这条记录漏掉。另外,关键词搜索无法区分多义词(比如"苹果"是手机还是水果)。
- 向量搜索(Semantic Search) :我们将一段文字输入给大模型的 Embedding API,它会返回一个浮点数数组(如
[0.12, -0.45, 0.89...])。这个多维数组就是这段文本在多维语义空间中的坐标。语义越相近的文本,它们在空间中的距离就越近 。这就需要像 Milvus 这样的专业向量数据库,来存储海量的向量,并提供极致的相似度计算性能。
二、Milvus 的"Hello World":日记语义搜索 Demo
下面这段代码展示了最简流程:连接 Milvus → 将文本转为向量 → 在集合中做相似度搜索。
2.1 依赖与基础配置
首先,我们需要安装官方 SDK 和 LangChain 提供的相关模块,并初始化客户端和大模型 Embedding 模型:
JavaScript
import { MilvusClient, MetricType } from '@zilliz/milvus2-sdk-node';
import { OpenAIEmbeddings } from '@langchain/openai';
import 'dotenv/config';
// 1. 实例化 Milvus 客户端 (使用 Zilliz Cloud 的 Endpoint 和 Token)
const client = new MilvusClient({
address: process.env.MILVUS_ADDRESS,
token: process.env.MILVUS_TOKEN,
});
// 2. 初始化大模型的 Embedding 能力
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
model: process.env.EMBEDDING_MODEL_NAME, // 例如:text-embedding-3-small
configuration: { baseURL: process.env.OPENAI_BASE_URL },
dimensions: 1024, // 核心约束:此维度必须与后续建表时的维度严格一致!
});
// 辅助函数:将文本转为向量
async function getEmbeddings(text) {
return await embeddings.embedQuery(text);
}
2.2 连接状态检查与集合加载
在操作 Milvus 时,一个极易踩坑的点是:查询之前,必须确保 Collection(集合)已经被加载到内存中。
JavaScript
// 3. 检查集群健康状态
const checkHealth = await client.checkHealth();
if (!checkHealth.isHealthy) {
console.error('Milvus连接失败:', checkHealth.reasons);
process.exit(1);
}
// 4. 将集合加载到内存(至关重要!)
await client.loadCollection({ collection_name: 'ai_diary' });
2.3 距离度量:余弦相似度(Cosine Similarity)
检索的核心代码如下:
JavaScript
// 5. 将用户提问转化为相同维度的向量
const query = "我想看看关于户外活动的日记";
const queryVector = await getEmbeddings(query);
// 6. 执行相似度搜索
const SearchRes = await client.search({
collection_name: 'ai_diary',
vector: queryVector,
limit: 3, // 取最相似的 Top 3
metric_type: MetricType.COSINE, // 指定距离度量方式为余弦相似度
output_fields: ['id', 'content', 'date', 'mood', 'tags'],
});
console.log(SearchRes);
这里我们使用了 MetricType.COSINE。在向量数学中,余弦相似度衡量的是两个向量在空间中夹角的余弦值,公式为:
<math xmlns="http://www.w3.org/1998/Math/MathML"> cos ( θ ) = A ⋅ B ∥ A ∥ ∥ B ∥ \cos(\theta) = \frac{A \cdot B}{\|A\| \|B\|} </math>cos(θ)=∥A∥∥B∥A⋅B
夹角越小,余弦值越接近 1,代表两段文本的语义越相似。即使日记里完全没有"户外"两个字,只要语义指向类似的情境,它在空间中的向量夹角就会很小,从而被精准召回。
三、大模型的超能力插件:RAG 架构解析
理解了向量检索,我们就掌握了 RAG 的一半。
RAG(Retrieval-Augmented Generation,检索增强生成) 是目前解决大模型"幻觉(Hallucination)"最成熟的方案。当大模型遇到没有在训练数据集中见过的问题(如企业私有文档、最新发布的小说)时,它往往会一本正经地胡说八道。RAG 通过"外挂知识库"的方式解决了这个问题。
一个完整的 RAG 流程包含 6 个核心要素:
- 知识库准备:准备原始的非结构化数据(如各种类型的文本、PDF、MP3、视频等)。
- Loader(加载器) :使用
@langchain/community提供的各类 Loader 将文件转换为代码可处理的格式。 - Splitter(分块器) :将巨大的长文本切片,拆分成一个个 Document 碎片。
- Document(文档对象) :包含
pageContent(正文碎片)和metadata(元数据,如章节号、作者等信息)。 - Embedding Model:将碎片化为向量。
- Milvus:持久化存储这些向量及关联内容,以备随时被检索。
延伸理念 :在目前的开发流中,我们提倡 MVP (Minimal Viable Product) 与 Vibe Coding 的理念。借助 Cursor 或 Claude Code 等 AI 编程助手,我们能实现"代码平权",快速把产品原型的想法通过 RAG 架构落地,随后再进入商业级别的精细化打磨。
四、实战 Step 1:构建电子书向量知识库 (ebook-writer.mjs)
目标:将《天龙八部》的 EPUB 电子书切片,并写入 Milvus。
4.1 确保集合与索引存在(Schema 设计)
和关系型数据库建表类似,我们需要在 Milvus 中定义 Schema。特别是元数据(Metadata)的设计非常重要,它能帮助我们在检索时进行标量过滤(Scalar Filtering)。
JavaScript
import { DataType, IndexType } from '@zilliz/milvus2-sdk-node';
async function ensureBookCollection(bookId) {
const hasCollection = await client.hasCollection({ collection_name: 'ebook' });
if (!hasCollection.value) {
// 创建集合并定义字段 (Schema)
await client.createCollection({
collection_name: 'ebook',
fields: [
{ name: 'id', data_type: DataType.VarChar, max_length: 50, is_primary_key: true },
{ name: 'book_id', data_type: DataType.VarChar, max_length: 100 },
{ name: 'book_name', data_type: DataType.VarChar, max_length: 100 },
{ name: 'chapter_num', data_type: DataType.Int32 }, // 标量:章节号,便于精确过滤
{ name: 'index', data_type: DataType.Int32 }, // 标量:段落序号
{ name: 'content', data_type: DataType.VarChar, max_length: 10000 },
{ name: 'vector', data_type: DataType.FloatVector, dim: 1024 }, // 向量字段
]
});
// 为向量字段创建倒排索引 (IVF_FLAT),加速海量数据检索
await client.createIndex({
collection_name: 'ebook',
field_name: 'vector',
index_type: IndexType.IVF_FLAT,
metric_type: MetricType.COSINE,
params: { nlist: 1024 },
});
}
await client.loadCollection({ collection_name: 'ebook' });
}
4.2 Loader 加载与 Splitter 智能切分
如果把一整章直接塞给 Embedding 模型,不仅会超 Token 限制,而且语义混合在一起会导致检索极不精准。因此我们需要切块(Chunk)。
JavaScript
import { EPubLoader } from "@langchain/community/document_loaders/fs/epub";
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
// 1. 解析 EPUB,开启按章分割
const loader = new EPubLoader('./天龙八部.epub', { splitChapters: true });
const documents = await loader.load(); // 此时每个 document 代表一整章内容
// 2. 初始化递归字符切分器
const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 500, // 每个块的最大容量
chunkOverlap: 50, // 重叠冗余量
});
深度解析 Splitter:
RecursiveCharacterTextSplitter 为什么好用?它是一种带策略的降级切割。它会优先寻找天然的语义切割符(如段落换行 \n\n,然后是句号 。,最后是逗号 ,)。它致力于在满足 chunkSize 限制的前提下,尽量保持一句话语义的完整性。
而 chunkOverlap(重叠部分,通常设为 chunkSize 的 10%)是为了防止一句重要的话刚好被一刀切成两半,通过牺牲一点空间,让上下块有部分交集,从而保障上下文语义不断层。
4.3 高效的数据入库
将文本切分为 chunks 后,我们需要调用大模型 API 获取向量,并执行批量插入操作:
JavaScript
// 遍历每一章进行切块与入库
for (let chapterIndex = 0; chapterIndex < documents.length; chapterIndex++) {
const chapterContent = documents[chapterIndex].pageContent;
// 将该章内容切分为 500 字左右的小碎片
const chunks = await textSplitter.splitText(chapterContent);
// 性能优化:利用 Promise.all 并发获取多段文本的 Embedding
const insertData = await Promise.all(
chunks.map(async (chunk, chunkIndex) => {
const vector = await getEmbeddings(chunk);
return {
id: `${bookId}_${chapterIndex + 1}_${chunkIndex}`,
book_id: bookId,
book_name: "天龙八部",
chapter_num: chapterIndex + 1,
index: chunkIndex,
content: chunk,
vector: vector,
}
})
);
// 执行批量插入
await client.insert({
collection_name: 'ebook',
data: insertData,
});
}
五、实战 Step 2:验证语义召回能力 (ebook-query.mjs)
在接入大模型生成之前,我们必须先做一步"纯检索"验证。如果检索出来的数据是错的,大模型回答得再漂亮也是"Garbage in, garbage out"。
JavaScript
// 1. 确保集合已加载
await client.loadCollection({ collection_name: 'ebook' });
const query = '段誉会什么武功?';
const queryVector = await getEmbeddings(query);
// 2. 向量库检索
const searchResult = await client.search({
collection_name: 'ebook',
vector: queryVector,
limit: 3, // 取 Top 3 相关片段
metric_type: MetricType.COSINE,
output_fields: ['id', 'book_id', 'book_name', 'chapter_num', 'index', 'content'],
});
// 3. 打印召回结果
searchResult.results.forEach((item, index) => {
console.log(`第 ${index + 1} 个结果: Score: ${item.score.toFixed(2)}`);
console.log(`章节: ${item.chapter_num}, 内容: ${item.content}`);
});
此时你应该能看到控制台打印出包含凌波微步、六脉神剑等相关段落的内容。分数(Score)越接近 1,说明语义贴合度越高。
六、实战 Step 3:拼装提示词与大模型生成 (ebook-rag.mjs)
到了最后一步,我们将检索到的碎片级上下文喂给 LLM,让它进行润色和总结。
JavaScript
// 假设 retrieveRelevantContent 封装了之前的检索逻辑,并返回了结果数组
const retrievedContent = await retrieveRelevantContent(question, 3);
// 1. 拼装 Context:将碎片转化为大模型可读的上下文
const context = retrievedContent
.map((item, i) => `
[片段${i+1}]
章节:第 ${item.chapter_num} 章
内容:${item.content}
`).join('\n\n----\n\n');
// 2. 构建强约束 Prompt
const prompt = `
你是一个专业的《天龙八部》小说阅读助手。基于小说内容回答问题,用准确、详细的语言。
请根据以下提供的小说片段内容回答问题:
【参考片段】
${context}
【用户问题】
${question}
【回答要求】
1. 如果片段中有相关信息,请结合小说内容给出详细、准确的回答。
2. 可以综合多个片段的内容,提供完整的答案。
3. 如果片段没有相关信息,请如实告知用户"在检索到的资料中未找到相关答案",绝不可凭空捏造!
4. 回答要符合小说的情节和人物设定,可适当引用原文内容支持。
AI助手的回答:
`;
// 3. 调用大模型 (model 初始化略)
const response = await model.invoke(prompt);
console.log(response.content);
为什么这段 Prompt 如此重要?
因为大模型有强烈的"讨好型人格",如果不加约束,当它发现上下文中没有答案时,就会动用自己预训练的记忆强行回答。但在企业级 RAG 应用中(比如客服机器人解答退换货政策),我们要求的是 100% 忠于挂载的文档 ,所以 回答要求第 3 点 的防御性提示词必不可少。
七、总结
到这里,我们就完整走通了一条 RAG 的数据流:
ebook-writer.mjs:解决如何将海量数据"搬进"并"切碎"存入 Milvus。ebook-query.mjs:解决如何用数学的方式(Cosine),精准地"捞出"相似度最高的段落。ebook-rag.mjs:解决如何利用提示词工程,让大模型"戴着镣铐跳舞",生成可信、可溯源的答案。