当内部文档或长篇文本无法直接塞进大模型时,RAG 就需要先从知识库中找出与问题相关的片段。检索方案从 MySQL 的模糊匹配,发展到 Elasticsearch 的关键词搜索,再到 Milvus 的语义向量召回,各自解决的问题与成本并不相同。下面将沿着这条演进路径,拆解检索底座的设计和落地过程。
一句话导读:LLM 再聪明,也背不出你公司的内部文档。想让 AI 基于自有知识库回答问题,就得先解决「怎么把文本找出来」这件事。本文从 MySQL 全文检索的痛点出发,带你依次搞懂 ElasticSearch 倒排索引、Milvus 向量库,最终落地一个能跑通的朴素 RAG(Naive RAG)管线。
很多人第一次做 RAG 都会问一个问题:为什么不能直接把文档全塞给大模型?
因为三个现实约束:
所以场景收敛成一条固定流水线:先检索出来相关片段,再把片段拼进 Prompt,最后让模型基于片段作答。这就是 RAG(Retrieval-Augmented Generation,检索增强生成)。
而「检索」这两个字,是整个流水线的地基。地基怎么选,直接决定了效果上限。
在公司里,关系型数据库(MySQL)一定是你最先想到的存放方式。它有清晰的 database -> table -> row -> column 结构,字段检索、表关联都很快。但是——当需求变成「在 content 这种大文本字段里做全文检索」时,性能会急剧恶化。
核心问题出在 SELECT ... LIKE '%关键词%' 这类写法上:
一句话:MySQL 适合「结构化数据的精确查找」,不适合「海量文本的关键词全文检索」。
所以业内普遍共识是——文本全文搜索,交给专门干这个的 ElasticSearch(ES)。
一个有趣的类比:MySQL 是常规b队,ES 是特种兵。常规b队管好纪律(行列、事务),特种兵专精攻坚(文本检索)。
ES 快,不是玄学,而是它的底层机制叫倒排索引(Inverted Index)。要理解它,先看 MySQL 的正向索引是怎么存储的。
| 文档 ID | 内容 |
|---|---|
| 1 | 乔峰是丐帮帮主 |
| 2 | 段誉会六脉神剑 |
| 3 | 虚竹破了珍珑棋局 |
正向索引以整行为单位存数据。想找「帮主」,就得把每行内容从头扫一遍,看看有没有这个词——这就是刚才说的慢。
ES 的做法是反过来:写入时先把文本分词(tokenization)拆成一个一个独立词条,然后以词条为核心,反向挂接所有包含它的文档 ID。
| 词条 | 文档 ID 列表 |
|---|---|
| 乔峰 | 1 |
| 帮主 | 1 |
| 六脉神剑 | 2 |
| 珍珑棋局 | 3 |
于是「正向索引是 文档→词,倒排索引是 词→文档」。用户输入关键词时,ES 只需要:
全程不需要全表遍历,所以能在海量文本下实现毫秒级全文检索。这就是 ES 与 MySQL 最本质的差距。
记忆锚点:MySQL 像「先翻一本书再找词」,ES 像「查字典的部首索引,先定位词再翻到页」。
理论说完,直接上车。ES 官方推荐用 Docker 容器化部署,配合 Kibana(可以把它理解为「ES 界面的 phpMyAdmin」)可视化查数据。
先建立心智模型:
一张 compose 文件通常长这样:
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:
启动命令与解释:
docker compose up -d
docker compose:命令会在当前目录寻找 docker-compose.yml 作为编排配置;up:把配置里的容器启动起来;-d:后台运行(detached),不占住你的终端。启动后 ES 9200 端口,Kibana 5601 端口。ES 的 9200 上存放的是索引(索引可理解成 ES 的「表」)。
ES 的操作术语和 MySQL 一一对应,理解起来就顺了:
| MySQL | ElasticSearch |
|---|---|
| database(库) | 通常用索引前缀区分业务 |
| table / 表结构 | index(索引)+ mapping |
| row(行) | document(文档) |
| column(列) | field(字段) |
| SQL 查询 | Query DSL |
| 正排索引(全表扫) | 倒排索引(词→文档) |
GET /_cat/indices?v
输出所有索引,以表格形式组织并显示。
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:查的时候用,粒度粗一点,保证匹配命中率。一句话记忆:写细存,查粗查。
GET /article/_search
{
"query": {
"match": {
"content": "六脉神剑"
}
}
}
这里的 DSL(Domain Specific Language,领域特定语言) 就是 ES 的「SQL」——只不过它通过 HTTP + JSON 来表达查询意图,专门为搜索引擎设计,语义清晰、可组合(能自由叠加 bool、should、filter 等)。
ES 解决的是「关键词/字面」匹配。但很快你会发现一个尴尬:「土豆」和「马铃薯」字面完全不同,语义却一样;纯关键词检索匹配不到它们。
再进一步:RAG 的核心诉求是「找语义相关的片段」,而关键字面匹配对「同义改写、意译、跨语言(tomato vs 西红柿)」无能为力。
这就轮到向量数据库登场了。核心思路:
市面上主流向量库各有定位:
@zilliz/milvus2-sdk-node Node 客户端 + TypeScript),生产级、支持分布式;备注:ES 生态自身也支持向量检索(kNN),很多生产架构其实用 ES(关键词)+ 向量库/向量字段(语义) 双引擎。我们下文先把「关键词检索」和「语义检索」两条腿分别立起来,最后再谈**混合检索(Hybrid Search)**怎么合流。
纸上谈兵结束,现在把一整本 EPUB 电子书拆成片段、向量化、灌进 Milvus。这一步是后续所有 RAG 的地基,代码我会给全,并加了详细注释。
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 入库脚本忽略、但很实用的工程细节。
单个片段过长会带来两个问题:
所以用 RecursiveCharacterTextSplitter 按结构划分,chunkSize=500、chunkOverlap=50。Overlap(重叠) 也很关键:它让相邻片段共享首尾 50 字符,避免「恰好在切缝处断掉」的句子上下文断裂。
bookId_chapterNum_index主键自增数字在多本书、多版本入库时不够直观。手动拼成 {book_id}_{chapter_num}_{index},一眼就能从 ID 反推「这本书第几章第几段」,后面检索、调试定位都方便。
数据灌好了,现在写第一个端到端的 RAG。它只有两步:检索(retrieve)+ 生成(generate),所以叫 Naive(朴素)RAG——是个极简但完整的最小闭环。
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;
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 };
};
const generateNode = async (state) => {
const context = state.documents
.map((item, i) =>
`[片段 ${i+1}]
章节: 第 ${item.chapter_num}章
内容:${item.content}`
).join("nn----------nn");
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 };
};
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。
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):
混合检索 = ES 关键词检索(倒排索引,精确命中实体/术语)
+ Milvus 语义检索(向量相似度,命中同义/意译)
+ 模型统一融合多路结果,提升专业场景准确率
关键词检索有 ES 这个「特种兵」,语义检索有向量库这条腿,两者各取所长、由模型融合打分——这才是 RAG 检索层的「完全体」。
text 分词做全文检索、keyword 不分词做精确匹配,中文配 IK 分词器(存 ik_max_word、查 ik_smart)。但这里有个必须诚实面对的问题:这个 Naive RAG 太「死板」了。 它无论什么问题都硬走一遍检索,没有判断、没有纠错、没有多步推理、也不会联网补充。下一篇,我们就把它升级成一个「会思考、会判断、会纠错」的 Agentic RAG。
AI 生成代码如何验收?用 8 组测试验证 CSV 去重脚本
asp+jsp+JavaScript动态实现添加数据行
把《天龙八部》接入 AI:从 MySQL LIKE 到倒排索引与向量召回,如何搭好 RAG 检索底座?
把 DeepSeek Harness 改造成可协作的 AI 团队
用 WorkBuddy 跑通并改造 DDD 脚手架:jet-ddd-demo 调试实录
Word长文本难整理?用Copilot快速转换为结构化表格