把《天龙八部》喂进 AI:从 MySQL LIKE 到倒排索引再到向量召回,RAG 的「检索底座」到底该怎么搭?

把《天龙八部》喂进 AI:从 MySQL LIKE 到倒排索引再到向量召回,RAG 的「检索底座」到底该怎么搭?

一句话导读:LLM 再聪明,也背不出你公司的内部文档。想让 AI 基于自有知识库回答问题,就得先解决「怎么把文本找出来」这件事。本文从 MySQL 全文检索的痛点出发,带你依次搞懂 ElasticSearch 倒排索引、Milvus 向量库,最终落地一个能跑通的朴素 RAG(Naive RAG)管线。

背景:为什么 AI 需要一套「检索系统」

很多人第一次做 RAG 都会问一个问题:为什么不能直接把文档全塞给大模型?

因为三个现实约束:

  1. LLM 只能思考,不懂你的私域数据。它没读过你公司内部的排期表、售前话术、或者《天龙八部》全文。
  2. Token 有成本,也有长度上限。把几百万字的资料一次性丢给模型,既贵又超长。
  3. 模型会「编」。没有可靠依据时,它倾向于一本正经地胡说(幻想)。

所以场景收敛成一条固定流水线:先检索出来相关片段,再把片段拼进 Prompt,最后让模型基于片段作答。这就是 RAG(Retrieval-Augmented Generation,检索增强生成)。

而「检索」这两个字,是整个流水线的地基。地基怎么选,直接决定了效果上限。

第一步:MySQL 的 LIKE 为什么不扛打?

在公司里,关系型数据库(MySQL)一定是你最先想到的存放方式。它有清晰的 database -> table -> row -> column 结构,字段检索、表关联都很快。但是 ------当需求变成「在 content 这种大文本字段里做全文检索」时,性能会急剧恶化。

核心问题出在 SELECT ... LIKE '%关键词%' 这类写法上:

  • 它是逐行遍历的:每一行都要整行读出来,再逐字去和关键词匹配;
  • 数据量越大、文本越长,这种模糊匹配就越慢
  • 它没法利用普通索引(B+Tree 前缀匹配帮不上模糊中间匹配),属于全表扫。

一句话:MySQL 适合「结构化数据的精确查找」,不适合「海量文本的关键词全文检索」。

所以业内普遍共识是------文本全文搜索,交给专门干这个的 ElasticSearch(ES)。

一个有趣的类比:MySQL 是常规部队,ES 是特种兵。常规部队管好纪律(行列、事务),特种兵专精攻坚(文本检索)。

第二步:倒排索引,ES 秒杀 MySQL 的核心武器

ES 快,不是玄学,而是它的底层机制叫倒排索引(Inverted Index) 。要理解它,先看 MySQL 的正向索引是怎么存储的。

正向索引:文档 → 词

文档 ID 内容
1 乔峰是丐帮帮主
2 段誉会六脉神剑
3 虚竹破了珍珑棋局

正向索引以整行为单位存数据。想找「帮主」,就得把每行内容从头扫一遍,看看有没有这个词------这就是刚才说的慢。

倒排索引:词 → 文档

ES 的做法是反过来 :写入时先把文本分词(tokenization)拆成一个一个独立词条,然后以词条为核心,反向挂接所有包含它的文档 ID

词条 文档 ID 列表
乔峰 1
帮主 1
六脉神剑 2
珍珑棋局 3

于是「正向索引是 文档→词,倒排索引是 词→文档」。用户输入关键词时,ES 只需要:

  1. 把关键词切词;
  2. 在倒排表里按词条精准命中对应的文档 ID;
  3. 直接取出这些文档。

全程不需要全表遍历 ,所以能在海量文本 下实现毫秒级全文检索。这就是 ES 与 MySQL 最本质的差距。

记忆锚点:MySQL 像「先翻一本书再找词」,ES 像「查字典的部首索引,先定位词再翻到页」。

第三步:用 Docker Compose 把 ES 和 Kibana 拉起来

理论说完,直接上车。ES 官方推荐用 Docker 容器化 部署,配合 Kibana(可以把它理解为「ES 界面的 phpMyAdmin」)可视化查数据。

