公司让我搞RAG,结果答非所问——这6个优化让准确率从60%飙到95%

102 阅读9分钟

这事得从三个月前说起。

公司内部有个知识库系统,存了几千篇技术文档、产品手册、运维手册。领导说:"你不是在搞AI吗?搞个智能问答,让员工直接用自然语言搜文档。"

我想着这不是RAG的典型场景嘛,Spring AI + 向量库,搭起来不复杂。第一版三天就上线了。

然后就被打脸了。

准确率大概只有60%左右——十个问题里有四个答非所问或者胡编乱造。有个同事问"数据库连接池怎么配",AI回答了一堆数据库索引优化的内容。还有一个同事问"线上Redis挂了怎么办",AI给出了一个Redis安装教程。

更尴尬的是,领导亲自试了一下,问了"本季度技术部的OKR是什么",AI回答:"我是一个AI语言模型,无法获取公司OKR信息。"领导当时的表情我现在想起来都觉得尴尬。

接下来两个月,我几乎把所有精力都放在了RAG优化上。准确率从60%提到了95%。下面是真正有效的6个优化手段,每一个都是踩了坑才想明白的。


优化1:切块策略——不是越小越好

第一版我用的是固定长度切块,每个块500 token。这是网上大部分教程的默认做法。

问题是文档被切得稀碎。比如一篇讲"数据库连接池配置"的文档,内容是这样的:

## 连接池配置
连接池最大连接数建议设置为CPU核心数的2-3倍。
公式:maxPoolSize = cpuCores * 2

## 索引优化
对于高频查询字段,建议添加B+树索引...

500 token的切块刚好把"连接池配置"和"索引优化"切成了一块。用户问"连接池怎么配",检索到这个块,但里面一半内容是索引优化的,AI就被干扰了。

改用按标题切分:

// 自定义切分策略:按Markdown标题切分
public class HeadingBasedSplitter implements DocumentTransformer {
    
    @Override
    public List<Document> apply(List<Document> documents) {
        List<Document> result = new ArrayList<>();
        for (Document doc : documents) {
            String text = doc.getText();
            // 按一级和二级标题切分
            String[] sections = text.split("(?=^#{1,2} )", -1);
            for (String section : sections) {
                if (section.trim().length() > 50) {
                    result.add(new Document(section.trim(), doc.getMetadata()));
                }
            }
        }
        return result;
    }
}

按语义边界切分后,"连接池配置"和"索引优化"就各自独立了。单这一项改动,准确率就提升了8个百分点。

经验:切块策略是最容易被忽视的,但它的影响远大于模型选择和prompt工程。不同类型的文档需要不同的切法——Markdown按标题切,PDF按段落切,代码按函数切。一刀切的固定长度是最差的方案。


优化2:检索阶段加Rerank——效果立竿见影

第一版的流程是:用户提问 → 向量检索top 5 → 直接丢给大模型。

问题在于向量检索的排序质量参差不齐。有时候top 1就是最相关的,有时候最相关的排在top 4或5。而且向量相似度高不代表语义相关——有些文档只是"用了相似的词"但"讲的不是一回事"。

加一层Rerank(重排序)模型:

@Service
public class RagService {
    
    private final VectorStore vectorStore;
    private final ChatClient chatClient;
    private final EmbeddingModel embeddingModel;
    
    public String ask(String question) {
        // Step 1: 向量检索,多召回一些(top 20)
        var candidates = vectorStore.similaritySearch(
            SearchRequest.builder()
                .query(question)
                .topK(20)
                .similarityThreshold(0.6)
                .build()
        );
        
        // Step 2: Rerank——用交叉编码器重新打分
        var reranked = rerank(question, candidates, 5);  // 取top 5
        
        // Step 3: 拼接上下文,调用大模型
        String context = reranked.stream()
            .map(Document::getText)
            .collect(Collectors.joining("\n\n---\n\n"));
            
        return chatClient.prompt()
            .system("""
                基于以下参考资料回答问题。要求:
                1. 只使用参考资料中的信息
                2. 如果资料中没有答案,说"文档中没有相关信息"
                3. 不要编造答案
                """)
            .user("参考资料:\n" + context + "\n\n问题:" + question)
            .call()
            .content();
    }
    
    // Rerank:用更精确的交叉编码器重新打分
    private List<Document> rerank(String query, List<Document> docs, int topK) {
        return docs.stream()
            .map(doc -> Map.entry(doc, computeRelevanceScore(query, doc.getText())))
            .sorted((a, b) -> Double.compare(b.getValue(), a.getValue()))
            .limit(topK)
            .map(Map.Entry::getKey)
            .toList();
    }
    
