【RAG】工程化实战:全链路原理复盘 + 方案选型 + 实战高阶玩法

🔥个人主页: Ryan

🔥专栏:【后端】登神长阶 实战落地后端全路线手册

在 AI 圈子里,一直流传着一个心照不宣的秘密:用 LangChain 或 LlamaIndex 几行代码搭一个 RAG(检索增强生成)系统,只需要一个下午;但要让它达到生产环境的落地上线标准,往往需要大半年。

大部分人在做 Demo 时,觉得 RAG 极其简单:把 PDF 切碎 -> 塞进向量数据库 -> 丢给大模型 -> 搞定。然而,一旦把这样的系统推向真实的业务场景,面对复杂的企业用户和各种奇葩的非结构化文档时,系统往往会演变成一个"幻觉放大器"。

1. 建立RAG概念

1.1 什么是RAG

RAG Retrieval-Augmented Generation 检索增强生成

它跳出了传统大模型 "仅凭自身预训练参数完成生成" 的单一模式,将语义检索大模型文本生成两大能力深度结合,让 AI 不再局限于训练阶段习得的固有知识,而是可以实时调取外部资料辅助作答。

一套完整的 RAG 系统分为离线预处理在线推理两大环节:

离线阶段,系统会对各类文档、知识库、业务资料做清洗、文本分块,再通过嵌入模型把文字转化为向量,存入向量数据库建立索引;

在线推理阶段,当用户发起提问时,系统先将问题转为向量,在向量库中精准匹配语义相近的文档片段,随后把检索到的参考资料、用户问题整合为规范提示词输入大模型。最终大模型结合自身理解与真实参考内容,输出完整、有据可依的回答。

1.2 它解决了什么问题

原生大模型受训练机制、数据边界、技术架构所限,在真实业务场景中存在诸多先天短板,RAG 架构正是针对性化解这些痛点而生,核心价值体现在三大方面:

知识幻觉

大模型本质是基于自回归的文本生成模型,一旦面对训练数据未覆盖、细节模糊的内容,很容易凭空编造事实、虚构数据与结论,这就是行业内所说的 "幻觉"。RAG 以检索到的真实文档作为作答基准,强制模型依托原始资料进行输出,从根源上减少虚构内容,让每一句回答都有迹可循,同时增强可解释性。

知识过期

所有主流大模型的预训练数据集都存在固定时间截止点,对于训练完成后诞生的新政策、行业动态、产品迭代信息、实时业务数据等,模型完全无法感知。RAG 的外部知识库支持动态增删改查,只需更新文档就能让系统掌握最新信息,无需重新训练模型,完美适配知识持续迭代的场景。

知识壁垒

企业内部手册、涉密资料、客户档案、专属业务文档等私有数据,体量庞大且具备私密性,既无法全部纳入大模型公开训练集,也不能随意对外共享。RAG 将私有数据独立部署在本地或专属向量库中,仅在问答环节按需调用片段,让大模型安全服务于企业内部专属场景。

1.3 它与LLM参数微调有什么区别

随着主流大模型的上下文窗口(Context Window)从 4K、32K 一路飙升到 1M 甚至 2M(百万级别 Token),技术圈开始出现一种声音:"既然模型能一次性读完几百本书,那 RAG 是不是该被淘汰了?"或者是"要让大模型学习企业私有知识,是不是直接上 Fine-Tuning(微调)更高级?"

这其实是典型的技术选型误区,首先一句话明确:两者解决的不是同一层的问题,不是替代关系

参数微调

参数微调是在预训练大模型的基础上,使用专属数据集继续迭代训练模型权重,相当于把知识、话术、风格永久 "刻入模型本体"。

相较于LLM参数微调,RAG解决了什么问题?

1. 知识更新困难、算力成本高,迭代效率极低

微调想要新增、修改、删除知识,必须重新整理标注数据集、启动训练、验证效果,整个流程从数小时到数天不等,完全无法适配频繁变动的内容(如新政策、产品迭代文档、实时资讯、动态业务数据)。并且微调对硬件要求高:尤其是大参数量模型,需要高性能 GPU 集群支撑训练;同时还要投入人力做数据清洗、标注、调参、模型评估,中小团队很难负担。

RAG 只需要对外部知识库做文档增删改查,向量库同步后即时生效 ,知识迭代效率提升几个量级。RAG 技术链路轻量化,核心工作是文档处理、文本分块、向量化、检索配置,普通服务器即可部署,几乎无需大规模标注和高端算力,落地成本大幅降低

2. 私有 / 涉密数据的合规与安全风险

微调必须将原始数据灌入训练流程,企业内部资料、客户隐私、涉密文档会被模型权重 "记忆",存在数据泄露、违反合规要求的巨大风险。

RAG 实现了模型与数据物理隔离:私有数据始终存放在独立向量库中,仅在问答时临时调取片段辅助生成,全程不参与模型训练,从架构上保障数据安全。

3. 错误知识永久固化,加剧幻觉

如果微调使用的训练数据本身存在错误、片面信息,这些错误会被写入模型权重,变成模型的 "固有认知",后续会反复输出错误内容,形成顽固性幻觉,且很难修复。

RAG 以原始文档为回答依据,事实源头可控;若某篇文档有误,直接替换 / 删除文档即可修复,不会影响整个系统。

4. 海量长文本、超大知识库无法承载

微调受训练数据规模、模型上下文窗口双重限制,不可能将几十万、上百万字的文档库、历史文献、全量业务手册全部纳入训练集。

RAG 通过文本分块 + 语义检索,只抽取和问题强相关的片段送入模型,突破了上下文长度和知识库体量的限制,天生适配海量文档问答。

5. 多领域知识相互干扰、模型能力退化

如果用多领域、碎片化数据反复微调模型,容易出现灾难性遗忘(模型丢失原有能力)、知识混淆、输出混乱等问题。

RAG 可搭建多个独立向量库,不同业务、不同领域的知识库相互隔离,按需切换调用,不会互相干扰。

1.4 为什么你的 RAG 在生产环境成了"幻觉放大器"?

当用户开始抱怨"AI 在胡说八道"时,很多工程师的第一反应是调优 Prompt,或者怪罪大模型(LLM)不够聪明。但事实上,真实生产环境中的 Bad Case,有 80% 发生在检索和数据工程阶段

我们可以把生产环境中最容易翻车的典型场景归纳为以下三种:

"未卜先知"与局部失真(Garbage in, Garbage out):直接拿传统的固定 Token 长度去硬切文档,极容易把一句完整的话、一个核心的表格数据从中间切断。当 LLM 拿到一个被腰斩的 Context(上下文)时,它只能被迫开启"脑补"模式,产生严重的逻辑幻想。

