从朴素RAG到Agentic RAG(一):基于ES+Milvus的数据双写与LLM查询扩展实战

7 阅读4分钟

一、 引言:为什么你的RAG总是“答非所问”?

在初步尝试大模型落地时,大家通常会使用“朴素RAG”:把文档切块,打上向量,用户提问直接去向量库搜Top-K,塞给LLM。

但很快我们就会遇到瓶颈:

  1. 向量检索(Milvus等) :语义理解强,但召回不够精准,特别是针对专有名词、订单号、型号等字面信息。
  2. 关键词检索(ES等) :基于倒排索引的BM25算法,字面匹配准,但缺乏语义丰富度。
  3. 大模型上下文限制:如果无脑把几十上百篇文档塞给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 模型在有限的上下文中优中选优?