摘要 :还在用
LIKE '%段誉%'做模糊搜索?OUT了!本文带你从零开始,利用 Milvus 向量数据库 + LangChain + OpenAI Embedding,打造一款能理解语义的《天龙八部》电子书 RAG(检索增强生成)助手。无需复杂部署,代码全开源,小白也能轻松上手向量数据库!
🤔 为什么我们需要 RAG?
想象一下,你手里有一本几百兆的《天龙八部》EPUB 电子书。 如果你想问:"段誉到底会哪些武功? "
- 传统搜索:你只能搜关键词"段誉"、"武功"。如果原文写的是"他施展出六脉神剑",而没出现"武功"二字,传统搜索可能就漏掉了。
- 语义搜索 (RAG) :它能理解"会哪些武功"和"施展出六脉神剑"在语义上是相关的。即使关键词不匹配,也能精准定位到相关段落,并让 AI 总结给你听。
这就是 RAG (Retrieval-Augmented Generation,检索增强生成) 的魅力。今天,我们就通过三个核心文件,手把手实现这个流程。
🛠️ 技术栈概览
本次 MVP (最小可行性产品) 我们选用以下"黄金组合":
- 数据源 :
天龙八部.epub(本地电子书) - 文档加载 & 分割 :
@langchain/community(EPUB Loader) +RecursiveCharacterTextSplitter - 嵌入模型 (Embedding) :
@langchain/openai(将文本转为向量) - 向量数据库 :Milvus (高性能向量检索,支持本地或云端)
- 大语言模型 (LLM) :
ChatOpenAI(用于最终的回答生成)
🚀 核心流程解析
RAG 的流程其实就两步:入库 (Indexing) 和 查询 (Querying) 。
第一步:数据入库 (ebook-writer.mjs)
这是最耗时的一步。我们需要把书"读"进去,切碎,变成向量,存进 Milvus。
1. 初始化 Milvus 集合 (Schema 设计)
向量数据库不像 MySQL 那样随意建表,我们需要定义 Schema 。在 ensureBookCollection 函数中,我们设计了如下字段:
yaml
// 核心 Schema 定义
fields: [
{ name: 'id', data_type: DataType.VarChar, is_primary_key: true }, // 主键
{ name: 'book_id', data_type: DataType.VarChar }, // 书籍ID
{ name: 'book_name', data_type: DataType.VarChar }, // 书名
{ 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 }, // ⭐ 核心:1024维向量
]
💡 注意 :
dim: 1024必须与你的 Embedding 模型输出维度一致(这里使用的是支持 1024 维的模型)。
2. 加载与切片 (Loading & Splitting)
电子书不能一次性塞进去,太大了。我们使用 LangChain 的 EPubLoader 按章节加载,再用 RecursiveCharacterTextSplitter 按段落切分。
ini
const loader = new EPubLoader(EPUB_FILE, { splitChapters: true });
const documents = await loader.load();
const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 500, // 每个片段500字符
chunkOverlap: 50, // 重叠50字符,保持上下文连贯
});
// 遍历每一章,切分后生成向量
for (let chapterIndex = 0; chapterIndex < documents.length; chapterIndex++) {
const chunks = await textSplitter.splitText(documents[chapterIndex].pageContent);
// ... 后续生成向量并插入
}
3. 向量化与批量插入
这是"魔法"发生的地方。调用 OpenAI API 将文本转为向量数组,然后批量写入 Milvus。
javascript
// 并发生成向量,提升性能
const insertData = await Promise.all(
chunks.map(async (chunk, chunkIndex) => {
const vector = await getEmbedding(chunk); // 调用 OpenAI Embedding
return {
id: `${bookId}_${chapterNum}_${chunkIndex}`,
content: chunk,
vector, // ⭐ 存入向量
// ...其他元数据
}
})
);
await client.insert({ collection_name: COLLECTION_NAME, data: insertData });
第二步:语义检索 (ebook-query.mjs)
数据入库后,我们就可以进行语义搜索了。
用户提问:"段誉会什么武功? "
- Query Embedding:先把这个问题也变成向量。
- Vector Search :在 Milvus 中计算问题向量与库中所有向量的余弦相似度 (Cosine Similarity) 。
- 返回结果:取出相似度最高的 Top 3 个文本片段。
csharp
const query = '段誉会什么武功?';
const queryVector = await getEmbedding(query);
const searchResult = await client.search({
collection_name: COLLECTION_NAME,
vector: queryVector,
limit: 3, // 取前3个最相关的
metric_type: MetricType.COSINE, // 使用余弦相似度
output_fields: ['content', 'chapter_num', 'book_name'], // 同时返回原文和元数据
});
此时,控制台会打印出得分最高的几个片段,哪怕原文里没有"武功"这两个字,只要语义相近(比如描述了打斗场景),都能被搜出来!
第三步:RAG 回答生成 (ebook-rag.mjs)
光搜出来还不够,我们要让 AI 基于搜到的内容回答问题。这就是 RAG 的完整形态。
构建 Prompt
我们将检索到的上下文(Context)注入到 Prompt 中,告诉 LLM:"别瞎编,只根据下面提供的小说片段回答"。
javascript
const context = retrievedContent.map((item, i) => {
return `
[片段${i + 1}]
章节:第${item.chapter_num}章
内容:${item.content}
`;
}).join('\n\n---\n\n');
const prompt = `
你是一个专业的《天龙八部》小说助手。
请根据以下《天龙八部》小说片段内容回答问题:
${context}
用户问题:${question}
回答要求:
1. 如果片段中有相关信息,请结合小说内容给出详情
2. 如果片段中没有相关的信息,请如实告知用户
3. 可以引用原文内容来支持你的回答
`;
调用 LLM 生成答案
arduino
const model = new ChatOpenAI({ temperature: 0.7, model: process.env.MODEL_NAME });
const response = await model.invoke(prompt);
console.log(response.content);
// 输出:段誉主要学会了北冥神功、凌波微步以及六脉神剑...
💻 完整源码展示
为了方便大家复现,以下是三个核心文件的完整代码。请确保你已经安装了依赖: npm install @zilliz/milvus2-sdk-node @langchain/openai @langchain/community @langchain/textsplitters dotenv
并且配置好 .env 文件:
ini
MILVUS_ADDRESS=your_milvus_address
MILVUS_TOKEN=your_milvus_token
OPENAI_API_KEY=your_sk_key
OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 # 或其他代理地址
EMBEDDING_MODEL_NAME=text-embedding-v3
MODEL_NAME=qwen-plus
1. 数据入库脚本:ebook-writer.mjs
php
import "dotenv/config";
import { parse } from 'path';
import {
MilvusClient,
DataType,
MetricType,
IndexType,
} from '@zilliz/milvus2-sdk-node'
import {
OpenAIEmbeddings
} from '@langchain/openai'
import {
EPubLoader
} from '@langchain/community/document_loaders/fs/epub'
import {
RecursiveCharacterTextSplitter
} from '@langchain/textsplitters'
const COLLECTION_NAME = 'ebook';
const VECTION_DIM = 1024;
const CHUNK_SIZE = 500;
const CHUNK_OVERLAP = 50;
const EPUB_FILE = './天龙八部.epub'; // 请确保当前目录下有此文件
const ADDRESS = process.env.MILVUS_ADDRESS;
const TOKEN = process.env.MILVUS_TOKEN;
const BOOK_NAME = parse(EPUB_FILE).name;
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
model: process.env.EMBEDDING_MODEL_NAME,
configuration:{
baseURL: process.env.OPENAI_BASE_URL,
},
dimensions: VECTION_DIM,
})
const client = new MilvusClient({
address: ADDRESS,
token: TOKEN,
})
async function getEmbedding(text) {
const result = await embeddings.embedQuery(text);
return result;
}
async function ensureBookCollection(bookId) {
try {
const hasCollection = await client.hasCollection({ collection_name: COLLECTION_NAME });
if (!hasCollection.value) {
console.log(`${COLLECTION_NAME} 集合不存在,创建集合`);
await client.createCollection({
collection_name: COLLECTION_NAME,
fields: [
{ name: 'id', data_type: DataType.VarChar, max_length: 100, 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: VECTION_DIM },
]
});
await client.createIndex({
collection_name: COLLECTION_NAME,
field_name: 'vector',
index_type: IndexType.IVF_FLAT,
metric_type: MetricType.COSINE,
params: { nlist: VECTION_DIM },
});
console.log('集合与索引创建成功');
}
try {
await client.loadCollection({ collection_name: COLLECTION_NAME });
} catch(err) {
console.log('集合已处于加载状态');
}
} catch(err) {
console.error('创建集合失败:', err.message);
throw err;
}
}
async function insertChunksBatch(chunks, bookId, chapterNum) {
if (chunks.length === 0) return 0;
const insertData = await Promise.all(
chunks.map(async (chunk, chunkIndex) => {
const vector = await getEmbedding(chunk);
return {
id: `${bookId}_${chapterNum}_${chunkIndex}`,
book_id: String(bookId),
book_name: BOOK_NAME,
chapter_num: chapterNum,
index: chunkIndex,
content: chunk,
vector
}
})
);
const insertResult = await client.insert({
collection_name: COLLECTION_NAME,
data: insertData,
});
return Number(insertResult.insert_cnt) || 0;
}
async function loadAndProcessEPubStreaming(bookId) {
console.log('开始加载 EPUB 文件');
const loader = new EPubLoader(EPUB_FILE, { splitChapters: true });
const documents = await loader.load();
const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: CHUNK_SIZE,
chunkOverlap: CHUNK_OVERLAP,
});
let totalInserted = 0;
for (let chapterIndex = 0; chapterIndex < documents.length; chapterIndex++) {
const chapter = documents[chapterIndex];
console.log(`处理第 ${chapterIndex + 1}/${documents.length} 章`);
const chunks = await textSplitter.splitText(chapter.pageContent);
if (chunks.length === 0) continue;
const insertedCount = await insertChunksBatch(chunks, bookId, chapterIndex + 1);
totalInserted += insertedCount;
console.log(`插入成功 ${insertedCount} 个片段, 累计 ${totalInserted}`);
}
console.log(`\n处理完成, 共插入 ${totalInserted} 个片段`);
}
async function main() {
try{
await client.connectPromise;
console.log('连接 Milvus 成功');
const bookId = 1;
await ensureBookCollection(bookId);
await loadAndProcessEPubStreaming(bookId);
} catch(err) {
console.error('主流程错误:', err);
}
}
main();
2. 纯向量检索测试:ebook-query.mjs
javascript
import 'dotenv/config';
import { MilvusClient, MetricType } from '@zilliz/milvus2-sdk-node';
import { OpenAIEmbeddings } from '@langchain/openai';
const ADDRESS = process.env.MILVUS_ADDRESS;
const TOKEN = process.env.MILVUS_TOKEN;
const COLLECTION_NAME = 'ebook';
const VECTION_DIM = 1024;
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
model: process.env.EMBEDDING_MODEL_NAME,
configuration:{ baseURL: process.env.OPENAI_BASE_URL },
dimensions: VECTION_DIM,
});
const client = new MilvusClient({ address: ADDRESS, token: TOKEN });
async function getEmbedding(text) {
return await embeddings.embedQuery(text);
}
async function main() {
try {
await client.connectPromise;
await client.loadCollection({ collection_name: COLLECTION_NAME }).catch(() => {});
const query = '段誉会什么武功?';
console.log(`正在搜索:"${query}"`);
const queryVector = await getEmbedding(query);
const searchResult = await client.search({
collection_name: COLLECTION_NAME,
vector: queryVector,
limit: 3,
metric_type: MetricType.COSINE,
output_fields: ['id','content','book_id','chapter_num','book_name'],
});
searchResult.results.forEach((item, index) => {
console.log(`\n--- 第${index + 1}个结果 (Score: ${item.score.toFixed(4)}) ---`);
console.log(`章节:${item.book_name} 第${item.chapter_num}章`);
console.log(`内容:${item.content.substring(0, 100)}...`);
});
} catch(err){
console.log('执行失败', err);
}
}
main();
3. RAG 智能问答:ebook-rag.mjs
javascript
import 'dotenv/config';
import { MilvusClient, MetricType } from '@zilliz/milvus2-sdk-node';
import { OpenAIEmbeddings, ChatOpenAI } from '@langchain/openai';
const ADDRESS = process.env.MILVUS_ADDRESS;
const TOKEN = process.env.MILVUS_TOKEN;
const COLLECTION_NAME = 'ebook';
const VECTION_DIM = 1024;
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
model: process.env.EMBEDDING_MODEL_NAME,
configuration: { baseURL: process.env.OPENAI_BASE_URL },
dimensions: VECTION_DIM,
});
const model = new ChatOpenAI({
temperature: 0.7,
apiKey: process.env.OPENAI_API_KEY,
model: process.env.MODEL_NAME,
configuration: { baseURL: process.env.OPENAI_BASE_URL },
});
const client = new MilvusClient({ address: ADDRESS, token: TOKEN });
async function getEmbedding(text) {
return await embeddings.embedQuery(text);
}
async function retrieveRelevantContent(question, k = 3) {
const queryVector = await getEmbedding(question);
const searchResult = await client.search({
collection_name: COLLECTION_NAME,
vector: queryVector,
limit: k,
metric_type: MetricType.COSINE,
output_fields: ['book_name', 'chapter_num', 'content']
});
return searchResult.results;
}
async function answerEbookQuestion(question, k = 3) {
console.log(`\n🤖 用户提问:${question}`);
const retrievedContent = await retrieveRelevantContent(question, k);
if (retrievedContent.length === 0) {
return '抱歉,没有在书中找到相关内容。';
}
const context = retrievedContent.map((item, i) => {
return `[片段${i + 1} - 第${item.chapter_num}章]: ${item.content}`;
}).join('\n\n');
const prompt = `
你是一个专业的《天龙八部》小说助手。请严格基于以下提供的小说片段内容回答问题。
【参考片段】:
${context}
【用户问题】:${question}
【回答要求】:
1. 准确、详细,符合原著情节。
2. 如果片段信息不足,请说明"根据现有片段无法完全确认",不要胡编乱造。
3. 适当引用原文。
AI 回答:
`;
const response = await model.invoke(prompt);
console.log(`\n✅ AI 回答:\n${response.content}`);
return response.content;
}
async function main() {
try {
await client.connectPromise;
await client.loadCollection({ collection_name: COLLECTION_NAME }).catch(() => {});
await answerEbookQuestion('谁的武功最厉害?');
} catch (err) {
console.log('运行出错', err.message);
}
}
main();
🎯 总结与展望
通过这三个文件,我们完成了一个完整的 RAG 闭环:
- 非结构化数据 (EPUB) -> 结构化向量 (Milvus)。
- 自然语言提问 -> 语义匹配 -> 精准上下文。
- 上下文 + LLM -> 高质量回答。
这仅仅是 MVP。在正式的商业级开发中,我们还可以加入:
- 混合检索:关键词匹配 (BM25) + 向量检索,效果更稳。
- 重排序 (Rerank) :对检索回来的结果再次打分排序。
- 多模态:不仅搜文字,还能搜书里的插图。
- 权限管理:不同用户只能搜自己上传的书。
向量数据库不再是黑科技,它已经成为了 AI 应用的标配基础设施。赶紧动手试试,把你的知识库"喂"给 AI 吧!
互动话题:你想把哪本书做成这样的 AI 助手?欢迎在评论区留言!