"Lost in the Middle"(大海捞针失败):当你的知识库召回了前 10 个最相关的文本片段丢给大模型时,大模型往往只能记住开头和结尾的信息。这种"长文本中间信息丢失"的现象,直接导致用户问到核心细节时,大模型明明"看"过,却依然回答"不知道"。

时间与空间的错位:向量检索只管"语义相似度",它没有"时效性"概念。如果你的知识库里同时存在《2024年第一季度财报》和《2025年第一季度财报》,当用户问"最新一季度的利润是多少"时,纯向量检索极大概率会因为语义高度相似,把 2024 年的数据召回出来,导致严重的业务灾难。

1.5 链路拆分

离线预处理:原始文档 → 数据清洗 → 文本分块 (Chunk) → 嵌入 (Embedding) → 向量 + 原文入库 + 构建索引

**线上推理:**用户提问 → Query 预处理 → Query 向量化 → 检索召回 → 重排 (Rerank) → 拼接上下文 & 构造提示词 → LLM 生成答案 → 结果后处理返回

后续的实战代码是基于 Spring-Boot-AI-Alibaba

2. 离线预处理

2.1 数据清洗

统一文本格式、剔除无效噪声、去除隐私信息,产出干净、有效、无冗余的纯文本。

在实际的企业开发中,会有专门的RAG知识库管理员去完成这一块工作,去沉淀,整理、产出风格统一,有效的文本(这里拿md文档举例)

这部分的主要工作,主要有 文件格式解析、基础降噪(删除无关符号内容信息)、内容去重、数据脱敏等等

文件格式解析(以 Markdown 为黄金标准) :企业原始文档(PDF/Word/HTML)必须统一向 Markdown 格式对齐。因为 Markdown 的 ### 等层级标签,能天然地为接下来的文本分块提供完美的语义边界信号。

基础降噪与内容去重 :利用正则清洗掉高频噪音(如无意义的特殊符号、乱码、重复的页眉页脚)。针对相同知识点在多份文档中重复出现的场景(例如:不同版本的员工手册),清洗阶段必须做文本去重(如 SimHash 或 MinHash 算法),防止线上检索到大量重复、冗余的 Chunk,白白浪费 LLM 的上下文窗口。

隐私脱敏(Data Masking):生产环境下,安全是不可触碰的红线。在入库前,必须通过命名实体识别(NER)或正则,对身份证号、手机号、薪资等敏感隐私信息(PII)进行掩码或替换脱敏。

java 复制代码
@Service
public class DataCleaningService {

    private final ChatClient chatClient;

    // 注入 ChatClient 用于 LLM 语义清洗
    public DataCleaningService(ChatClient.Builder chatClientBuilder) {
        this.chatClient = chatClientBuilder.build();
    }

    /**
     * 执行完整的前置数据清洗管道
     * @param pdfResource 原始非结构化文件资源
     * @return 清洗并切片完成的、可直接用于 Embedding 的 Document 列表
     */
    public List<Document> processAndCleanData(Resource pdfResource) {
        
        // Step 1: 代码处理 - 原始文档解析提取
        TikaDocumentReader reader = new TikaDocumentReader(pdfResource);
        List<Document> rawDocuments = reader.read();

        // Step 2: 代码处理 - 基于正则与规则的硬清洗 (去噪、去广告、去页眉页脚)
        List<Document> ruleCleanedDocs = rawDocuments.stream()
                .map(this::cleanNoiseByRegex)
                .filter(doc -> doc.getContent() != null && !doc.getContent().isBlank())
                .collect(Collectors.toList());

        // Step 3: LLM 处理 - 语义级别提纯(可选,耗时,通常用于极度离线的核心文档)
        List<Document> llmPurifiedDocs = ruleCleanedDocs.stream()
                .map(this::purifyContentWithLLM)
                .collect(Collectors.toList());

        // Step 4: 代码处理 - 结构化切片 (Chunking)
        // 使用针对 Token 优化的切片器(Spring AI 内置)
        DocumentTransformer splitter = new TokenTextSplitter(
                800,  // chunk size
                200,  // chunk overlap
                10,   // max chunks per doc
                true  // keep separator
        );

        // 返回最终可以直接存入 Milvus/PGVector 的高质量 Chunk 集合
        return splitter.transform(llmPurifiedDocs);
    }

    /**
     * 【纯代码/正则清洗】
     * 极快、零成本。秒杀顽固的无用空白字符、HTML标签、特定广告语和页脚规则。
     */
    private Document cleanNoiseByRegex(Document doc) {
        String content = doc.getContent();

        // 1. 去除多余的空行和连续空格
        content = content.replaceAll("\\s+", " ");
        
        // 2. 正则剔除页眉页脚模板(例如:模拟去除 "Page X of Y" 或 "内部资料,禁止外传")
        content = content.replaceAll("(?i)page \\d+ of \\d+", "");
        content = content.replaceAll("内部资料,禁止外传", "");

        // 3. 剔除常见的无用网页链接或特殊乱码
        content = content.replaceAll("https?://\\S+\\s?", "");

        // 保持 Metadata 原样,只更新清洗后的文本内容
        return new Document(content.trim(), doc.getMetadata());
    }

    /**
     * 【LLM 语义清洗与增强】
     * 依靠大模型对经过初筛的文本进行"脱水"与"纠错"
     */
    private Document purifyContentWithLLM(Document doc) {
        String systemPrompt = """
                你是一个严格的数据清洗专家。请对输入的文本进行预处理,要求:
                1. 修正由于 PDF 或者是 OCR 解析带来的文本断句错误、错别字。
                2. 去除文本中与核心主题无关的口语化垫字、社交推广语。
                3. 严禁篡改原意,严禁补充任何外部知识,仅做去噪和修正。
                4. 直接输出清洗后的纯文本,不要带有任何解释或 Markdown 格式包裹。
                """;

        // 让 LLM 介入清洗
        String purifiedContent = this.chatClient.prompt()
                .system(systemPrompt)
                .user(doc.getContent())
                .call()
                .content();

        // 还可以顺便利用 LLM 做数据增强 (Data Enrichment),比如把处理的元数据顺手挂在 Document 上
        Map<String, Object> metadata = doc.getMetadata();
        metadata.put("is_llm_purified", true);

        return new Document(purifiedContent, metadata);
    }
}

2.2 文本分块

Chunking

大模型、嵌入模型都有单次可处理字符 / Token 上限,几十万字的长文档无法直接向量化和检索。分块就是把长文本切分成一段段短小、语义相对完整的文本片段(Chunk),是决定检索精度的核心环节之一。

切割粒度

chunk既不能太大也不能太小

太大,语义会压缩在一起,变得笼统,且容易超出Embeding模型的输入长度限制

太小,可能会上下文缺失,语义缺失

因此切割的粒度与我们原始文本息息相关

切割策略

1. 固定大小切割 (Fixed Size Chunking)

思路:按固定字符 / Token 数量强行切割,例如每 800 Token 切一块;

