Milvus 不只是“把向量存进去”:从电子书语义检索到可落地 RAG 系统的工程拆解

0 阅读16分钟

很多人第一次接触 RAG,最容易形成一种错觉:只要把文档切块、算向量、丢进向量数据库,再接一个大模型,事情就结束了。

但真正做过项目的人通常很快会发现,问题并不在“能不能把向量存进去”,而在于这条链路是否设计得足够清晰:文档该怎么加载、块该怎么切、元数据该保留什么、检索命中的片段该怎么组织给模型、哪些参数是为了召回质量,哪些参数是为了工程可维护性。

这篇文章我想用一个很直观的案例来讲清这件事:做一个“电子书语义检索助手”。表面上它是一个 EPUB 小说问答 demo,本质上它和企业知识库、产品手册问答、内部文档检索,是同一类问题。

我的核心结论先放在前面:

RAG 的关键从来不是“接上 Milvus”,而是把 Loader、Splitter、Embedding、Retriever、Prompt 和 LLM 组织成一条职责明确、参数可解释、边界清楚的检索增强链路。Milvus 是其中负责高效召回的基础设施,不是整个系统本身。

image.png

为什么这个问题不能靠 MySQL 关键词搜索解决

假设我有一本《天龙八部》的 EPUB 电子书,现在想问:

段誉会什么武功?

如果你把它当成传统数据库查询问题,会很自然地想到全文检索、倒排索引,甚至 LIKE '%段誉%' 这类关键词方案。但这里的难点在于,用户的问题和原文表述未必完全一致。

比如原文里可能写的是:

  • 六脉神剑
  • 凌波微步
  • 北冥神功

而用户问的是“会什么武功”。这不是一次简单的字符串匹配问题,而是一次语义召回问题。

关键词检索擅长解决“你知道要搜什么词”的场景,比如查订单号、查固定字段、查报错关键字。它并不擅长回答“语义相近但表达不同”的问题,更不擅长在多个片段之间做归纳。

这就是 RAG 适合出场的地方。

先把整条链路建立起来:RAG 里每个组件到底干什么

很多教程喜欢上来就贴代码,但如果读者脑子里没有完整链路,后面看到的每个 API 都像孤立动作。先把整体认知建立起来更重要。

在这个案例里,核心链路可以拆成六个角色:

  1. Loader:负责把原始文档读进来。
  2. Splitter:负责把长文本切成适合向量检索的片段。
  3. Embedding:把自然语言文本映射成高维向量。
  4. Vector Database:存储向量和元数据,并执行相似度检索。
  5. Retriever:根据问题召回最相关的若干片段。
  6. LLM + Prompt:基于召回片段组织上下文并生成答案。

这条链路里最容易混淆的有两点:

  • Embedding 不是回答问题的模型,它负责把“文本”和“问题”放进同一个向量空间,便于比较相似度。
  • Milvus 不是大模型,也不是知识理解模块,它负责高效检索,解决的是“从海量片段里先找谁更像”。

image.png

查询时的本质过程是这样的:

  1. 用户问题先被向量化。
  2. 在 Milvus 中按相似度找出最接近的问题片段。
  3. 把这些片段连同问题一起交给大模型。
  4. 大模型再基于这些上下文生成回答。

image.png

如果只做到第 2 步,那叫语义检索;做到第 4 步,才是完整的 RAG。

image.png

为什么这个案例里需要 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,这是不够的。更合理的思路是两阶段:

  1. 先按文档天然结构切,比如章、节、标题。
  2. 再在每一章内部按固定窗口二次切块。

这个案例里,第一层已经由 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 它不是最聪明的语义切分器,但它足够通用、简单、稳定,适合作为大多数入门和中小规模项目的默认方案。

image.png

image.png

第三步:向量库里存的不只是向量,还要存“可解释的元数据”

一个常见低级错误是,向量库里只存 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 });
}

这段代码做了三件事:

  1. 确保集合存在。
  2. 为向量字段创建索引。
  3. 把集合加载到内存,准备检索。

它背后的设计原因分别是:

  • schema 决定你未来能不能解释检索结果。
  • index 决定你未来能不能在数据规模增长后仍然有可接受的查询速度。
  • loadCollection 决定检索是否能以在线方式快速执行。

image.png

第四步:写入时要把“向量化”和“业务标识”一起完成

入库的本质不是“插记录”,而是把每个 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 这是一个很实用的做法。

第二,向量生成发生在写入链路中,这意味着离线入库成本在线查询成本是分开的。文档内容只需要在入库时向量化一次,用户提问时只需要向量化问题本身。

image.png

查询链路: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 组织的清晰度。

image.png

第二步:把检索结果组织成模型真正能利用的上下文

检索结束后,不是简单地把三个 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,比如“请回答下面问题”,模型很可能把通用知识和检索上下文混在一起,结果看起来流畅,但不一定忠于原文。

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_FLATnlist

这两个参数更偏向 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 编排,但基础召回都没打稳。更现实的路径通常是:

  1. 先让 chunk 设计合理。
  2. 再让召回质量可评估。
  3. 再考虑 rerank、过滤、混合检索。

4. 保持向量库 schema 的业务可扩展性

即便当前只是一个 demo,也建议元数据字段按“未来可能筛选和展示”的思路来设计。比如后续你很可能想按文档类型、作者、时间、权限标签做过滤,那现在就不要把 schema 设计得太死。

一个更务实的选型结论

如果你正在做的是中文文档问答、知识库检索、内部资料问答,这套方案的默认推荐是:

  • 文档加载阶段尽量保留原始结构信息。
  • 切块阶段采用“两阶段切分”,而不是简单粗暴整本硬切。
  • 向量库里同时保存向量和可解释元数据。
  • 检索阶段先控制好 topK 和上下文质量,再谈更复杂优化。
  • Prompt 要明确要求模型基于证据回答,并在证据不足时承认不确定。

反过来说,不太推荐把以下做法当默认方案:

  • 不保留任何元数据,只存文本和向量。
  • 为了追求“精确”把 chunk 切得极碎。
  • 把十几二十个弱相关片段一股脑塞进 Prompt。
  • 指望 LLM 在没有可靠召回的前提下自己“想明白”。

总结

“电子书语义检索助手”这个案例的价值,不在于它能回答《天龙八部》里的问题,而在于它把一个完整 RAG 系统的核心骨架暴露得非常清楚:

  • Loader 解决的是“文档怎么进来”。
  • Splitter 解决的是“知识单元怎么切”。
  • Embedding 解决的是“文本如何进入可比较的向量空间”。
  • Milvus 解决的是“如何从大量片段里高效召回候选内容”。
  • Prompt 和 LLM 解决的是“如何把候选内容转成对用户有意义的答案”。

真正值得记住的不是某个 SDK 的写法,而是这条链路里的职责边界和工程判断。

当你理解了这一点,这个 demo 就不再只是“小说问答小练习”,而会变成一个可以迁移到企业知识库、产品文档检索、内部助手系统的通用原型。

而 Milvus 的真正价值,也不只是“能存向量”,而是在 RAG 系统中承担起高效语义召回这一层稳定、明确、可扩展的基础设施职责。