    private double computeRelevanceScore(String query, String docText) {
        // 用交叉编码器模型计算query和document的相关性
        // 比向量余弦相似度更精确
        return crossEncoderModel.score(query, docText);
    }
}

Rerank用的模型是bge-reranker-v2-m3,本地部署的,延迟在30-50ms,完全可接受。

这一步改动让准确率从68%直接跳到79%。是我所有优化里投入产出比最高的一个。


优化3:Hybrid检索——向量不够,关键词来凑

纯向量检索有个问题:对专有名词和精确匹配不敏感。

比如用户问"ZK集群怎么扩容",向量检索可能返回一堆讲"Zookeeper原理"的文档,但用户要的是"扩容操作步骤"。因为向量关注的是语义相似度,而不是精确的关键词匹配。

解决方案是Hybrid检索——向量检索 + BM25关键词检索,融合两者的结果:

public List<Document> hybridSearch(String query) {
    // 向量检索
    var vectorResults = vectorStore.similaritySearch(
        SearchRequest.builder().query(query).topK(20).build()
    );
    
    // BM25关键词检索(用Elasticsearch)
    var bm25Results = elasticsearchClient.search(s -> s
        .query(q -> q.match(m -> m.field("content").query(query)))
        .size(20),
        Document.class
    ).hits().hits().stream().map(h -> h.source()).toList();
    
    // Reciprocal Rank Fusion(RRF)融合
    return reciprocalRankFusion(vectorResults, bm25Results, 5);
}

// RRF算法:综合两路检索的排名
private List<Document> reciprocalRankFusion(
        List<Document> vectorResults, 
        List<Document> bm25Results, 
        int topK) {
    Map<String, Double> scores = new HashMap<>();
    Map<String, Document> docMap = new HashMap<>();
    
    // RRF公式:score = 1 / (k + rank)
    int k = 60;  // 经验值
    
    for (int i = 0; i < vectorResults.size(); i++) {
        String id = vectorResults.get(i).getId();
        scores.merge(id, 1.0 / (k + i + 1), Double::sum);
        docMap.put(id, vectorResults.get(i));
    }
    
    for (int i = 0; i < bm25Results.size(); i++) {
        String id = bm25Results.get(i).getId();
        scores.merge(id, 1.0 / (k + i + 1), Double::sum);
        docMap.putIfAbsent(id, bm25Results.get(i));
    }
    
    return scores.entrySet().stream()
        .sorted((a, b) -> Double.compare(b.getValue(), a.getValue()))
        .limit(topK)
        .map(e -> docMap.get(e.getKey()))
        .toList();
}

Hybrid检索让专有名词查询的准确率提升了很多。从79%到86%。


优化4:元数据过滤——别什么都往context里塞

这个问题是这样的:用户问"Java服务的日志在哪看",向量检索返回了"Java服务日志配置"、"Python服务日志配置"、"运维日志查看指南"等文档。用户只要Java的,但检索结果里混进了一堆不相关的。

解决方案是在检索时加元数据过滤。每个文档入库时打上标签:

// 入库时打标签
public void importDocument(String content, Map<String, String> metadata) {
    Document doc = new Document(content, metadata);
    vectorStore.add(List.of(doc));
}

// 检索时按元数据过滤
var results = vectorStore.similaritySearch(
    SearchRequest.builder()
        .query(question)
        .topK(10)
        .filterExpression("language == 'java' && type == 'guide'")  // 只查Java指南类文档
        .build()
);

但这里有个鸡生蛋蛋生鸡的问题——用户的原始提问里不一定包含过滤信息。用户就是问"日志在哪看",你得先判断他想查哪个语言的。

我用了一个两阶段的方法:先用LLM提取查询意图和过滤条件,再带着过滤条件做检索:

public String ask(String question) {
    // Phase 1: 让LLM分析问题,提取检索策略
    var queryAnalysis = chatClient.prompt()
        .system("""
            分析用户问题,提取以下信息(JSON格式):
            - searchQuery: 优化后的检索关键词
            - filters: 元数据过滤条件(如果有)
            - intent: 用户意图分类(配置/操作/排障/概念)
            """)
        .user(question)
        .call()
        .content();
    
    // 解析LLM返回的检索策略
    QueryStrategy strategy = parseStrategy(queryAnalysis);
    
    // Phase 2: 带策略检索
    var docs = vectorStore.similaritySearch(
        SearchRequest.builder()
            .query(strategy.searchQuery())
            .topK(20)
            .filterExpression(strategy.filterExpression())
            .build()
    );
    
    // Phase 3: Rerank + 生成
    // ...
}