纯 固定大小切割 在实际中根本不使用,一般会加上 重叠 Overlap解决边界的截断问题

适用场景:纯文本,无明显结构的文档,这是玩具 Demo 的做法,生产环境极不推荐。因为它无法从根本上解决句子、代码段或表格在中间被拦腰折断的惨剧。

2. 语义边界切割 (Semantic Based Chunking)

思路:按文档的自然语义边界来切割,比如段落,章节,标题层级,不在语义中间截断,找到文字的天然分割点

操作:一般会先维护一个分隔符优先级,然后尝试按段落切,切出来太大,再按标点进一步切分,依次类推,直至满足chunkSize

适用场景:有着清晰标题结构的md或者html

3. 特殊内容专项处理

思想:针对表格、FAQ、代码、问答对、层级目录单独定制切分规则,不破坏原有结构。

4. 父子切割 (Parent-Child-Chunking)

精髓分离"检索"与"生成"的职能

操作 :把长文档切成 1024 词的父块(存入关系/全文数据库供 LLM 阅读),再把父块细切成 256 词的子块(存入向量库用于高精度匹配)。通过 parent_id 建立两者的映射。用更小的特征去勾勒检索线索,用更完整的上下文去赋能 LLM 生成。

java 复制代码
@Component
public class ParentChildDocumentSplitter {

    // 定义父分块和子分块的大小
    private static final int PARENT_CHUNK_SIZE = 800;
    private static final int PARENT_CHUNKS_OVERLAP = 100;
    
    private static final int CHILD_CHUNK_SIZE = 150;
    private static final int CHILD_CHUNKS_OVERLAP = 30;

    /**
     * 将输入的高质量清洗后文档,切分为建立了父子引用关系的 Chunk 集合
     * * @param cleanedDocuments 经过前置清洗后的核心文档列表
     * @return 最终返回【子分块列表】(子分块元数据中会挂载父分块的全量文本和父ID)
     */
    public List<Document> splitWithParentChildStrategy(List<Document> cleanedDocuments) {
        // 1. 初始化父、子切片器
        TokenTextSplitter parentSplitter = new TokenTextSplitter(
                PARENT_CHUNK_SIZE, PARENT_CHUNKS_OVERLAP, 10, true
        );
        TokenTextSplitter childSplitter = new TokenTextSplitter(
                CHILD_CHUNK_SIZE, CHILD_CHUNKS_OVERLAP, 10, true
        );

        List<Document> allChildChunksToEmbed = new ArrayList<>();

        for (Document originalDoc : cleanedDocuments) {
            // 2. 第一层切分:切出父分块 (Parent Chunks)
            List<Document> parentChunks = parentSplitter.apply(List.of(originalDoc));

            for (Document parentDoc : parentChunks) {
                // 为当前父分块生成一个唯一 ID
                String parentId = UUID.randomUUID().toString();
                String parentText = parentDoc.getContent();

                // 3. 第二层切分:把当前的父分块,进一步细切成子分块 (Child Chunks)
                List<Document> childChunks = childSplitter.apply(List.of(parentDoc));

                for (Document childDoc : childChunks) {
                    // 获取并继承原有的元数据,避免丢失来源信息
                    Map<String, Object> childMetadata = childDoc.getMetadata();

                    // 4. 核心纽带:在子分块的元数据中,绑定父分块的特征
                    childMetadata.put("parent_id", parentId);
                    
                    // 策略选择:
                    // 选择 A:只存 parent_id,检索命中后通过数据库二次查询 Parent 文本(适合商业数据库)
                    // 选择 B:直接把父文本塞进子分块元数据,随子分块一同持久化(适合快速落地的轻量系统)
                    childMetadata.put("parent_content", parentText);
                    childMetadata.put("doc_type", "child_chunk");

                    // 重新构建子分块 Document 对象
                    Document standardChildDoc = new Document(
                            childDoc.getContent(), 
                            childMetadata
                    );
                    
                    allChildChunksToEmbed.add(standardChildDoc);
                }
            }
        }

        // 最终返回所有的子分块。这批子分块会被送进 EmbeddingClient 并存入向量数据库
        return allChildChunksToEmbed;
    }
}

5. 延迟切割 Late Chunking

思想:前面提到的这几种策略都是先分块后编码 的处理方式,Late Chunking 就是先编码后分块

关键操作:

  • a. 全局前向传播 :直接把整篇长文档(如 8K 长度)塞进长文本 Embedding 模型(如 jina-embeddings-v2)。模型在计算时,通过全局自注意力机制(Self-Attention),让每一个 Token 向量都吸收了整篇文章的上下文信息。此时,后半句里的"它"在向量层面上已经和"苹果公司"深度绑定了。

  • b. 向量层面切分:保持这个有序的 Token 向量序列不动,根据语义边界或固定大小,在向量序列上"切刀"。

  • c. 块平均池化(Mean Pooling) :对切好的子块内部的所有 Token 向量做平均池化(Mean Pooling),合并成一个能代表这个子块语义的唯一向量。这样得到的子块向量,既有本地高精度,又自带全局上帝视角。

2.3 嵌入 Embedding

这一步是实际做嵌入即向量化的过程,把一段自然语义文本映射为一个固定长度的浮点数向量

**核心价值:**把「语义相似」转化为「空间距离相近」(RAG 的灵魂)

语义越接近的两段文本,它们对应的向量,在高维空间中的距离就越近、夹角就越小。

java 复制代码
/**
     * 生成向量嵌入
     * 调用阿里云 DashScope Text Embedding API
     * 
     * @param content 文本内容
     * @return 向量嵌入(浮点数列表)
     */
    public List<Float> generateEmbedding(String content) {
        try {
            if (content == null || content.trim().isEmpty()) {
                logger.warn("内容为空,无法生成向量");
                throw new IllegalArgumentException("内容不能为空");
            }

            logger.debug("开始生成向量嵌入, 内容长度: {} 字符", content.length());
            
            // 确保 API Key 已设置(防止被其他地方覆盖)
            if (Constants.apiKey == null || Constants.apiKey.isEmpty()) {
                logger.warn("检测到 Constants.apiKey 为空,重新设置");
                Constants.apiKey = apiKey;
            }
            
            logger.debug("调用 API 前 Constants.apiKey: {}", 
                Constants.apiKey != null ? Constants.apiKey.substring(0, Math.min(8, Constants.apiKey.length())) + "..." : "null");

            // 构建请求参数
            TextEmbeddingParam param = TextEmbeddingParam
                    .builder()
                    .model(model)
                    .texts(Collections.singletonList(content))
                    .build();

            // 调用 API
            TextEmbeddingResult result = textEmbedding.call(param);

            // 检查结果
            List<Float> floatEmbedding = getFloats(result);

            logger.info("成功生成向量嵌入, 内容长度: {} 字符, 向量维度: {}", 
                content.length(), floatEmbedding.size());

            return floatEmbedding;

        } catch (NoApiKeyException e) {
            logger.error("API Key 未设置或无效", e);
            throw new RuntimeException("API Key 未设置,请配置 dashscope.api.key", e);
        } catch (Exception e) {
            logger.error("生成向量嵌入失败, 内容长度: {}", content != null ? content.length() : 0, e);
            throw new RuntimeException("生成向量嵌入失败: " + e.getMessage(), e);
        }
    }

