基于 Milvus 和 LangChain 实现的天龙八部阅读助手
1. 什么是 RAG?
RAG 是 Retrieval-Augmented Generation(检索增强生成) 的缩写。
简单来说,它是一种让大语言模型(LLM)在回答问题前,先去“查资料”的技术架构。通过结合外部知识库的检索能力和大模型的生成能力,来解决大模型存在的以下问题:
- 知识滞后:无法获取训练数据之后的新信息。
- 幻觉(胡说八道) :生成看似合理但事实错误的内容。
- 私有数据缺失:不知道用户特定的内部数据。
2. RAG 的核心流程
从命名来看,RAG 分为两部分:先检索 (Retrieval) ,后增强 (Generation/Augmentation) 。
- 检索:从知识库中检索出与用户问题相关的文档片段。
- 增强:将检索到的文档加入到 Prompt(提示词)中,增强上下文,让大模型能够基于这些具体文档生成更准确、更有依据的回答。
为了实现这一流程,RAG 系统通常被划分为两个主要阶段:
阶段一:数据准备阶段
将公司文档或项目相关文档进行切分、向量化,并存储到向量数据库中作为知识库。
阶段二:推理问答阶段
- 接收用户问题。
- 对问题进行向量化。
- 从向量数据库中检索出与问题相关的文档。
- 将检索到的文档加入到 Prompt 中。
- 调用大模型生成回答。
本文以《天龙八部》电子书为例,介绍如何基于 Milvus(向量数据库)和 LangChain(应用框架)实现一个简单的 RAG 阅读助手。
3. 数据准备阶段
3.1 在 Milvus 中创建集合
Milvus 是一个向量数据库。类比 MySQL,在插入数据之前,我们需要先“建库建表”。
(注:开始前需申请免费的 Milvus 账号并创建集群)
初始化 Milvus 客户端实例
// 初始化一个 Milvus客户端实例
// 通过client对数据库建立连接进行操作
// ADDRESS 是 Milvus 集群的地址
// TOKEN 是 Milvus 集群的访问令牌
const client = new MilvusClient({
address: ADDRESS,
token: TOKEN,
})
定义集合结构
此步骤主要经历两个阶段:建表 和 添加索引(为后续查询优化做准备)。
async function ensureBookCollection(bookId) {
try {
// 判断集合是否存在 类比到mysql 中的 就是在这一步判断表是否已经创建过
const hasCollection = await client.hasCollection({
collection_name: COLLECTION_NAME,
})
if (!hasCollection.value) {
console.log(`${COLLECTION_NAME} 集合不存在,创建集合`);
// 创建集合
await client.createCollection({
collection_name: COLLECTION_NAME, // 集合名称,即表名
// schema 定义了集合的字段和数据类型
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 },
]
});
console.log('集合创建成功');
// 为集合添加索引 性能优化 提高查询速率
await client.createIndex({
collection_name: COLLECTION_NAME, // 集合名称,即表名
field_name: 'vector', // 为 vector 字段创建索引
index_type: IndexType.IVF_FLAT, //
metric_type: MetricType.COSINE,
params: {
nlist: VECTION_DIM, // 将向量分为多少维,一般与选择的模型有关
}
});
console.log('索引创建成功')
}
} catch(err) {
console.error('创建集合失败:', err.message);
throw err;
}
}
3.2 对文档进行向量化和切分
A. 加载文档
本例使用 .epub 格式文档,因此选用 LangChain 提供的 EPubLoader。
try{
console.log('开始加载EPUB文件');
// 实例化一个加载器
const loader =new EPubLoader(
EPUB_FILE,
{
splitChapters: true, // 是否将章节切分 我这里设置为true 是因为我想将每一章作为一个文档
}
);
const documents =await loader.load(); // 加载文档 并切分章节 这里做一个初步切分
}
B. 对文档进行精细切分
使用 RecursiveCharacterTextSplitter(递归字符文本切分器)进行二次切分。它会优先按段落切分,若长度仍超标,则按句子、字符递归切分。
// 实例化一个切分器
// RecursiveCharacterTextSplitter 递归字符文本切分器
// 递归地将文本切分,优先切分段落,然后切分句子,最后切分字符
const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: CHUNK_SIZE, // 每个字段的最大字符数
chunkOverlap: CHUNK_OVERLAP, // 每个字段之间的重叠字符数
// 默认的sparators \n\n 段落换行 \n 换行符。 ,
});
let totalInserted=0;
for(let chapterIndex=0; chapterIndex<documents.length;chapterIndex++){
const chapter = documents[chapterIndex];
const chapterContent = chapter.pageContent;
console.log(`处理第${chapterIndex+1}/${documents.length}章`);
const chunks =await textSplitter.splitText(chapterContent);
console.log(`拆分为${chunks.length}个字段`);
if(chunks.length===0){
console.log(`跳过空章节\n`);
continue;
}
console.log('生成向量并插入中...');
// 功能剥离,保证每个函数不会过于复杂 将向量化操作进行封装
const insertedCount =await insertChunksBatch(chunks,bookId,chapterIndex+1);
totalInserted+=insertedCount;
console.log(`插入成功${insertedCount}个片段,累计插入${totalInserted}个片段`);
}
console.log(`\n处理完成 共插入${totalInserted}`);
return totalInserted;
切分器核心参数解析:
chunkSize: 规定按多少字符数大小切割。chunkOverlap: 在下一次切割时,保留上一次切割最后的chunkOverlap个字符(保持上下文连贯性)。separators: 规定切分时的分隔符,默认是\n\n(段落),\n(换行),。,,。
C. 文档向量化与入库
主要步骤:创建向量化模型实例 -> 封装向量化函数 -> 并发执行向量化并插入 Milvus。
// 实例化一个向量化模型
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, // 模型对应的向量维度,通常与选择的模型有关,有些模型能够接受比自己指定的维度更高的向量,我们这里选的是text-embedding-v3,1024维
})
// 封装向量化函数
async function getEmbedding(text) {
const result = await embeddings.embedQuery(text); // 对目标文本进行向量化操作
return result;
}
// 并发执行向量化操作
async function insertChunksBatch(chunks,bookId,chapterIndex){
try {
if(chunks.length===0){
return 0;
}
// 性能优化 embedding 并发
// 返回结果是符合schema 的数组
const insertData =await Promise.all(
chunks.map(async (chunk,chunkIndex)=>{
// 对每个字段进行向量化操作
const vector = await getEmbedding(chunk);
// 返回字段与集合中的字段一致
return {
id:`${bookId}_${chapterIndex}_${chunkIndex}`,
book_id:bookId,
book_name:BOOK_NAME,
chapter_num:chapterIndex,
index:chunkIndex,
content:chunk,
vector:vector,
}
})
)
// 插入到向量数据库中
const insertResult = await client.insert({
collection_name: COLLECTION_NAME,
data: insertData,
})
return Number(insertResult.insert_cnt||0);
} catch (error) {
}
}
PS: 虽然使用循环迭代也能实现向量化,但耗时大幅增加。使用
Promise.all并发执行能显著提高效率。
至此,专属知识库搭建完成,数据准备阶段结束。
4. 推理问答阶段
与传统基于文本字符的精确匹配或模糊查询不同,向量搜索能解决语义匹配问题(例如搜“水果”能匹配到“苹果”)。因为知识库已向量化,用户输入也必须向量化后才能进行相似度匹配。
问答阶段主要包含三个步骤:
4.1 用户输入向量化
调用之前封装好的函数即可。
const queryVector = await getEmbedding(question);
4.2 检索 (Search)
携带用户输入的向量去数据库中进行相似度匹配。
async function retrieveRelevantContent(question, k=3) {
try {
// 用户输入
const queryVector = await getEmbedding(question);
// 调用searc方法进行查询
const searchResult = await client.search({
collection_name: COLLECTION_NAME,
vector: queryVector,
limit: k,
metric_type: MetricType.COSINE,
output_fields: ['id', 'book_id', 'book_name', 'chapter_num', 'index', 'content']
});
return searchResult.results
} catch(err) {
console.log('向量搜索失败:', err.message);
return [];
}
}
search 方法参数说明:
collection_name: 集合名称。vector: 用户输入的向量化表示。limit: 返回结果数量(返回前 k 个最相似的结果)。metric_type: 距离度量类型。此处使用COSINE(余弦相似度),值域 0-1,1 表示完全相似。output_fields: 需要返回的字段列表。
返回数据结构示例:
{
"status": {
"error_code": "Success",
"reason": ""
},
"results": [
[
{
"score": 0.9876543,
"id": "1001",
"book_id": "b_001",
"book_name": "西游记",
"chapter_num": 14,
"index": 5,
"content": "唐僧在五行山下...",
"vector": []
},
{
"score": 0.9654321,
"id": "1005",
"book_id": "b_001",
"book_name": "西游记",
"chapter_num": 14,
"index": 8,
"content": "孙悟空拜唐僧为师..."
}
]
],
"session_ts": 1710000000000,
"collection_name": "your_collection_name"
}
4.3 生成回答 (Generation)
将检索到的内容组装进 Prompt,交给大模型生成最终答案,从而解决数据孤岛和幻觉问题。
const model = new ChatOpenAI({
apiKey: process.env.OPENAI_API_KEY,
model: process.env.MODEL_NAME,
configuration:{
baseURL: process.env.OPENAI_BASE_URL,
},
temperature: 0.7,
})
async function answerEbookQuestion(question, k=3) {
try {
console.log('开始回答问题:', question);
const retrievedContent = await retrieveRelevantContent(question, k);
console.log(`[检索到的内容]`, retrievedContent);
if(retrievedContent.length === 0) {
console.log('没有检索到相关内容');
return '抱歉,没有找到相关内容';
}
// retrievedContent.forEach((item,index)=>{
// })
const context =retrievedContent
.map((item,i)=> `
[片段${i+1}]
章节:第${item.chapter_num}章
内容:${item.content}
`).join('\n\n---\n\n')
const prompt =`
你是一个专业的《天龙八部》小说助手,基于小说内容回答问题。用准确、详细的语言。
请根据以下《天龙八部》小说片段内容回答问题:
${context}
用户问题:${question}
回答要求:
1. 如果片段中有相关信息,请结合小说内容给出详情,准确的回答
2. 可以综合多个片段的内容提供完整的答案
3. 如果片段中没有相关的信息,请如实告知用户
4. 回答要准确,符合小说的情节和人物设定
5. 可以引用原文内容来支持你的回答
AI助手的回答:
`
console.log("AI助手的回答");
const res = await model.invoke(prompt);
console.log(res.content);
return res.content;
} catch(err) {
return '抱歉,处理问题时出错';
}
}
5. 主函数执行
最后,在主函数中连接 Milvus,加载集合,并发起问答测试。
async function main() {
try {
console.log('Connection to Milvus...');
await client.connectPromise;
try {
await client.loadCollection({
collection_name: COLLECTION_NAME,
});
console.log('Collection already loaded');
} catch(err) {
console.log('Collection not loaded');
}
const result=await answerEbookQuestion("谁的武功最厉害?")
console.log(`最终回答:${result}`);
} catch(err) {
console.error('Connection to Milvus failed:', err.message);
}
}
main();
6. 总结:深入理解 RAG 架构
通过构建《天龙八部》阅读助手,我们不仅完成了一个具体的应用案例,更深刻理解了 RAG (Retrieval-Augmented Generation) 这一大模型应用核心范式的本质。
RAG 的核心价值
RAG 不仅仅是一种技术组合,它是解决大语言模型(LLM)“先天不足”的关键方案:
- 打破知识边界:让模型能够访问训练数据之外的私有数据(如公司内部文档、最新新闻、特定书籍)。
- 抑制幻觉产生:通过“先检索后生成”的机制,强制模型基于事实依据(Grounding)回答,大幅减少胡编乱造。
- 可追溯性:生成的答案可以关联到具体的原文片段(如本例中的章节和段落),提供了答案的来源证明,增加了可信度。
关键流程复盘
成功的 RAG 系统依赖于两个紧密耦合的阶段:
-
数据准备(ETL + Embedding) :
- 切分策略是基石:合理的
chunkSize和chunkOverlap设置,既保证了语义的完整性,又避免了信息过载。 - 向量化是桥梁:将非结构化文本转化为高维向量,使得计算机能够理解“语义相似度”而非仅仅是“字符匹配”。
- 存储是保障:利用 Milvus 等专用向量数据库,实现海量数据下的毫秒级检索。
- 切分策略是基石:合理的
-
推理问答(Retrieve + Generate) :
- 语义检索:将用户问题转化为向量,在知识库中寻找最相关的上下文,解决了传统关键词搜索无法处理语义泛化的问题。
- 提示词增强:将检索到的上下文动态组装进 Prompt,充当模型的“短期记忆”或“参考资料”,引导模型输出精准答案。
技术启示
本项目展示了 LangChain 作为编排框架的灵活性(轻松加载文档、切分文本、调用模型)与 Milvus 作为向量底座的高性能(高效索引、相似度搜索)的完美结合。
对于开发者而言,掌握 RAG 意味着掌握了通往垂直领域大模型应用的钥匙。无论是构建企业知识库、智能客服,还是像本文这样的个性化阅读助手,RAG 都是当前最具落地价值和技术可行性的架构方案。随着技术的演进,结合混合检索、重排序(Re-rank)以及多模态能力的 RAG 系统,将在未来展现出更强大的智能水平。