一、 引言:为什么你的RAG总是“答非所问”?
在初步尝试大模型落地时,大家通常会使用“朴素RAG”:把文档切块,打上向量,用户提问直接去向量库搜Top-K,塞给LLM。
但很快我们就会遇到瓶颈:
- 向量检索(Milvus等) :语义理解强,但召回不够精准,特别是针对专有名词、订单号、型号等字面信息。
- 关键词检索(ES等) :基于倒排索引的BM25算法,字面匹配准,但缺乏语义丰富度。
- 大模型上下文限制:如果无脑把几十上百篇文档塞给LLM,不仅开销大,还会导致模型“迷失在中间(Lost in the middle)”,产生严重幻觉。
破局之道:Agentic RAG + 混合检索 + 重排。
正如手稿中所总结的公式:
混合检索RAG = Milvus向量检索(语义,缺点是不准确) + ES文本检索(关键词,缺点是语义不够丰富)
本系列分为上下两篇。上篇我们将搞定数据基建与查询扩展,下篇我们将基于LangGraph实现多路召回与Rerank重排。
二、 数据基建:ES与Milvus的双写策略
(参考 seed-data.mjs)
在数据初始化阶段,我们需要将同一份数据(生活笔记)同时写入ES和Milvus。ES负责关键词倒排索引,Milvus负责向量相似度检索。
2.1 ElasticSearch 建表与Mapping
中文场景必须使用 ik_max_word(建索引时细粒度)和 ik_smart(查询时粗粒度)分词器,否则关键词匹配效果极差。同时要显式声明 tags、mood 等字段为 keyword 以支持精确过滤。
javascript
// seed-data.mjs 片段
const client = new Client({node: ES_NODE});
await client.indices.create({
index: indexName,
mappings: {
properties: {
note_title: { type: 'text', analyzer: 'ik_max_word', search_analyzer: 'ik_smart' },
note_body: { type: 'text', analyzer: 'ik_max_word', search_analyzer: 'ik_smart' },
tags: { type: 'keyword' },
mood: { type: 'keyword' },
priority: { type: 'integer' },
create_at: { type: 'date' },
updated_at: { type: 'date' },
}
}
});
// 批量写入数据 (Bulk API)
const now = new Date().toISOString();
await client.bulk({
refresh: true, // 立即刷新索引使其可见
operations: rows.flatMap(row => {
const {id, ...rest} = row;
return [
{ index: { _index: indexName, _id: id } },
{ ...rest, create_at: now, updated_at: now }
];
})
});
2.2 Milvus 向量库建设
将笔记的标题与正文拼接,使用 text-embedding-v3 生成向量,并建立 HNSW 索引。
javascript
// seed-data.mjs 片段
const texts = rows.map(row => `${row.note_title}${row.note_body}`);
const vectors = await embeddings.embedDocuments(texts);
const dim = vectors[0].length; // 向量维度
await milvusClient.createCollection({
collection_name: collectionName,
fields: [
{ name: 'id', data_type: DataType.VarChar, max_length: 100 },
{ name: 'langchain_primaryid', data_type: DataType.Int64, is_primary_key: true, autoID: true },
{ name: DOC_TEXT, data_type: DataType.VarChar, max_length: 10000 }, // 拼接后的文本
{ name: EMBEDDING, data_type: DataType.FloatVector, dim },
// ... 其他字段 (mood, priority, tags等)
]
});
// 建立 HNSW 向量索引
await milvusClient.createIndex({
collection_name: collectionName,
field_name: EMBEDDING,
index_type: IndexType.HNSW,
metric_type: MetricType.L2,
params: { M: 8, efConstruction: 64 }
});
注:ES与Milvus通过 id 字段进行关联,确保在后续混合检索时能够正确合并去重。
三、 查询扩展:把1个问题变成3个
(参考 query-augment.mjs)
手稿中提到:“RAG中怎么提升召回率?改写3-5个不同角度问题,提供更多的文档。”
用户提问往往带有口语化、指代不清等问题。利用LLM进行Query Rewrite是性价比最高的提升召回率的手段。
3.1 结构化输出 Prompt 设计
我们利用 zod 定义严格的 Schema,强制LLM输出一个长度为3的字符串数组,并保留专有名词。
javascript
// query-augment.mjs
import { ChatPromptTemplate } from '@langchain/core/prompts';
import * as z from 'zod';
export const QueryAugmentSchema = z.object({
queries: z.array(z.string()).length(3)
.describe('恰好3条中文检索问句:不同角度改写或扩写;保留订单号、品牌等字面信息;不要编造事实'),
});
const AUGMENT_PROMPT = ChatPromptTemplate.fromMessages([
["system", `用户会给出一句中文问题。请另外写出恰好3条检索用的问句(与原意一致,角度尽量不同)
便于搜索引擎或向量库分别召回:可改写说法、换提问角度、或略加限定词;专有名词、型号、订单号等必须保留原样。
只输出结构化字段 queries(长度为3的字符串数组)`],
["human", "{query}"]
]);
3.2 安全调用与兜底
即使LLM调用失败或输出格式不符合预期,我们也需要保证系统能降级处理,用原问题作为唯一的召回Query。
javascript
export async function augmentQuery(chatModel, query) {
const structured = chatModel.withStructuredOutput(QueryAugmentSchema);
const chain = AUGMENT_PROMPT.pipe(structured); // 工作节点的编排
try {
const raw = await chain.invoke({query});
return { queries: normalizeThreeQueries(query, raw.queries) }
} catch(error) {
// 兜底:失败时直接返回原问题
return { queries: normalizeThreeQueries(query, [])}
}
}
3.3 合并检索Query
最终将原问题和扩展出的3个问题合并,供下游检索节点使用。
javascript
export function retrievalQueryString(original, augmentation) {
return [original, ...(augmentation.queries ?? [])]
.map(s => typeof s === "string" ? s.trim() : "")
.filter(Boolean)
}
四、 总结
至此,我们的数据层(ES+Milvus双写)和输入层(LLM查询扩展)已经准备就绪。
我们解决了“数据怎么存”和“问题怎么改写”的问题。但在下篇中,我们将面临更复杂的挑战:如何并行调度 ES 和 Milvus?如何合并去重?如何利用 Rerank 模型在有限的上下文中优中选优?