Embedding算法

算法是通过迭代的

从初代的静态词向量(Word2Vec、FastText)-> 上下文相关向量(BERT)-> 句子级对比学习 (SBERT)

SBERT 用共享权重的孪生网络(Siamese Network)改造 BERT:

  • 传统 BERT(交叉编码器 Cross-Encoder):两个句子一起输入,输出相似度分数(无法复用)
  • SBERT(双编码器 Bi-Encoder):两个句子分别独立通过同一个 BERT,各自生成句向量,再用余弦相似度比较

2.4 存储数据+构建索引

离线预处理的最后一步,是将清洗出的数据真正持久化。在工业界,一个标准的知识库条目包含"三位一体"的数据流:

  • 向量(Vector) :高维浮点数数组,存入向量数据库的向量索引区(如 HNSW、IVF_FLAT 结构),用于线上的高并发 ANN(近似最近邻)语义检索。

  • 文本块原文(Chunk Text):用于在线检索命中后,直接提取出来拼接进 Prompt 喂给大模型。

  • 元数据(Metadata) :包含 file_name(文件名)、category(业务分类)、created_at(创建时间)、parent_id(父块ID)

java 复制代码
 /**
     * 插入向量到 Milvus
     */
    private void insertToMilvus(String content, List<Float> vector, 
                                Map<String, Object> metadata, int chunkIndex) throws Exception {
        try {
            // 确保 collection 已加载
            R<RpcStatus> loadResponse = milvusClient.loadCollection(
                LoadCollectionParam.newBuilder()
                    .withCollectionName(MilvusConstants.MILVUS_COLLECTION_NAME)
                    .build()
            );

            if (loadResponse.getStatus() != 0 && loadResponse.getStatus() != 65535) {
                throw new RuntimeException("加载 collection 失败: " + loadResponse.getMessage());
            }

            // 生成唯一 ID(使用 _source + 分片索引)
            String source = (String) metadata.get("_source");
            String id = UUID.nameUUIDFromBytes((source + "_" + chunkIndex).getBytes()).toString();

            // 构建字段数据
            List<InsertParam.Field> fields = new ArrayList<>();
            
            // ID 字段
            fields.add(new InsertParam.Field("id", Collections.singletonList(id)));
            
            // content 字段
            fields.add(new InsertParam.Field("content", Collections.singletonList(content)));
            
            // vector 字段
            fields.add(new InsertParam.Field("vector", Collections.singletonList(vector)));
            
            // metadata 字段(JSON 对象)
            com.google.gson.Gson gson = new com.google.gson.Gson();
            com.google.gson.JsonObject metadataJson = gson.toJsonTree(metadata).getAsJsonObject();
            fields.add(new InsertParam.Field("metadata", Collections.singletonList(metadataJson)));

            // 构建插入参数
            InsertParam insertParam = InsertParam.newBuilder()
                    .withCollectionName(MilvusConstants.MILVUS_COLLECTION_NAME)
                    .withFields(fields)
                    .build();

            // 执行插入
            R<MutationResult> insertResponse = milvusClient.insert(insertParam);

            if (insertResponse.getStatus() != 0) {
                throw new RuntimeException("插入向量失败: " + insertResponse.getMessage());
            }

            logger.debug("向量插入成功: id={}, source={}, chunk={}", id, source, chunkIndex);

        } catch (Exception e) {
            logger.error("插入向量到 Milvus 失败", e);
            throw e;
        }
    }

3. 在线推理

3.1 意图识别+前置预处理

作为用户请求的 "第一道闸门",从源头过滤无效请求,同时把用户的问题优化成适合检索的标准格式,避免后续环节做无用功

意图识别

用户的问题千奇百怪(例如闲聊"你是谁"、"今天天气怎么样")。在线推理的第一步,是利用轻量级分类器或关键词规则进行语义路由

  • 如果是闲聊或通用常识,直接路由给基座 LLM,不触发 RAG 检索

  • 如果涉及敏感词或违规提问,直接触发安全护栏(Guardrails)拦截并返回标准拒绝语。

  • 只有当判断问题属于私有知识库领域时,才放行进入下一步。

前置预处理

对用户的原始 Query 做标准化处理,解决口语化、噪声、歧义问题,本质是一个 Query Rewrite 的环节

本质:在最初阶段优化用户的原始查询,以实现:少漏召、少误召、意图匹配更准,从源头提升检索和问答的整体质量。

Query Rewrite 策略

1. 直接改写

  • 基础清洗:去除无效空格、特殊符号、错别字修正(如 "登陆"→"登录")、敏感信息脱敏
  • 口语化转标准:如 "咋弄啊"→"如何进行 XX 操作?",精简冗余表述,保留核心意图
  • 指代消解:补充用户省略的上下文,如用户问 "它的参数是多少?",结合对话历史补全为 "XX 功能的参数是多少?"

Prompt 模版

复制代码
SYSTEM: 你是一个 RAG 检索优化器。请分析多轮对话历史和当前用户问题,消除指代不明(如它、那个、该配置),补全省略主语,弃口语化表达,输出一个最适合知识库检索的标准陈述化问题。
---
对话历史: {chat_history}
用户当前问题: {user_query}
---
要求:仅输出改写后的标准检索词,不做任何解释。

**不足:**我们在之前已经了解到,检索过程实际做的事语义匹配,因此问题与文档还有一个天然语义鸿沟,问题是疑问句,文档是陈述句,这两类文本在向量维度天生有距离,因此引入了下面的策略 HyDE

2. 假设文档嵌入 HyDE (Hypothetical Document Embedding)

核心思想:用 LLM 把短查询 "脑补" 成一篇完整回答(假文档),再用这篇假文档的向量去匹配真实文档------ 假文档与真实文档长度、风格、语义密度一致,向量空间更接近。

Prompt 模版

复制代码
根据用户提出的问题,凭你的常识生成一段可能的答案段落。
注意:不要求事实完全准确,只需模拟专业技术文档/知识库的陈述叙述风格,用于辅助后续的 RAG 语义检索。
用户提问: {user_query}
输出要求:仅输出生成的模拟答案段落,不做任何解释。

存在问题:还存在一种情况,当用户的提出的问题过于具体,具体到这一块的内容确实在知识库里没有针对这一部分的知识,但是有与之相关的背景知识的补充,这种情况改写会失效,因此引入了Step-back Prompting 后退提问