先建立心智模型:

  • 镜像(image)=打包好的「代码 + 运行环境依赖」;
  • 容器(container)=镜像运行起来后的独立进程实例;
  • docker-compose.yml=把多个容器编排到一起的配置文件。

一张 compose 文件通常长这样:

yaml 复制代码
version: "3.8"
services:
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.12.0
    container_name: es8
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=false
    ports:
      - "9200:9200"
    volumes:
      - es-data:/usr/share/elasticsearch/data

  kibana:
    image: docker.elastic.co/kibana/kibana:8.12.0
    container_name: kibana
    ports:
      - "5601:5601"
    environment:
      - ELASTICSEARCH_HOSTS=http://es8:9200

volumes:
  es-data:

启动命令与解释:

bash 复制代码
docker compose up -d
  • docker compose:命令会在当前目录 寻找 docker-compose.yml 作为编排配置;
  • up:把配置里的容器启动起来;
  • -d后台运行(detached),不占住你的终端。

启动后 ES 监听 9200 端口,Kibana 监听 5601 端口。ES 的 9200 上存放的是索引(索引可理解成 ES 的「表」)。

第四步:建索引与检索------Mapping 和 DSL

ES 的操作术语和 MySQL 一一对应,理解起来就顺了:

MySQL ElasticSearch
database(库) 通常用索引前缀区分业务
table / 表结构 index(索引)+ mapping
row(行) document(文档)
column(列) field(字段)
SQL 查询 Query DSL
正排索引(全表扫) 倒排索引(词→文档)

看所有索引

bash 复制代码
GET /_cat/indices?v

输出所有索引,以表格形式组织并显示。

创建索引并声明 Mapping

css 复制代码
PUT /article
{
  "mappings": {
    "properties": {
      "title":   { "type": "text" },
      "content": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart" },
      "author":  { "type": "keyword" }
    }
  }
}

这里藏着全文检索最关键的 type 选择

  • text 类型:会分词,用于标题、正文这类需要全文检索的字段;
  • keyword 类型:不分词,整值精确匹配,用于作者名、标签、ID 这类字段。

于是你自然拥有了「全文检索 + 精确过滤 」的组合能力:正文用 match 语义搜索,作者用 term 精确锁定。

中文字段要配中文分词器

ES 默认的分词器对中文只做粗粒度的单字切分,效果一般。所以中文场景几乎必配 IK 分词器,它提供两档:

  • ik_max_word存的时候用,尽量多存索引、粒度最细(把句子切到最细的词);
  • ik_smart查的时候用,粒度粗一点,保证匹配命中率。

一句话记忆:写细存,查粗查

用 DSL 检索

css 复制代码
GET /article/_search
{
  "query": {
    "match": {
      "content": "六脉神剑"
    }
  }
}

这里的 DSL(Domain Specific Language,领域特定语言) 就是 ES 的「SQL」------只不过它通过 HTTP + JSON 来表达查询意图,专门为搜索引擎设计,语义清晰、可组合(能自由叠加 boolshouldfilter 等)。

第五步:向量检索------语义相似的利器

ES 解决的是「关键词/字面」匹配。但很快你会发现一个尴尬:「土豆」和「马铃薯」字面完全不同,语义却一样;纯关键词检索匹配不到它们。

再进一步:RAG 的核心诉求是「找语义相关的片段」,而关键字面匹配对「同义改写、意译、跨语言(tomato vs 西红柿)」无能为力。

这就轮到向量数据库登场了。核心思路:

  1. Embedding 模型把一段文本编码成一组高维浮点向量(比如 1024 维);
  2. 语义相近的文本,向量在空间里也靠得近
  3. 检索时把用户问题也转成向量,然后算向量相似度(余弦相似度 COSINE 是常用度量),取距离最近的 Top-K 条。

市面上主流向量库各有定位:

  • Milvus :Java 生态(文中用 @zilliz/milvus2-sdk-node Node 客户端 + TypeScript),生产级、支持分布式;
  • Qdrant:Rust 实现,Python 生态常用;
  • Pinecone:托管云服务,开箱即用。

