RAG准确率翻倍,我做了这些优化。。。

RAG准确率翻倍,我做了这些优化。。。 一、RAG效果不好到底卡在哪很多人以为RAG就是“文档切一切→向量化→丢给大模型”就跑完了。结果跑出来效果拉跨还一脸懵逼我都照教程做了啊。RAG的完整流程其实是这样的任何一个环节出问题整个效果都会崩。我见过太多团队在“向量化”这一步死磕换了好几个embedding模型结果提升不到5%。为啥因为问题根本不在那儿。下面我把每个环节拆开来讲希望对你会有所帮助。二、文档解析你喂给RAG的是“干净数据”还是“垃圾”很多小伙伴工作中可能会遇到这种情况从公司内部系统导出一堆PDF、Word文档直接扔进去切分。结果发现很多文档里全是扫描件图片格式一个字都提不出来或者表格被拆得支离破碎完全丢了语义。2.1 问题出在哪大多数RAG教程用的示例文档都是规整的txt或md文件但真实世界里的文档啥样都有扫描版PDF其实就是一张张图片你不做OCR一个字都捞不到Word里的表格直接转成纯文本后行列关系全乱了PPT课件一页一页的每页只有几个bullet point直接切分后上下文碎片化严重带页眉页脚的技术文档每一段都会带上页眉切出来的chunk里全是“第xx页共xx页”这种无效信息2.2 怎么破方案一PDF先做OCR再提取。扫描件PDF别省事儿一定要跑一遍OCR。推荐用PaddleOCR或Tesseract我们实测PaddleOCR中文效果更好。import fitz # PyMuPDF from paddleocr import PaddleOCR ocr PaddleOCR(use_angle_clsTrue, langch) def pdf_to_text_with_ocr(pdf_path): doc fitz.open(pdf_path) full_text [] for page in doc: # 先检查页面是不是纯文本 text page.get_text() if len(text.strip()) 50: # 文字太少怀疑是扫描件 # 把页面转成图片 pix page.get_pixmap() img_path ftemp_page_{page.number}.png pix.save(img_path) # OCR识别 result ocr.ocr(img_path, clsTrue) page_text for line in result[0]: page_text line[1][0] \n full_text.append(page_text) else: full_text.append(text) return\n.join(full_text)方案二表格单独处理。Word和PDF里的表格提取成markdown格式比纯文本好得多因为大模型能理解表格结构。import pandas as pd from docx import Document def extract_tables_from_docx(docx_path): doc Document(docx_path) tables_markdown [] for table in doc.tables: # 转成pandas DataFrame data [] for row in table.rows: row_data [cell.text for cell in row.cells] data.append(row_data) df pd.DataFrame(data[1:], columnsdata[0]) if len(data) 1else pd.DataFrame(data) tables_markdown.append(df.to_markdown()) return\n\n.join(tables_markdown)方案三元数据附加。每个chunk生成的时候把来源文档的名称、章节标题、创建时间作为元数据附加上。这样检索的时候可以按文档来源过滤也方便大模型引用出处。chunk_metadata { source_file: 2025年销售报告.pdf, section: 第三章 华东区销售数据, page: 23, created_at: 2025-12-01 }避坑提醒元数据不要塞太多2-3个关键字段就够了。塞多了embedding的时候反而会稀释正文内容的权重。三、文档切分切分这一步是RAG的“命门”。切太小了上下文丢失语义切太大了检索精准度下降而且token消耗爆炸。切错了神仙也救不回来。3.1 常见切分方式的对比切分方式怎么做优点缺点固定长度按500/1000字硬切简单、可控把一句话拦腰切断语义全毁按句子切用句号/换行分割保正句子完整性单个句子信息量太少按段落切用空行分割保正语义单元段落太长时检索不精准递归切分从段落→句子→固定长度逐级降级灵活、兼顾语义实现稍微复杂语义切分用embedding计算相邻句子的相似度相似度突降时切保正主题一致性成本高、慢3.2 常见的切分方案方案一递归切分RecursiveCharacterTextSplitter。这个方案已经是最佳实践了LangChain里有现成的。它的逻辑是优先按段落切段落太长就按句子切句子太长再按固定长度硬切。from langchain.text_splitter import RecursiveCharacterTextSplitter splitter RecursiveCharacterTextSplitter( chunk_size800, # 每个chunk目标大小字符数 chunk_overlap200, # 重叠长度防止上下文丢失 separators[\n\n, \n, 。, , , , , , ] ) chunks splitter.split_text(long_document)chunk_overlap很多人不重视其实非常关键。举个例子一段关于“Spring事务传播机制”的文字切在两个chunk之间前一个chunk讲完了“REQUIRED”后一个chunk开头讲“REQUIRES_NEW”如果没有重叠检索的时候可能只命中后半个chunk读不到前面的定义答案就残了。经验值chunk_size在500-1000之间比较合理overlap取chunk_size的20%-30%。方案二带结构的切分MarkdownHeaderTextSplitter。如果文档本身是有结构的markdown可以按标题层级来切。每个chunk都带上完整的标题路径作为上下文。from langchain.text_splitter import MarkdownHeaderTextSplitter headers_to_split_on [ (#, h1), (##, h2), (###, h3), ] splitter MarkdownHeaderTextSplitter(headers_to_split_on) splits splitter.split_text(markdown_text) # 每个split的metadata里会有h1, h2, h3字段这个方案尤其适合技术文档、产品手册这类有明确章节结构的文档。检索的时候一个chunk里带着“第三章第二节建表语句”这样的路径信息大模型理解上下文会准确得多。避坑提醒不要一刀切地用固定chunk_size。不同类型的文档应该用不同的切分策略FAQ类适合按问题切、长文章适合递归切、代码库适合按函数切。我们团队的做法是维护一个切分配置表按文档类型走不同的管道。四、向量化Embedding很多人在这一步花大量时间换embedding模型从text-embedding-ada-002换到bge-large-zh换到m3e折腾一圈发现提升有限。为啥因为embedding模型是“通用的”它不可能懂你业务的专有名词。4.1 embedding模型的核心能力embedding的本质是把文本映射到一个高维向量空间语义相近的文本在这个空间里距离更近。不同模型的能力差异主要在两方面语言的覆盖度中文、英文、多语言混合哪个更强句子长度有些模型擅长短文本比如256 token以内有些能处理长文本8192 token但你得明白embedding模型不是你业务的“语义专家”。如果你的文档里全是“SKU”、“PO单”、“BOM表”这些行业术语换什么通用模型都白搭。4.2 真正有效的方案方案一微调embedding模型。用你自己的业务数据去微调模型让模型学会议“PO单”和“采购订单”说的是同一回事。微调需要准备正样本对语义相似的句子对和负样本对语义不同的句子对。# 伪代码示意 train_data [ (用户要求退款, 客户申请退货, 1), # 正样本标签1 (用户要求退款, 系统维护公告, 0), # 负样本标签0 # ... 收集1000对 ] # 用sentence-transformers框架微调微调的门槛不算太高几百到一千条标注数据就能看到明显效果。我们团队用300条人工标注的正负样本微调bge-large-zh后域内检索准确率从68%提到了82%。方案二关键词向量混合检索。纯向量检索对专有名词的命中效果不好因为“PO-202400123”这种字符串的语义太稀疏。混合检索的思路是先用关键词检索BM25或Elasticsearch捞一批候选再用向量检索做精排。from rank_bm25 import BM25Okapi # 建BM25索引 tokenized_corpus [doc.split() for doc in corpus] bm25 BM25Okapi(tokenized_corpus) # 混合检索 query 订单PO-202400123的物流状态 bm25_results bm25.get_top_n(query.split(), corpus, n20) vector_results vector_store.similarity_search(query, k20) # 合并去重后重排序 final_candidates list(set(bm25_results vector_results))混合检索的效果通常比纯向量检索好15-20个百分点尤其在专有名词、编号、代码片段这些场景下效果提升特别明显。方案三Query改写。用户的原始问题有时候太口语化直接拿去检索效果不好。用大模型把用户问题改写成一个更“文档友好”的查询语句。def rewrite_query(original_query): prompt f 将用户的自然语言问题改写成适合检索的形式要求 1. 提取关键实体如订单号、产品名、时间范围 2. 用陈述句表达 3. 去掉语气词和无关信息 原问题{original_query} 改写结果 return llm.invoke(prompt)比如用户问“上个月那个退货单咋还没处理啊”改写后变成“2025年5月退货单处理状态”。这个改写后的查询跟文档内容更接近检索准确率会高很多。五、检索与重排序Rerank召回再多不如排得准。很多RAG系统的效果瓶颈不在“找不到相关文档”而在“把不相关的文档排在了前面”。大模型的上下文窗口有限你塞进去10个chunk只有2个相关大模型被噪音干扰后照样答不对。5.1 为什么要做重排序向量检索一次可以召回几十上百条结果但召回的列表是按向量距离排序的。这个排序是“语义相似度排序”不是“对用户问题的相关性排序”。举个例子用户问“怎么配置数据库连接池”向量检索召回了“HikariCP官方文档”、“Druid配置详解”、“连接池原理”这三条跟用户问题的语义都很接近但哪条最相关“HikariCP官方文档”可能是最相关的但如果向量模型错误地把“Druid配置”排在了第一位结果就崩了。Rerank模型的作用就是用一个更精炼的模型通常是cross-encoder架构对召回结果重新打分排序。5.2 主流Rerank模型对比模型特点适用场景Cohere Rerank效果最好有免费额度追求极致效果不介意调用APIBGE-Reranker开源可本地部署数据敏感、无网环境Cross-Encoder精度高但速度慢小数据量、对延迟不敏感ColBERT延迟和精度的折中大规模检索场景5.3 代码示例from sentence_transformers import CrossEncoder # 加载rerank模型 reranker CrossEncoder(BAAI/bge-reranker-v2-m3) def rerank_results(query, candidates, top_k5): # 构造(query, doc)对 pairs [[query, doc.page_content] for doc in candidates] # 计算相关性分数 scores reranker.predict(pairs) # 按分数降序排序 scored list(zip(candidates, scores)) scored.sort(keylambda x: x[1], reverseTrue) # 取top_k return [doc for doc, score in scored[:top_k]] # 使用 retrieved_docs vector_store.similarity_search(query, k20) reranked_docs rerank_results(query, retrieved_docs, top_k5)避坑提醒rerank模型的计算量比embedding大很多不要对全库做rerank那会慢死。正确流程是先用向量检索快速捞出top-k比如20-50只对这几十条做rerank最后取top-5喂给大模型。六、上下文构建你喂给大模型的prompt质量决定一切。检索回来的chunk怎么塞进prompt里直接决定了最终答案的质量。这一步的细节非常多我见过太多人在这个环节翻车。6.1 基础模板 vs 高级模板最基础的做法就是把chunks拼在一起丢给大模型prompt f 根据以下资料回答问题 {.join(chunks)} 问题{query} 这种做法效果一般。更好的做法是1. 给每个chunk加来源标识。这样大模型在回答时可以引用来源增强可信度。prompt f 请根据以下参考资料回答用户问题。每个参考资料都有编号[1]、[2]等。回答时请引用来源编号。 参考资料 [1] 来自《MySQL性能优化指南》第3章索引设计原则... [2] 来自公司内部Wiki《订单系统设计文档》订单表建表语句... [3] 来自《2025年技术周报》慢查询优化案例... 用户问题{query} 请给出答案并在每个关键信息后面标注来源例如[1]。 2. 使用Few-shot示例。给大模型几个“问答示例”它能更好地理解你期望的回答格式。prompt f 请参考以下示例的格式回答问题。 示例1 问题如何查看MySQL版本 答案可以使用SELECT VERSION();命令查看MySQL版本。 示例2 问题Spring Boot如何配置日志级别 答案在application.yml中配置logging.level.rootINFO。 现在请回答 问题{query} 参考资料{chunks} 答案 3. 增加“不知道就说不知道”的约束。这个极其重要不然大模型会硬编答案。prompt f 重要规则如果参考资料中没有明确的信息请直接说“根据现有资料无法回答该问题”不要编造答案。 参考资料{chunks} 问题{query} 答案 6.2 上下文窗口溢出怎么办当检索回来的chunk总token数超过大模型上下文窗口时比如你用的是max_tokens4096的模型必须做压缩或截断。方案一按相关性截断。Rerank后的chunk列表已经按分数排好序了取前N个直到token数超限为止。import tiktoken enc tiktoken.get_encoding(cl100k_base) max_prompt_tokens 3500 # 给回答留一些空间 selected_chunks [] total_tokens 0 for chunk in reranked_chunks: tokens len(enc.encode(chunk.page_content)) if total_tokens tokens max_prompt_tokens: break selected_chunks.append(chunk) total_tokens tokens方案二摘要压缩。对于特别长的chunk让大模型先做个摘要。但注意摘要会丢失细节适合粗粒度信息不适合精确数值类问题。def summarize_chunk(chunk_text): summary_prompt f请用一句话概括以下内容\n{chunk_text} return llm.invoke(summary_prompt)6.3 多轮对话中的上下文管理RAG系统往往需要支持多轮对话用户追问、澄清。你不能每次都只把“当前问题”拿去检索要把对话历史也纳入考量。def build_context_with_history(query, history, retrieved_chunks): # 把历史对话也写进prompt history_str \n.join([f用户{h[user]}\n助手{h[assistant]} for h in history[-3:]]) prompt f 对话历史 {history_str} 当前用户问题{query} 参考资料{retrieved_chunks} 请结合对话历史和参考资料回答问题。 return prompt同时检索时可以用查询重写把历史信息融合进去def query_with_history(current_query, history): # 让大模型生成一个包含上下文的新查询 prompt f 对话历史{history} 最新问题{current_query} 请把历史信息和最新问题融合成一个完整的查询语句 return llm.invoke(prompt)七、进阶技巧让RAG效果再上一个台阶。上面的基础方案跑通后效果应该能到70%左右。如果要突破到85%以上下面这几个进阶技巧值得一试。7.1 自查询检索器Self-Query Retriever传统的检索只能按文本相似度找但用户常常会问带条件的问题比如“查一下2025年6月的销售数据只要华东区的”。Self-Query的思想是让大模型先解析用户问题提取出查询语句和过滤条件然后用元数据过滤去检索。from langchain.retrievers.self_query.base import SelfQueryRetriever # 定义元数据字段 metadata_fields [ AttributeInfo(nameyear, description文档所属年份, typeint), AttributeInfo(nameregion, description区域华东/华南/华北, typestring), AttributeInfo(namedoc_type, description文档类型销售报告/技术文档, typestring), ] retriever SelfQueryRetriever.from_llm( llmllm, vectorstorevector_store, document_contents公司销售数据和技术文档, metadata_field_infometadata_fields, )用户问“2025年华东区的销售数据”SelfQueryRetriever会自动提取出query“销售数据”filter{year: 2025, region: 华东}。7.2 多路召回与融合不要只依赖一种检索方式。向量检索、关键词检索、SQL查询、API调用都可以作为不同的“召回源”然后把结果融合起来。def multi_path_retrieve(query): results [] # 路径1向量检索 results.extend(vector_store.similarity_search(query, k10)) # 路径2BM25关键词检索 results.extend(bm25_search(query, k10)) # 路径3如果是产品编号直接查数据库 if re.match(r^PO-\d{9}$, query): db_result db.query(fSELECT * FROM orders WHERE order_id{query}) results.append(db_result) # 去重后rerank return deduplicate_and_rerank(results)7.3 假设性文档嵌入HyDE当用户的问题非常抽象时直接拿问题去检索可能效果不好。HyDE的思路是先让大模型假设已经找到了答案根据这个假设生成一段虚拟文档再用虚拟文档去检索。def hyde_retrieve(query): # 1. 让大模型生成一个假设性的答案虚拟文档 hypothetical_doc llm.invoke(f请回答以下问题写一段详细的答案{query}) # 2. 用虚拟文档去向量库检索 real_docs vector_store.similarity_search(hypothetical_doc, k10) return real_docs举个例子用户问“那个很火的数据库迁移工具叫啥来着”直接检索很难匹配。HyDE先生成一段描述“Flyway是一个开源的数据库版本管理工具支持SQL脚本迁移...”然后用这段描述去匹配“Flyway”的文档。HyDE在查询很短、信息量很少的场景下效果提升明显但注意它也会引入“幻觉”——生成的虚拟文档可能不准确导致检索偏离。建议只在查询长度5个词时启用。7.4 窗口检索如果一个文档被切成了很多chunk用户问的问题可能需要跨多个chunk的信息才能回答。窗口检索的做法是当命中一个chunk时把它的前后N个chunk也一起带上。def retrieve_with_window(chunk_id, chunks_list, window_size2): start max(0, chunk_id - window_size) end min(len(chunks_list), chunk_id window_size 1) return chunks_list[start:end]这个方案在长文档、需要上下文连贯的场景比如论文、技术手册里非常有效。7.5 小型专用的索引 vs 一个万能的大索引很多团队上来就想建一个大索引把所有文档往里塞。结果发现技术文档和客服FAQ混在一起检索时互相干扰。更好的做法是按领域拆分索引。技术文档一个索引客服对话一个索引产品手册一个索引。查询的时候先识别用户意图用个小模型分类然后只在对应的索引里检索。def classify_intent(query): # 用轻量级分类器判断 # 返回: tech_doc / customer_service / product_manual return intent def route_query(query): intent classify_intent(query) if intent tech_doc: return tech_vectorstore.similarity_search(query) elif intent customer_service: return service_vectorstore.similarity_search(query) else: return product_vectorstore.similarity_search(query)八、一个生产级RAG管道的Java实现上面讲了这么多理论和Python示例考虑到很多小伙伴是Java技术栈下面用Java完整实现一个RAG管道。这里用了Spring AI Chroma向量库。package com.example.rag; import org.springframework.ai.document.Document; import org.springframework.ai.embedding.EmbeddingClient; import org.springframework.ai.vectorstore.VectorStore; import org.springframework.ai.vectorstore.chroma.ChromaVectorStore; import org.springframework.ai.vectorstore.filter.Filter; import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder; import org.springframework.stereotype.Service; import reactor.core.publisher.Flux; import java.util.*; import java.util.stream.Collectors; Service publicclass RAGPipeline { privatefinal VectorStore vectorStore; privatefinal EmbeddingClient embeddingClient; privatefinal ChatClient chatClient; privatefinal RerankerService reranker; privatefinal QueryRewriter queryRewriter; public RAGPipeline(VectorStore vectorStore, EmbeddingClient embeddingClient, ChatClient.Builder chatBuilder, RerankerService reranker, QueryRewriter queryRewriter) { this.vectorStore vectorStore; this.embeddingClient embeddingClient; this.chatClient chatBuilder.build(); this.reranker reranker; this.queryRewriter queryRewriter; } /** * 完整的RAG问答流程 */ public String ask(String userQuestion) { return askWithHistory(userQuestion, Collections.emptyList()); } public String askWithHistory(String userQuestion, ListMapString, String history) { // 1. 查询改写含多轮对话上下文 String rewrittenQuery queryRewriter.rewriteWithHistory(userQuestion, history); // 2. 检索多路召回 ListDocument retrieved multiPathRetrieve(rewrittenQuery); // 3. 重排序 ListDocument reranked reranker.rerank(rewrittenQuery, retrieved, 5); // 4. 构建prompt带元数据 String prompt buildPrompt(reranked, userQuestion, history); // 5. 调用大模型生成答案 return chatClient.prompt(prompt).call().content(); } private ListDocument multiPathRetrieve(String query) { SetString seenIds new HashSet(); ListDocument all new ArrayList(); // 路径1向量检索 ListDocument vecResults vectorStore.similaritySearch(query, 20); for (Document doc : vecResults) { if (seenIds.add(doc.getId())) { all.add(doc); } } // 路径2关键词检索模拟实际可用ES或Lucene ListDocument kwResults keywordSearch(query, 20); for (Document doc : kwResults) { if (seenIds.add(doc.getId())) { all.add(doc); } } // 路径3如果是数字ID直接查元数据 if (query.matches(\\d)) { Filter filter new FilterExpressionBuilder().eq(id, query).build(); ListDocument idResults vectorStore.similaritySearch( SearchRequest.query(query).withFilter(filter).withTopK(10)); for (Document doc : idResults) { if (seenIds.add(doc.getId())) { all.add(doc); } } } return all; } private String buildPrompt(ListDocument docs, String question, ListMapString, String history) { StringBuilder context new StringBuilder(); int idx 1; for (Document doc : docs) { String source doc.getMetadata().getOrDefault(source, 未知来源); String page doc.getMetadata().getOrDefault(page, ); context.append(String.format([%d] 来源%s, idx, source)); if (!page.isEmpty()) { context.append( 第).append(page).append(页); } context.append(\n).append(doc.getContent()).append(\n\n); } StringBuilder historyStr new StringBuilder(); if (!history.isEmpty()) { historyStr.append(对话历史\n); for (MapString, String turn : history) { historyStr.append(用户).append(turn.get(user)).append(\n); historyStr.append(助手).append(turn.get(assistant)).append(\n); } historyStr.append(\n); } return String.format( 你是一个专业的问答助手。请基于以下参考资料回答用户问题。 重要规则 1. 如果参考资料中没有明确信息请直接回答根据现有资料无法回答该问题。 2. 回答时请在每个关键信息后标注来源编号例如[1]。 3. 答案要准确、简洁、有条理。 %s 参考资料 %s 用户问题%s 答案 , historyStr.toString(), context.toString(), question); } private ListDocument keywordSearch(String query, int topK) { // 简化实现实际可以用Elasticsearch或Lucene // 这里仅示意 returnnew ArrayList(); } }对应的查询重写器实现Component publicclass QueryRewriter { privatefinal ChatClient chatClient; public QueryRewriter(ChatClient.Builder builder) { this.chatClient builder.build(); } public String rewriteWithHistory(String query, ListMapString, String history) { if (history.isEmpty()) { return rewrite(query); } String historyText history.stream() .map(turn - 用户 turn.get(user) \n助手 turn.get(assistant)) .collect(Collectors.joining(\n)); String prompt String.format( 对话历史 %s 最新用户问题%s 请结合对话历史把问题改写成一个完整的、独立的查询语句。 只输出改写后的查询不要输出其他内容。 , historyText, query); return chatClient.prompt(prompt).call().content(); } public String rewrite(String query) { // 简单改写去除语气词 return query.replaceAll(啊|呢|吧|哦|呀, ).trim(); } }这个Java实现涵盖了多路召回、重排序、元数据过滤、多轮对话上下文等核心能力可以直接集成到Spring Boot项目里。九、效果评估没量化就别说自己提升了。说了这么多优化手段还有一个重要问题是你怎么知道自己改完以后效果是真的变好了还是心理作用9.1 评估指标指标怎么算说明召回率检索到的相关文档数 / 总相关文档数越高说明检索越全面精准率检索到的相关文档数 / 总检索文档数越高说明噪音越少MRR平均倒数排名第一个正确答案排名的倒数取平均衡量排序质量答案准确率人工判断答案是否正确最终业务指标9.2 构建测试集不要拍脑袋去评估。准备一套标注好的测试集至少100-200条问题正确答案对应的文档片段。[ { question: 如何配置Spring Boot的数据库连接池, ground_truth: 在application.yml中设置spring.datasource.hikari.*相关参数, relevant_docs: [doc_123, doc_456] } ]每次改完代码跑一遍测试集对比前后指标的变化。这是唯一能科学评估RAG效果的方法。9.3 持续迭代的建议RAG系统没有“一次成型”的说法。上线后还得持续优化记录bad case用户问什么系统没答对人工标注出来补充知识库bad case往往是因为知识库里压根没有相关信息调整切分策略如果是某个chunk内容不全导致的调整切分方式重写查询模板如果是prompt问题微调prompt十、总结写到这里我们已经把RAG从文档解析到最终生成的整条链路都过了一遍。回头看最初那个问题——“如何高效提升RAG效果”我的答案可以浓缩成一句话不要只在一个环节上死磕整条链路都得优化而且每个环节都有明确的优先级。按照我们的实践建议你按这个顺序迭代先做文档清洗OCR、表格提取、元数据附加。这一步投入产出比最高能解决50%以上的基础问题。再优化切分策略从固定长度切分换成递归切分加chunk_overlap。再针对特殊文档代码、表格做定制化处理。引入混合检索向量BM25解决专有名词问题。这一步能让召回率提升10-15%。加rerank只需要在召回结果上做精排效果立竿见影。这一步能让答案准确率提升10%左右。最后打磨prompt加来源标识、Few-shot、拒答规则。这一步决定了最终答案的用户体验。如果有余力上HyDE、Self-Query、多路召回这些进阶技巧它们能把效果从85%往90%以上推但边际收益递减先保证前面的都做到位。另外还有几个容易被忽略的点别把所有文档放一个索引里。按领域拆分按意图路由干扰少效果好。重视多轮对话的上下文管理。很多RAG翻车不是因为没找到资料而是因为没把历史对话考虑进去。建立测试集量化评估。没有数据支撑的“我感觉变好了”在团队决策里一文不值。希望这篇文章能让你少走一些弯路。