3. Step-back Prompting 后退提问

Step-Back Prompting 由 Zheng 等人于 2023 年在论文《Take a Step Back: Evoking Reasoning via Abstraction in Large Language Models》中正式提出,是一种基于抽象思维的提示工程技术,专门解决 LLM 在复杂推理中 "只见树木不见森林" 的问题。

它的灵感来自人类解决难题的方式:当遇到复杂问题时,我们会先退一步思考核心原理,再用这些原理指导具体解题步骤。

Prompt 模版

复制代码
# Step 1: 后退思考
请先思考这个问题背后的核心原理/基本概念/通用原则是什么?

具体问题:[你的原始问题]
后退问题:[引导模型生成的抽象问题]

请先回答后退问题:

# Step 2: 基于原理解决问题
现在,请基于你对上述核心原理的理解,详细解答原始问题:
[你的原始问题]

举例:

具体问题:线上 SpringBoot 应用频繁 Full GC,老年代回收后内存占用依然很高,该怎么排查?

后退问题:请先说明 JVM 老年代的核心作用、Full GC 的触发条件,以及老年代内存无法释放的根本原理是什么?

4. 多Query扩展

核心思想:用 AI 帮你 "换位思考",生成多个可能的查询方式,覆盖更多检索维度,避免 "一叶障目"。

基本的流程: 原始查询 → 生成变体 → 并行检索 → 结果融合

a. 输入原始查询

用户提出核心问题,如:"如何排查 JVM 频繁 Full GC 且老年代内存不释放?"(这是我们之前 JVM 示例的问题)

b. LLM 生成查询变体(关键步骤)

LLM 分析核心意图,生成 3-5 个不同角度的查询,例如:

  • 变体 1:"JVM 老年代内存无法释放的排查方法"(聚焦内存释放问题)
  • 变体 2:"SpringBoot 应用频繁 Full GC 原因及解决方案"(结合应用场景)
  • 变体 3:"JVM 内存泄漏如何通过堆 dump 分析?"(聚焦工具使用)
  • 变体 4:"JVM GC Roots 引用链分析步骤"(深入底层原理)

c. 并行检索

所有查询变体同时在知识库 / 向量库中检索,获取各自的相关文档集合。

d. 结果融合(去重 + 排序)

常用 RRF(Reciprocal Rank Fusion,倒数排名融合)算法:

大概思路是

  • 对每个文档计算所有查询结果中的排名倒数和
  • 按总分排序,去重后返回 Top-N 结果

3.2 Query 向量化

把预处理后的用户 Query,转换成和离线知识库同空间的向量,是语义检索的 "基础介质"。

**注意:**调用统一 Embedding 模型,必须使用和离线环节分块时完全一致的模型(含版本、tokenizer、参数),否则向量空间不匹配,检索会彻底失效。

3.3 语义检索

快速召回和用户 Query 语义最相关的一批候选文档块,是 RAG 检索阶段的 "粗召回" 环节.

用 Query 向量在向量库中做相似度检索,召回Top-K个结果(K 一般设为 10~30,平衡召回率和后续环节压力)

在实际的企业工程开发中,经常会在这一步采取 多路召回的策略

多路召回

顾名思义,多路召回即采用不同多种的检索方式,召回内容然后进行合并排序

单一检索存在天生缺陷,工业级必须采用「向量语义检索 + 关键词精准检索」的多路召回架构,通过互补规避短板:​

  • 纯向量检索:擅长语义模糊匹配,比如用户问 "如何扩容节点",可以召回 "节点垂直扩容""集群水平扩容" 等相关文档,但对专有名词、数字、编号的召回效果差,无法精准匹配技术文档中的 API 接口、错误码、专属配置项;
  • 关键词检索(BM25 算法):擅长精准匹配,比如用户输入 "API 接口返回错误码 403",可以精准召回包含该字符串的文档,但对语义理解能力弱,无法识别同义词、转述表述;
  • 多路召回融合:结合两者优势,用 Reciprocal Rank Fusion(RRF)算法合并两类检索结果,同时兼顾准召率。
java 复制代码
import io.milvus.param.R;
import io.milvus.param.dml.HybridSearchParam;
import io.milvus.param.dml.AnnSearchParam;
import io.milvus.param.dml.ranker.RRFRanker;
import io.milvus.response.SearchResultsWrapper;
import io.milvus.grpc.SearchResults;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public List<SearchResult> searchSimilarDocuments(String query, int topK) {
    try {
        logger.info("开始多路召回搜索 (稠密向量 + BM25全文检索), 查询: {}, topK: {}", query, topK);

       
        // 1. 准备第一路:稠密向量检索 (Dense Search)
        List<Float> queryVector = embeddingService.generateQueryVector(query);
        logger.debug("稠密查询向量生成成功, 维度: {}", queryVector.size());

        AnnSearchParam denseSearchParam = AnnSearchParam.newBuilder()
                .withVectorFieldName("vector") // 稠密向量字段名
                .withVectors(Collections.singletonList(queryVector))
                .withMetricType(io.milvus.param.MetricType.L2) // 向量相似度度量
                .withTopK(topK)
                .withParams("{\"nprobe\":10}")
                .build();

        
        // 2. 准备第二路:BM25 全文检索 (Sparse Search)
        // Milvus 2.5+ 支持直接传入原始文本查询,内部通过 BM25 函数处理
        AnnSearchParam bm25SearchParam = AnnSearchParam.newBuilder()
                .withVectorFieldName("sparse_vector") // 存储 BM25 稀疏向量的字段名
                .withVectors(Collections.singletonList(query)) // 直接传入原始文本 Query
                .withMetricType(io.milvus.param.MetricType.BM25) // 指定度量类型为 BM25
                .withTopK(topK)
                .build();

        
        // 3. 构建多路召回核心参数 (HybridSearchParam) 与 RRF 重排器
        // RRF (Reciprocal Rank Fusion) 算法通过排名来融合多路结果,不需要归一化不同路的分数
        // k=60 是 RRF 的标准常数
        RRFRanker rrfRanker = RRFRanker.newBuilder()
                .withK(60) 
                .build();

        HybridSearchParam hybridSearchParam = HybridSearchParam.newBuilder()
                .withCollectionName(MilvusConstants.MILVUS_COLLECTION_NAME)
                .addSearchParam(denseSearchParam)  // 压入第一路:向量语义
                .addSearchParam(bm25SearchParam)   // 压入第二路:文本关键词(BM25)
                .withRanker(rrfRanker)             // 设置服务端 RRF 融合重排
                .withTopK(topK)                    // 融合后最终保留的 TopK 条数
                .withOutFields(List.of("id", "content", "metadata"))
                .build();

        
        // 4. 执行混合搜索
        R<SearchResults> searchResponse = milvusClient.hybridSearch(hybridSearchParam);

        if (searchResponse.getStatus() != 0) {
            throw new RuntimeException("Milvus多路混合检索失败: " + searchResponse.getMessage());
        }

        
        // 5. 解析混合搜索结果
        SearchResultsWrapper wrapper = new SearchResultsWrapper(searchResponse.getData().getResults());
        List<SearchResult> results = new ArrayList<>();

        for (int i = 0; i < wrapper.getRowRecords(0).size(); i++) {
            SearchResult result = new SearchResult();
            result.setId((String) wrapper.getIDScore(0).get(i).get("id"));
            result.setContent((String) wrapper.getFieldData("content", 0).get(i));
            
            // 注意:经由 RRF 重新算分后,分数越接近 1 代表综合两路后的相关性越高(与原 L2 的越小越好相反)
            result.setScore(wrapper.getIDScore(0).get(i).getScore()); 
            
            // 解析 metadata
            Object metadataObj = wrapper.getFieldData("metadata", 0).get(i);
            if (metadataObj != null) {
                result.setMetadata(metadataObj.toString());
            }
            
            results.add(result);
        }

        logger.info("多路召回搜索完成, 服务端融合后返回 {} 个最优文档", results.size());
        return results;

    } catch (Exception e) {
        logger.error("多路召回相似文档失败", e);
        throw new RuntimeException("搜索失败: " + e.getMessage(), e);
    }
}