备注:ES 生态自身也支持向量检索(kNN),很多生产架构其实用 ES(关键词)+ 向量库/向量字段(语义) 双引擎。我们下文先把「关键词检索」和「语义检索」两条腿分别立起来,最后再谈**混合检索(Hybrid Search)**怎么合流。

第六步:实战入库------把《天龙八部》整个装进 Milvus

纸上谈兵结束,现在把一整本 EPUB 电子书拆成片段、向量化、灌进 Milvus。这一步是后续所有 RAG 的地基,代码我会给全,并加了详细注释。

js 复制代码
import "dotenv/config";
import { parse } from 'node: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_collection';
const VECTOR_DIM = 1024;
const CHUNK_SIZE = 500;          // 每个片段最多 500 字符
const CHUNK_OVERLAP = 50;        // 相邻片段重叠 50 字符,保住上下文连贯
const EPUB_FILE = './天龙八部.epub';
const BOOK_NAME = parse(EPUB_FILE).name; // 从文件名去掉扩展名拿书名

// 1. 初始化 Embedding 模型
const embeddings = new OpenAIEmbeddings({
  apiKey: process.env.OPENAI_API_KEY,
  model: process.env.EMBEDDINGS_MODEL_NAME,
  configuration: { baseURL: process.env.OPENAI_BASE_URL },
  dimensions: VECTOR_DIM
});

// 2. 初始化 Milvus 客户端(对接 docker 里的 milvus)
const client = new MilvusClient({ address: 'localhost:19530' });

async function getEmbedding(text) {
  return embeddings.embedQuery(text);
}

// 3. 创建集合(相当于建表)+ 建索引
async function ensureCollection() {
  const has = await client.hasCollection({ collection_name: COLLECTION_NAME });
  if (!has.value) {
    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: 200 },
        { 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: VECTOR_DIM }
      ]
    });
    // IVF_FLAT:先分桶再在目标桶内暴力比对,速度快
    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 });
}

// 4. 批量把一批片段向量化并插入
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: bookId,
        book_name: BOOK_NAME,
        chapter_num: chapterNum,
        index: chunkIndex,
        content: chunk,
        vector
      };
    })
  );
  const res = await client.insert({ collection_name: COLLECTION_NAME, data: insertData });
  return Number(res.insert_cnt) || 0;
}

// 5. 加载 EPUB,按章节流式处理(边处理边插入,避免一次性全部驻留内存)
async function loadAndProcessEPubStreaming(bookId) {
  const loader = new EPubLoader(EPUB_FILE, { splitChapters: true });
  const documents = await loader.load();
  console.log(`加载完成,共 ${documents.length} 个章节`);

  const textSplitter = new RecursiveCharacterTextSplitter({
    chunkSize: CHUNK_SIZE,
    chunkOverlap: CHUNK_OVERLAP
  });

  let totalInserted = 0;
  for (let i = 0; i < documents.length; i++) {
    const chunks = await textSplitter.splitText(documents[i].pageContent);
    if (chunks.length === 0) continue;
    totalInserted += await insertChunksBatch(chunks, bookId, i + 1);
    console.log(`已处理第 ${i+1} 章,累计插入 ${totalInserted} 条`);
  }
  return totalInserted;
}

async function main() {
  await client.connectPromise;
  await ensureCollection();
  const total = await loadAndProcessEPubStreaming(1);
  console.log(`处理完成,总插入 ${total} 条`);
}

main().catch((err) => { console.error(err); process.exit(1); });

这里有几个设计点值得单独讲讲:

为什么「流式处理」而不是一次性读完?

电子书很大,一次性全载入内存再一起向量化容易 OOM。这里的模式是按章节循环:拆一个章节 → 向量化 → 立即 insert,内存占用保持平稳,中途也好观察进度。这是很多 RAG 入库脚本忽略、但很实用的工程细节。

为什么要切块(Chunking)?

单个片段过长会带来两个问题:

  • 向量化时,一个长文本的向量会被「平均淹没」,语义变得模糊;
  • 命中「一句关键线索」时,送给模型的上下文里却掺了大量无关段落。

所以用 RecursiveCharacterTextSplitter 按结构划分,chunkSize=500chunkOverlap=50Overlap(重叠) 也很关键:它让相邻片段共享首尾 50 字符,避免「恰好在切缝处断掉」的句子上下文断裂。