加了一步LLM意图分析后,检索准确率从86%到91%。代价是多了一次LLM调用,延迟增加了300ms左右。对我们来说可以接受。


优化5:回答溯源——让AI说清楚它从哪看到的

这一步不是提升准确率的,而是提升可信度的。

RAG最大的问题是用户不知道AI的回答从哪来的。AI说"连接池最大连接数建议设为CPU核心数的2-3倍",用户怎么知道这是文档里写的还是AI编的?

给每个回答加上来源引用:

// 在prompt里要求AI标注来源
return chatClient.prompt()
    .system("""
        基于以下参考资料回答问题。
        回答时必须在每个观点后标注来源编号,如[1]、[2]。
        参考资料按编号排列。
        如果资料中没有答案,说"文档中没有相关信息"。
        """)
    .user("参考资料:\n" + formatContextWithIds(reranked) 
          + "\n\n问题:" + question)
    .call()
    .content();

// 输出示例:
// 连接池最大连接数建议设为CPU核心数的2-3倍[1]。
// 公式为:maxPoolSize = cpuCores * 2[1]。
// 如果连接数不够,会出现"ConnectionTimeout"异常[3]。

然后在前端把[1][2]渲染成可点击的链接,点击跳转到对应文档。

这一步虽然不影响准确率数字,但对用户体验的提升很大。用户能验证AI说的是不是真的,出了问题也知道去哪个文档查。


优化6:建立评测集——没有度量就没有优化

最后这个优化不是技术手段,是方法论。

之前我说"准确率从60%到95%",这个数字怎么来的?凭感觉吗?

不是。我花了一周时间,整理了200个问答对作为评测集。这些问题来自真实用户提问、客服记录、以及我自己编的边界case。每个问题都有标准答案。

每次改了RAG的任何环节(切块策略、检索参数、prompt),就跑一遍这200题,看准确率变化:

// 评测脚本
public class RagEvaluator {
    
    public EvaluationResult evaluate(List<TestCase> testCases) {
        int correct = 0;
        int partial = 0;
        int wrong = 0;
        List<String> failures = new ArrayList<>();
        
        for (TestCase tc : testCases) {
            String answer = ragService.ask(tc.question());
            String grade = gradeAnswer(answer, tc.expectedAnswer());
            
            switch (grade) {
                case "correct" -> correct++;
                case "partial" -> partial++;
                case "wrong" -> {
                    wrong++;
                    failures.add(String.format("Q: %s\nExpected: %s\nGot: %s", 
                        tc.question(), tc.expectedAnswer(), answer));
                }
            }
        }
        
        return new EvaluationResult(correct, partial, wrong, failures);
    }
    
    // 用LLM做自动评分(节省人工)
    private String gradeAnswer(String actual, String expected) {
        return chatClient.prompt()
            .system("""
                判断AI回答是否正确。标准答案和AI回答如下。
                评分标准:
                - correct: 回答包含了标准答案的核心信息
                - partial: 部分正确,但有遗漏或不精确
                - wrong: 完全错误或编造了信息
                只返回一个词:correct/partial/wrong
                """)
            .user("标准答案:" + expected + "\nAI回答:" + actual)
            .call()
            .content();
    }
}

有了评测集之后,每次优化都能量化效果。之前是"感觉好了一点",现在是"准确率从83%到88%,主要改善在专有名词查询场景"。

这一步是最重要的。 没有评测集,你所有的优化都是盲人摸象。


最终的准确率变化曲线

第一版(固定切块 + 向量检索)        60%
+ 按标题切分                        68%
+ Rerank重排序                      79%
+ Hybrid检索(向量+BM25)           86%
+ 元数据过滤+意图分析                91%
+ 答案溯源(不影响准确率,提升信任度) 91%
+ 评测集驱动迭代                     95%

95%够用吗?对我们来说够了。剩下的5%是那些知识库里确实没有答案的问题,AI正确地说了"文档中没有相关信息"——这本身就是正确行为。


我的一点体会

搞了两个月RAG,最大的感悟是:RAG的瓶颈不在模型,在工程。

用GPT-4还是DeepSeek、用哪个embedding模型,这些当然有影响,但都是几个百分点的差异。真正的差距来自切块策略、检索质量、过滤精度这些工程细节。

很多人一上来就折腾prompt工程,调temperature、改system prompt。但如果你喂给模型的上下文本身就是垃圾,prompt写得再花哨也没用。

Garbage in, garbage out. 这句话放在RAG里再合适不过了。