BM25

BM25 全称 Okapi BM25(Best Matching 25),是经典概率式词袋检索模型,诞生于传统信息检索领域,也是目前工业界关键词检索的事实标准。

核心能力:基于关键词词频、文档频率、文档长度计算「查询语句」与「文档」的相关性分数,分数越高代表匹配度越强

核心特性:不理解语序、不理解上下文、不理解深层语义,只统计词汇出现规律(纯词袋模型)

为什么 BM25 会取代 TF-IDF?

在关键词检索(Sparse Retrieval)路径中,Okapi BM25 是目前工业界的事实标准(也是 Elasticsearch 底层的默认相关性算法)。它是对传统 TF-IDF 的一次革命性升级:

  • TF(词频)的饱和压制:在 TF-IDF 中,某个词在文档中出现的次数越多,权重就呈线性无限飙升。这就导致某些恶意堆砌关键词的"灌水长文档"排名靠前。BM25 引入了参数 k_1(通常设为 1.2~2.0),对词频做饱和曲线压制。当一个词出现的次数超过一定阈值后,再增加次数,相关性得分也几乎不再增长。

  • 文档长度归一化(Document Length Normalization):长文档由于词汇量大,天然容易匹配到关键词。BM25 引入了参数 b(通常设为 0.75),动态惩罚那些又长又臭的文档。如果一篇文章很短且精准命中了冷门关键词,它的得分会远高于一篇包含大量废话的长文档。

BM25 的核心优化方向:

  1. TF 做饱和压制:词频增长到一定程度后,不再额外加分;

  2. 引入文档长度归一化因子:动态抵消文档长短带来的偏差;

  3. 基于概率模型重新拟合相关性,整体打分更符合人类检索直觉。

RRF

RRF 全称 Reciprocal Rank Fusion(倒数排名融合),是 Cormack 等人在 2009 年提出的多排序列表融合算法,现已成为工业界混合检索(Hybrid Search)的事实标准。

核心能力:仅基于文档在多个检索列表中的排名,计算综合相关性得分,无需关注原始检索分数的量级与分布;

3.4 重排 Rerank

粗召回环节需要兼顾速度,无法做到精准排序,重排是用少量计算成本大幅提升结果相关性的性价比最优手段。​

粗召回用 Bi-Encoder:独立编码 Query 和文档,速度快但交互语义理解弱;​

重排用 Cross-Encoder:将 Query 与候选文档块拼接成一对,同时输入模型进行交互语义计算,输出两者的相关性得分(一般是 0~1 分),虽然计算量更大、延迟更高,但排序精度比 Bi-Encoder 提升 15%~25%,足以抵消延迟增加的成本。​用重排模型精选出最终的 Top-5 文档,是克服大模型"Lost in the Middle(长文本中间信息丢失)"缺陷的核心胜负手。

java 复制代码
/**
     * 对多路召回后的结果进行深度语义重排
     * @param query 原始用户问题
     * @param candidateResults 多路召回(Hybrid Search)返回的候选集
     * @param finalTopK 最终喂给大模型的内容数量 (通常比多路召回的 topK 小,例如粗筛取 15-20,精筛 Rerank 取 3-5)
     * @return 经由重排模型打分、重新降序排序、且截断后的高质量结果
     */
    public List<SearchResult> rerankDocuments(String query, List<SearchResult> candidateResults, int finalTopK) {
        if (candidateResults == null || candidateResults.isEmpty()) {
            return Collections.emptyList();
        }

        try {
            logger.info("开始执行深度语义重排, 候选文档数: {}, 最终保留 topK: {}", candidateResults.size(), finalTopK);

            // 1. 提取候选文档的纯文本内容,保持索引顺序一致
            List<String> documentsText = candidateResults.stream()
                    .map(SearchResult::getContent)
                    .collect(Collectors.toList());

            // 2. 组装请求 Payload (标准的 Rerank API 协议)
            Map<String, Object> requestBody = new HashMap<>();
            requestBody.put("model", "gte-rerank"); // 或 bge-reranker-large 等生产模型
            requestBody.put("query", query);
            requestBody.put("documents", documentsText);
            requestBody.put("top_n", finalTopK); // 让算法侧直接返回前 N 个

            // 3. 设置请求头
            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.APPLICATION_JSON);
            headers.set("Authorization", "Bearer " + apiKey);

            HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);

            // 4. 发起同步 RPC 调用
            ResponseEntity<RerankApiResponse> responseEntity = restTemplate.postForEntity(rerankUrl, entity, RerankApiResponse.class);
            
            if (!responseEntity.getStatusCode().is2xxSuccessful() || responseEntity.getBody() == null) {
                logger.warn("Rerank API 调用失败,状态码: {}, 触发降级机制:直接返回粗筛结果", responseEntity.getStatusCode());
                return candidateResults.stream().limit(finalTopK).collect(Collectors.toList());
            }

            // 5. 解析重排打分结果并映射回原 SearchResult
            RerankApiResponse response = responseEntity.getBody();
            List<SearchResult> rerankedResults = new ArrayList<>();

            if (response.getOutput() != null && response.getOutput().getResults() != null) {
                for (RerankResultItem item : response.getOutput().getResults()) {
                    // item.getIndex() 代表当前这一条精筛结果对应原 candidateResults 列表中的哪个下标
                    int originalIndex = item.getIndex();
                    if (originalIndex >= 0 && originalIndex < candidateResults.size()) {
                        SearchResult originalDoc = candidateResults.get(originalIndex);
                        
                        // 组装全新的精筛结果
                        SearchResult finalResult = new SearchResult();
                        finalResult.setId(originalDoc.getId());
                        finalResult.setContent(originalDoc.getContent());
                        finalResult.setMetadata(originalDoc.getMetadata());
                        
                        // 覆盖分数为重排模型的绝对置信度得分 (通常在 0~1 之间,越大越相关)
                        finalResult.setScore((double) item.getRelevanceScore()); 
                        
                        rerankedResults.add(finalResult);
                    }
                }
            }

            logger.info("重排阶段完美结束,最终筛选出 {} 条绝对高价值文档输入给 LLM", rerankedResults.size());
            return rerankedResults;

        } catch (Exception e) {
            logger.error("重排服务遭遇致命异常, 触发安全降级", e);
            // 生产防御性编程:如果重排网络抖动或超限,绝不能让 RAG 挂掉,降级直接返回粗筛前 finalTopK 条
            return candidateResults.stream().limit(finalTopK).collect(Collectors.toList());
        }
    }