为什么手动生成主键 bookId_chapterNum_index

主键自增数字在多本书、多版本入库时不够直观。手动拼成 {book_id}_{chapter_num}_{index},一眼就能从 ID 反推「这本书第几章第几段」,后面检索、调试定位都方便。

第七步:第一个能跑的 RAG------Naive RAG

数据灌好了,现在写第一个端到端的 RAG。它只有两步:检索(retrieve)+ 生成(generate) ,所以叫 Naive(朴素)RAG------是个极简但完整的最小闭环。

状态定义

js 复制代码
import "dotenv/config";
import { ChatOpenAI, OpenAIEmbeddings } from "@langchain/openai";
import { Annotation, END, START, StateGraph } from '@langchain/langgraph';
import { Milvus } from '@langchain/community/vectorstores/milvus';

const COLLECTION_NAME = "ebook_collection";
const TOP_K = 5;

// LangGraph 的所有节点共享一个 State(状态)。谁往里写,谁就能读。
const GraphState = Annotation.Root({
  question: Annotation,   // 用户问题
  k: Annotation,          // 检索数量
  documents: Annotation,  // 检索到的文档
  generation: Annotation  // 生成的内容
});

const model = new ChatOpenAI({
  model: process.env.MODEL_NAME,
  temperature: 0,
  configuration: { baseURL: process.env.OPENAI_BASE_URL },
  apiKey: process.env.OPENAI_API_KEY
});
const embeddings = new OpenAIEmbeddings({
  model: "text-embedding-v3",
  dimensions: 1024
});

let vectorStore;

检索节点

js 复制代码
async function retrieveRelevantContent(question, k = TOP_K) {
  try {
    // similaritySearchWithScore 返回 [(doc, score), ...]
    const docsWithScores = await vectorStore.similaritySearchWithScore(question, k);
    return docsWithScores.map(([doc, score]) => ({
      score,
      content: doc.pageContent,
      id: doc.metadata?.id ?? "unknown",
      book_id: doc.metadata?.book_id ?? "未知",
      chapter_num: doc.metadata?.chapter_num ?? "未知",
      index: doc.metadata?.index ?? "未知"
    }));
  } catch (err) {
    console.error("检索出错:", err.message);
    return [];
  }
}

const retrieveNode = async (state) => {
  const documents = await retrieveRelevantContent(state.question, state.k);
  return { question: state.question, k: state.k, documents };
};

生成节点

js 复制代码
const generateNode = async (state) => {
  const context = state.documents
    .map((item, i) =>
      `[片段 ${i+1}]
章节: 第 ${item.chapter_num}章
内容:${item.content}`
    ).join("\n\n----------\n\n");

  const prompt = `
你是一个专业的《天龙八部》小说助手。请根据以下小说片段回答问题:
${context}
用户问题:${state.question}

回答要求:
1. 如果片段中有相关信息,请给出详细、准确的回答
2. 可以综合多个片段内容,提供完整答案
3. 如果片段中没有相关信息,请如实告知用户
4. 回答要准确,符合小说情节和人物设定
5. 可以引用原文支持你的回答

AI 助手的回答:
`;
  process.stdout.write("\n[AI回答(流式)]\n");
  let generation = "";
  const stream = await model.stream(prompt);
  for await (const chunk of stream) {
    const text = typeof chunk.content === "string" ? chunk.content : "";
    if (!text) continue;
    generation += text;
    process.stdout.write(text); // 边生成边打印,体验更好
  }
  process.stdout.write("\n");
  return { question: state.question, k: state.k, documents: state.documents, generation };
};

把节点串成图

js 复制代码
const graph = new StateGraph(GraphState)
  .addNode("retrieve", retrieveNode)
  .addNode("generate", generateNode)
  .addEdge(START, "retrieve")
  .addEdge("retrieve", "generate")
  .addEdge("generate", END)
  .compile();

流程是直的:START -> retrieve -> generate -> END

连接向量库并调用