3.5 上下文拼接

核心做到以下几点

1. 结构化组织上下文

利用明确、不易被污染的分隔符(如 XML 标签)划分系统指令、参考文档、用户 Query,防止大模型发生提示词注入或语意混淆。

2. "考点"动态排序

按照重排相关性得分从高到低的顺序进行拼接。最相关的文档放在最前面(或最后面) ,契合大模型的"首因效应"与"近因效应"。每块文档必须强制附带溯源元数据(如 [来源: 2025年Q4财报.md | 内部ID: 1024]),供前端渲染"查看出处",实现确定性追溯。

3.Token 精准校验

线上拼接 Prompt 时,绝不能用传统的字符串 length() 估算长度。

  • 避坑落地 :必须使用大模型官方对应的分词工具(如 OpenAI 体系的 TikToken ,开源体系的 HuggingFace Transformers Tokenizer)线上精准计算总 Token 长度。

  • 防爆机制:为大模型的最大上下文窗口预留 15%~20% 的"余量预算"(给大模型生成答案留足空间)。一旦 TikToken 检测到当前拼接的文本即将爆仓,立即从相关性最低的尾部文档块开始进行动态拦截或截断剔除。

java 复制代码
@Service
public class PromptSynthesisService {

    private static final Logger logger = LoggerFactory.getLogger(PromptSynthesisService.class);

    /**
     * 将精排后的文档与用户原始问题拼接,生成最终可直接用于大模型演练的 Prompt 对象
     *
     * @param query            用户原始提问
     * @param rerankedResults  重排(Rerank)后留下的最高质量知识分块
     * @return 完美的、带有明确隔离边界的 Spring AI Prompt 对象
     */
    public Prompt buildCombinedPrompt(String query, List<SearchResult> rerankedResults) {
        logger.info("开始拼接 RAG 上下文,当前参考素材数量: {}", rerankedResults.size());

        // 1. 格式化拼接上下文文本 (Context Processing)
        // 生产实践中,建议为每个 Chunk 带上清晰的编号与边界标识(如 <doc_x>),防止大模型产生幻觉或被文档内容带偏
        StringBuilder contextBuilder = new StringBuilder();
        for (int i = 0; i < rerankedResults.size(); i++) {
            SearchResult doc = rerankedResults.get(i);
            contextBuilder.append(String.format("【文献资料 %d】:\n", i + 1));
            contextBuilder.append(doc.getContent().trim());
            contextBuilder.append("\n----------------------------------------\n");
        }
        
        String finalContextString = contextBuilder.toString();
        logger.debug("上下文拼接完成,总字符长度: {}", finalContextString.length());

        // 2. 编写严格的 System Prompt 设定 AI 行为准则(防幻觉的关键)
        String systemPromptText = """
                你是一个专业、严谨的智能知识库助手。
                请严格基于下面提供给你的 [参考资料] 来回答用户的问题。
                
                行为守则:
                1. 如果用户的问题在 [参考资料] 中找不到明确的答案,请直接回答:"抱歉,根据已知资料无法回答该问题。",严禁胡编乱造或凭借自身过往知识补充。
                2. 回答时应当事实求是,保持中立客观,条理清晰。
                3. 如果答案引用了某段资料,可以在句末标明引用的文献编号(例如:[文献资料 1])。
                """;

        // 3. 编写 User Prompt 模板,动态植入上下文与用户 Query
        // {context} 和 {query} 是占位符,由 Spring AI 的 PromptTemplate 动态注入
        String userPromptTemplateText = """
                [参考资料]
                {context}
                
                [用户问题]
                {query}
                
                请基于上述参考资料,回答该问题:
                """;

        // 4. 利用 Spring AI 渲染用户消息
        PromptTemplate userPromptTemplate = new PromptTemplate(userPromptTemplateText);
        // 动态传递参数映射
        Message userMessage = userPromptTemplate.createMessage(Map.of(
                "context", finalContextString,
                "query", query
        ));

        // 5. 组合 System Message 和 User Message
        Message systemMessage = new SystemMessage(systemPromptText);
        
        // 组装成最终的多角色提示词对象(Multi-Message Prompt)
        // 这样分角色传入是目前工业界大模型(如 GPT-4, DeepSeek)最推荐、最稳定的 Prompt 交互形态
        Prompt finalPrompt = new Prompt(List.of(systemMessage, userMessage));

        logger.info("最终 RAG Prompt 组装完毕,准备呼叫大模型。");
        return finalPrompt;
    }
}

4. 评估RAG效果

当你按照前两节的架构,把离线的"父子双层存储"和线上的"多路召回+Rerank"全部撸出来之后,你一定会被业务方或者老板追问两个灵魂拷问:

  1. "你做了这么多重构,系统的回答准确率到底提升了多少?"

  2. "加了这么多改写和重排模型,线上接口的延迟(Latency)撑得住高并发吗?"

如果回答不上来,系统就永远只是个实验室里的玩具。第四节我们就来死磕 RAG 的量化评估高并发工程落地

我们把整个评估过程拆成两层:检索层生成层

4.1 检索层评估

这一层主要关注两个指标

1. Hit Rate @K 命中率 --找的对不对

白话直觉 :我让系统召回 Top-K 个文档块,这 K 个块里只要有一个包含了标准答案所在的文档,就算命中。

业务价值:Hit Rate 决定了 RAG 系统的绝对上限。如果 Hit Rate @5 只有 70%,说明有 30% 的用户提问,大模型连看参考资料的机会都没有。如果这个指标低,说明你的离线数据清洗、父子块切分(Chunking)或者多路召回(BM25 权重)出了大问题。

2. MRR @N (Mean Reciprocal Rank,平均倒数排名) --找的快不快

考核 Rerank 的核心靶向

白话直觉 :不仅要捞得到,还要看最相关的文档排在第几名

数学公式

如果标准文档在系统里排第1名,得分就是 1;排在第2名,得分就是 0.5;排在第十名,得分就是 0.1 。

业务价值 :大模型有严重的"Lost in the Middle"缺陷(只爱看上下文的开头和结尾)。MRR 就是用来监控 Rerank 模型 灵不灵的。MRR 分数越高,说明重排模型越成功地把"核心考点"顶到了大模型的眼皮子底下。

4.2 生成层评估 RAGAs

评估 RAG 绝对不能靠人工抽样瞎猜,目前工业界公认的黄金标准是基于 RAGAs(RAG Assessment)"LLM-as-a-Judge(大模型作为裁判)" 自动化评估框架。

RAGAS 的核心思想是不需要昂贵的真实标准答案(Ground Truth),而是紧扣 用户提问(Query)检索出的上下文(Context)模型生成的回答(Answer) 这三者组成的核心三元组,拆解出三大硬性量化指标:

1. 忠实度(Faithfulness)------ 拦截"参数化幻觉"

定义 :检查 LLM 生成的 Answer 中,到底有百分之多少的内容完全源自检索出来的 Context。

痛点:有时候大模型很聪明,会用自己原本语料库里的常识去回答问题,但企业知识库要的是"依据本地文档回答"。如果模型瞎编或者动用了外来知识,Faithfulness 分数就会暴跌。

计算逻辑:让裁判大模型将 Answer 拆解为多个原子断言,逐一去 Context 中核对是否能推导出来。得分在 0~1 之间,生产环境一般要求 > 0.95。

对策:

  • 【Prompt 强约束与思维阻断】 :在 System Prompt 中加入极端强烈的结构化人设约束。例如:"你是一个严格的文档阅读助手。请仅根据给定的 <context> 标签内的内容回答问题。如果内容中没有提及,请直接回答'知识库未收录该内容',绝对不允许动用你自身的常识或进行任何逻辑外推。"
  • 【降低模型温度(Temperature)】 :直接将 LLM 的推理温度 Temperature 压低至 0.00.1。高温度会鼓励模型的创造力和发散性,而 RAG 生产环境需要的是绝对的严谨与确定性。
  • 【负样本微调(Negative Sample FT)】 :如果改 Prompt 依然压不住某些长文本大模型的"倾诉欲",需要针对性地构建一批 [无答案Context + 问题 $\rightarrow$ 拒绝回答] 的微调数据集,对基座模型进行微调,强行固化模型的"认错"本能。

2. 检索相关性(Context Relevance)------ 榨干"召回纯度"

定义 :评估召回出来的 Context 里面,到底有多少是真正有用的干货,有多少是废话(噪声)这一部分包括 Context Precision 精确率 和 Context Recall 召回率。

痛点 :如果你线上为了保险,一口气召回了 20 个 Chunk 丢给 LLM,虽然包含答案的概率变高了,但噪声也变大了,Context Relevance 得分就会降低。这个指标能倒逼你在线上调优 BM25 与向量检索的融合比例(RRF 参数)

对策:

A. 如果 Context Precision(精确率)过低(废话太多):

  • 工业级对策(调优药方)

    • 【Rerank 动态阈值截断(Threshold Filtering)】:多路召回可能捞上来了 20 个块,但重排模型(Reranker)打分后,不要一股脑全喂给大模型。必须在线上设置一个硬性置信度阈值(如 Score \> 0.6),低于这个分数的块直接动态剔除,精简上下文。

    • 【优化分块(Chunking)粒度】 :说明你的 Chunk 切得太大了(比如 1024 Tokens 里只有 50 Tokens 是有用的)。引入父子块(Parent-Child)架构,将用于检索的子块缩小到 128~256 Tokens,提升向量的几何聚焦度。

B. 如果 Context Recall(召回率)过低(漏掉要点):

  • 工业级对策(调优药方)

    • 【微调 Embedding / 扩充多路召回】 :说明纯向量检索发生了严重的语义漏召。此时必须加大 BM25 关键词检索的权重 ,或者引入多 Query 扩展(Query Expansion),换 4 个不同的角度去并发捞数据,扩大网眼。

    • 【长文本延迟切割(Late Chunking)】:检查是否因为在离线切块时切断了代词(如"它"、"该配置"),导致关键信息没有被赋予正确的全局向量,从而被检索漏掉。改用先编码后切分的 Late Chunking 方案。

3. 答案相关性(Answer Relevance)------ 拒绝"答非所问"

定义:评估生成的 Answer 与用户的原始 Query 是否契合。

痛点:有时候检索很准,模型也没有胡说八道(忠实度很高),但由于 Prompt 没有调好,模型"绕圈子"或者漏掉了用户提问里的核心诉求。

落地实践:通过计算生成答案与原始提问在 Embedding 空间中的语义相似度来量化。

对策:

  • 【优化输出格式与思维链(CoT)触发】 :说明提示词(Prompt)在引导模型组织语言时失控了。在 Prompt 中明确规定输出结构。例如要求模型使用 "总-分-总" 结构,或者引入 Few-Shot(少样本提示),给大模型喂 2-3 个标准的"好回答样本",约束其拟合标准业务风格。
  • 【Query 预处理中的意图对齐】 :有时候是因为用户的提问本身就是反问句或复合复杂句,LLM 在拼接时被搞晕了。必须回溯到 3.1 节的 Query Rewrite 阶段,确保消除了用户的口语化和指代歧义后,再把干净利落的 Query 连同 Context 复合提交。

如果你想造一艘船,不要抓人来收集木头,也不要指派任务和发号施令。相反,你要教会他们向往渴望那辽阔、无垠的大海 ------圣埃克苏佩里

相关推荐
学计算机的计算基2 小时前
MySQL 性能调优面试复习:Explain、索引、慢查询、缓存和架构优化
java·数据库·笔记·mysql
Hillain2 小时前
软件设计师设计模式
java·开发语言·经验分享·笔记·算法·设计模式·软考
影寂ldy2 小时前
C# 泛型方法
java·前端·c#
摇滚侠2 小时前
Spring 零基础入门到进阶 IOC 概述 11 - 13
java·后端·spring
李少兄2 小时前
Spring Boot Test 启动类自动发现机制解析与工程实践
java·spring boot·后端
码云骑士2 小时前
【1.2Java基础】Win10环境变量配置详解-从原理到排雷
android·java
码云骑士2 小时前
【2.Java基础】Java常量与变量-从基本类型到类型转换全面掌握
java·开发语言
AI玫瑰助手2 小时前
Python函数:匿名函数lambda的定义与使用场景
android·java·python
刃神太酷啦2 小时前
MySQL 库表操作 +数据类型+ 基础概念全梳理----《Hello MySQL!》(2)
java·c语言·数据库·c++·vscode·mysql·adb