js 复制代码
async function main() {
  const question = "阿朱的结局是什么?";
  vectorStore = await Milvus.fromExistingCollection(embeddings, {
    collectionName: COLLECTION_NAME,
    url: "localhost:19530",
    textField: "content",
    primaryField: "id",
    vectorField: "vector",
    indexCreateOptions: {
      metric_type: "COSINE",
      // HNSW:多层近邻图索引;相比 IVF_FLAT 是另一类索引策略
      index_type: "HNSW",
      params: { M: 16, efConstruction: 200 },
      search_params: { ef: 64 }
    }
  });

  const result = await graph.invoke({
    question,
    k: TOP_K,
    documents: [],
    generation: ""
  });

  result.documents.forEach((item, i) => {
    console.log(`\n【片段 ${i+1}】相似度: ${item.score.toFixed(4)}`);
    console.log(`章节: 第 ${item.chapter_num} 章`);
    console.log(`内容: ${item.content.substring(0, 120)}...`);
  });
  console.log("\n[AI回答]\n", result.generation);
}

main().catch(console.error);

当用户问「阿朱的结局是什么」,这个最小 RAG 会:把问题向量化 → 在 Milvus 里相似度检索出 Top-5 片段 → 把片段拼进 Prompt → 让模型基于片段作答。「先检索,再增强生成」的最小闭环跑通了。

第八步:混合检索------关键词 + 语义 双引擎合流

等你想把架构做得更稳,会发现纯向量检索有一个已知短板:

专业术语、精确实体,纯语义检索容易匹配不准。

比如问「高血糖的成因」,向量检索可能把语义相近的「低血糖」也捞进来;查「乔峰」这种专有名词,关键词精准命中反而更可靠。而反过来,关键词又搞不定同义改写。

所以生产级方案是混合检索(Hybrid Search)

markdown 复制代码
混合检索 = ES 关键词检索(倒排索引,精确命中实体/术语)
         + Milvus 语义检索(向量相似度,命中同义/意译)
         + 模型统一融合多路结果,提升专业场景准确率

关键词检索有 ES 这个「特种兵」,语义检索有向量库这条腿,两者各取所长、由模型融合打分------这才是 RAG 检索层的「完全体」。

小结:这一篇你该带走的东西

  1. MySQL 适合精确结构化查找,不适合大文本全文检索;海量文本检索交给 ES。
  2. ES 快的本质是倒排索引:写入时分词建「词→文档」表,查询时按词条精准命中,无需全表扫。
  3. text 分词做全文检索、keyword 不分词做精确匹配 ,中文配 IK 分词器(存 ik_max_word、查 ik_smart)。
  4. 向量库用 Embedding 把语义编码为向量,靠余弦相似度取 Top-K,补足关键词检索的同义/意译短板。
  5. 入库要做 Chunking(切块)+ Overlap(重叠)+ 流式处理,Naive RAG = 检索 + 生成的最小闭环。
  6. 生产级检索层是混合检索:ES 关键词 + 向量语义双引擎合流。

但这里有个必须诚实面对的问题:这个 Naive RAG 太「死板」了。 它无论什么问题都硬走一遍检索,没有判断、没有纠错、没有多步推理、也不会联网补充。下一篇,我们就把它升级成一个「会思考、会判断、会纠错」的 Agentic RAG

相关推荐
sarasuki1 小时前
如何让 Agent 安全运行你的命令 :命令分级 + Hook + 读写锁
人工智能·设计模式·agent
XLYcmy1 小时前
Prompt 设计相关问题
网络安全·llm·prompt·agent·cot·漏洞检测·harness
先吃饱再说2 小时前
为什么 MySQL 的 LIKE 查询这么慢?Elasticsearch 倒排索引完全解析
elasticsearch·agent
YDS8292 小时前
AI Agent 脚手架 —— 脚手架工程化和Maven私服
ai·agent·spring ai
ba_pi3 小时前
springAI2.0接入mcp读取mysql
java·agent·spring ai
七牛云行业应用3 小时前
WorkBuddy自定义模型失败怎么办?从接口鉴权到协议兼容的完整排查
人工智能·agent·ai编程
深圳市爱派派智能科技有限公司3 小时前
告别部署繁琐:LlamaPi 一键搭建本地 Agent 推理底座
rk3588·agent·openai api·本地部署·边缘ai·端侧大模型·llamapi