公司动态
从零构建生产级RAG系统:文档处理、向量检索与混合搜索实战
1. 从“知道”到“用上”为什么RAG是当前AI应用落地的关键一步如果你最近在关注大模型应用开发那“RAG”这个词一定高频出现在你的视野里。它不再是实验室里的概念而是正在成为连接大模型“通用智能”与企业“私有知识”之间那道最现实的桥梁。我见过太多团队拿着一个能力强大的基础模型比如GPT-4、Claude或者开源的Qwen、Llama兴奋地想要把它接入自己的业务系统却很快撞上南墙模型对内部流程、产品细节、非公开文档一问三不知甚至还会一本正经地“胡编乱造”幻觉问题。这时候RAG检索增强生成就成了那个“开锁的钥匙”。简单来说RAG的核心思想并不复杂当用户提问时不是让大模型凭空回忆或生成而是先从你的专属知识库比如公司文档、产品手册、客服记录里找到与问题最相关的几段资料然后把“问题相关资料”一起交给大模型让它基于这些“证据”来组织答案。这就像让一个博学的顾问在回答前先快速翻阅一遍最相关的档案柜。这样做的好处立竿见影答案的准确性和专业性大幅提升幻觉被有效遏制并且知识更新变得极其简单——你只需要更新档案柜向量数据库里的文件即可。然而从“知道RAG是什么”到“做出一个稳定、高效、可用的RAG系统”中间隔着一道巨大的鸿沟。网上充斥着各种“5分钟搭建RAG”的教程它们往往止步于一个能跑的Demo却对生产环境中必须面对的细节避而不谈文档该怎么切分才合理向量检索到底用余弦相似度还是点积检索出来的结果质量不高怎么办如何评估整个RAG系统的效果本系列文章的目的就是和你一起亲手填平这道鸿沟。我们将完全从零开始不依赖任何“一键部署”的黑箱一步步构建一个面向生产级考虑的RAG系统。上半部分我们将聚焦在最核心的“检索”环节打好地基。2. 构建知识库的基石文档处理与向量化全流程拆解一个RAG系统的表现十之七八在检索而检索的质量又十之七八取决于知识库的构建。这一步没做好后面用再高级的模型也是“垃圾进垃圾出”。我们把这个过程拆解为四个关键步骤加载、切分、向量化、存储。每一步都有大量细节值得深究。2.1 文档加载与解析告别格式陷阱你的知识可能存在于PDF、Word、PPT、HTML、Markdown甚至Excel表格中。第一步就是把它们统一转换成纯文本。这里最大的坑在于“信息丢失”和“格式错乱”。以最常见的PDF为例很多教程直接用PyPDF2的extract_text但对于扫描版PDF或复杂排版的PDF效果惨不忍睹。生产环境我推荐使用pdfplumber或pymupdf它们对文本位置和布局的分析能力更强。对于包含大量表格的文档camelot或tabula是更好的选择但它们配置更复杂。# 一个更健壮的PDF加载示例使用pdfplumber import pdfplumber def load_pdf_with_pdfplumber(file_path): text_chunks [] with pdfplumber.open(file_path) as pdf: for page in pdf.pages: # 提取文本尝试保持布局 text page.extract_text(layoutTrue) # 提取表格 tables page.extract_tables() for table in tables: # 将表格转换为Markdown格式的文本便于后续处理 table_text \n.join([|.join(row) for row in table]) text f\n\n[表格开始]\n{table_text}\n[表格结束] if text.strip(): text_chunks.append(text) return text_chunks对于网页内容直接requestsBeautifulSoup是基础但要小心JavaScript渲染的内容。对于动态加载的页面可能需要用到Selenium或Playwright。这里的关键是定义一个清晰的解析规则比如只提取article标签或特定class下的内容避免把导航栏、广告、页脚也塞进知识库。注意在解析阶段务必记录每个文本块的元数据Metadata例如来源文件名、原始页码、章节标题等。这些元数据在后续的检索结果溯源和精排阶段至关重要。一个简单的字典结构如{“source”: “用户手册.pdf”, “page”: 5, “section”: “安装步骤”}就很有用。2.2 文本切分Chunking艺术与科学的结合这是整个流程中最具“艺术性”的一环没有放之四海而皆准的黄金法则。切分的目标是让每个文本块Chunk包含一个相对完整的语义单元既不能太碎丢失上下文也不能太大引入噪声且影响向量表征质量。1. 固定长度重叠切分最简单粗暴的方法。比如每256个字符切一段前后重叠50个字符。用LangChain的RecursiveCharacterTextSplitter可以轻松实现。from langchain.text_splitter import RecursiveCharacterTextSplitter splitter RecursiveCharacterTextSplitter( chunk_size256, chunk_overlap50, length_functionlen, separators[\n\n, \n, 。, , , , ] ) chunks splitter.split_text(full_text)这种方法适用于格式规整、语义边界不明显的长文本如日志、某些报告。缺点是可能把一个完整的句子或段落从中间切断。2. 基于语义分割器更高级的方法尝试在自然的语义边界处切分如段落、标题、句子。spaCy或nltk的句子分割器是基础工具。更智能的如semantic-text-splitter这类库会利用嵌入模型计算句子间的相似度在语义变化大的地方进行切分。这能产生质量更高的块但计算成本也更高。3. 混合策略与分层索引在生产系统中我经常采用混合策略。例如先按章节/标题进行粗切分大块如2000字符再对大块进行固定长度的重叠细切分小块如512字符。然后构建两层索引大块索引用于快速定位相关章节小块索引用于精准检索细节。这平衡了召回率和精度。我的经验是切分策略必须与你的文档类型和查询模式相匹配。如果是问答型查询“产品的保修期是多久”小块128-256字符效果更好如果是需要推理和总结的查询“对比A方案和B方案的优劣”则需要更大的块512-1024字符以保留更多上下文。最好的方法是准备一个小的测试集用不同的切分参数跑一下检索效果选择最优的。2.3 向量化Embedding将文本映射为数学空间这是让计算机“理解”文本语义的关键一步。我们通过嵌入模型Embedding Model把一段文本转换成一个固定长度的、高维的向量比如768或1024维。语义相似的文本其向量在空间中的距离如余弦相似度也更近。模型选型OpenAItext-embedding-3系列效果第一梯队简单易用但按量付费且有网络要求。开源模型这是生产环境的主流选择可私有化部署。通用性强BAAI/bge-large-zh-v1.5中文、thenlper/gte-large中英文是当前社区公认的标杆效果非常接近甚至超越付费API。轻量化BAAI/bge-small-zh-v1.5、sentence-transformers/all-MiniLM-L6-v2在精度和速度间取得平衡适合对延迟敏感的场景。最新趋势像jina-embeddings-v2这类支持长上下文如8192 token的模型可以直接嵌入整个文档避免了切分的麻烦但对计算资源要求更高。本地部署示例使用Sentence Transformersfrom sentence_transformers import SentenceTransformer # 加载模型首次运行会自动下载 model SentenceTransformer(BAAI/bge-large-zh-v1.5) # 编码文本列表 sentences [这是一个句子, 这是另一个句子] embeddings model.encode(sentences, normalize_embeddingsTrue) # 归一化便于使用余弦相似度normalize_embeddingsTrue非常重要它将向量归一化为单位长度此时余弦相似度计算简化为点积np.dot(a,b)计算效率极高。关键参数与优化批处理Batch一次性编码大量文本时务必使用批处理能极大提升GPU利用率。embeddings model.encode(sentences, batch_size32, show_progress_barTrue)设备Device明确指定使用GPU。model SentenceTransformer(BAAI/bge-large-zh-v1.5, devicecuda)精度Precision如果显存紧张可以尝试使用半精度fp16。model model.half() # 转换为半精度模型2.4 向量数据库选型与实战以Milvus为例向量生成后我们需要一个专门的数据来存储它们并能进行高速的相似度搜索。这就是向量数据库Vector Database的用武之地。它和传统关系型数据库如PostgreSQL的核心区别在于它针对高维向量的“近似最近邻搜索ANN”做了极致优化。为什么不用PostgreSQL的pgvector插件pgvector让PostgreSQL具备了存储和搜索向量的能力对于小规模数据量比如十万级、且团队已有PostgreSQL运维经验的场景它是一个快速上手的方案。但它本质上是通用数据库的一个扩展在专为向量搜索设计的索引算法如IVF_FLAT、HNSW、大规模分布式部署、性能监控工具链等方面与专业向量数据库有差距。当你的数据量达到百万、千万级或者对查询延迟QPS、P99延迟有严格要求时专业向量数据库的优势就体现出来了。主流向量数据库对比特性MilvusPinecone (托管)Weaviate (开源/托管)Qdrant (开源)核心优势生态成熟功能全面性能强劲社区活跃完全托管开箱即用无需运维内置向量对象存储GraphQL接口Rust编写性能极致API简洁部署方式可自托管K8s/ Docker也有云托管版仅云托管可自托管也有云托管可自托管也有云托管学习成本中等概念较多集合、分区、段等极低中等较低适合场景中大型企业需要深度控制和定制化初创团队快速验证原型无运维负担需要同时处理结构化数据和向量的场景对性能和资源效率有极致要求的场景为什么选择Milvus作为示例因为它可能是目前最成熟、功能最全的开源向量数据库之一。它诞生得早经历了大量生产环境的检验社区和文档都非常完善。它支持多种索引类型、标量过滤、时间旅行查询、数据压缩等高级功能能满足从简单到复杂的各种需求。学习它能帮你理解向量数据库领域的核心概念。2.4.1 Milvus 安装与部署避开初学者的坑网上教程很多但有些细节不提就容易踩坑。这里以Docker Compose方式部署单机版为例这是开发测试的最佳方式。准备工作确保你的机器已安装Docker和Docker Compose。建议为Milvus的数据和日志挂载本地目录便于持久化和排查问题。下载配置文件从Milvus官网GitHub仓库获取最新的docker-compose.yml文件。千万不要直接用一些老旧教程里的配置。wget https://github.com/milvus-io/milvus/releases/download/v2.4.0/milvus-standalone-docker-compose.yml -O docker-compose.yml启动前关键检查打开docker-compose.yml找到etcd、minio、milvus-standalone服务的volumes配置。确保它们映射到了你主机上的目录例如services: etcd: volumes: - ./volumes/etcd:/etcd minio: volumes: - ./volumes/minio:/minio_data milvus-standalone: volumes: - ./volumes/milvus:/var/lib/milvus然后创建这些目录mkdir -p ./volumes/{etcd,minio,milvus}。这一步避免了容器重启后数据丢失。启动服务docker-compose up -d使用docker-compose ps查看所有容器状态是否为 “Up”。首次启动会拉取镜像需要一些时间。连接验证Milvus默认通过gRPC的19530端口和HTTP的9091端口提供服务。我们可以用Python客户端快速测试。from pymilvus import connections, utility # 连接到Milvus服务 connections.connect(hostlocalhost, port19530) # 检查连接是否成功 print(utility.get_server_version())如果输出版本号如2.4.0恭喜你Milvus已经成功跑起来了。踩坑记录最常见的问题是端口冲突。确保19530和9091端口没有被其他程序占用。另一个问题是资源不足Milvus运行需要一定内存如果容器启动后不断重启可以尝试增加Docker的内存分配在Docker Desktop设置中。2.4.2 核心概念与数据操作不仅仅是存和取理解Milvus的几个核心概念能让你更好地设计数据架构集合Collection相当于关系数据库中的“表”是存储向量和标量数据的顶层容器。字段Field集合中的列。最重要的字段类型是FloatVector存储向量此外还有Int64、VarChar等标量类型用于存储元数据如文档ID、标题。分区Partition集合的逻辑子集。你可以按业务维度如“2024年文档”、“产品A手册”创建分区查询时可以限定在特定分区提升搜索效率和管理便利性。对于小规模数据可以不用分区。索引Index这是向量数据库性能的灵魂。Milvus支持多种索引类型如IVF_FLAT、IVF_SQ8、HNSW。创建索引是一个后台过程它会根据你选择的算法对向量数据进行预处理以加速ANN搜索。下面是一个完整的创建集合、插入数据、创建索引、搜索的示例from pymilvus import ( connections, utility, FieldSchema, CollectionSchema, DataType, Collection, AnnSearchRequest, RRFRanker, WeightedRanker ) # 1. 连接同上 connections.connect(hostlocalhost, port19530) # 2. 定义字段 fields [ FieldSchema(nameid, dtypeDataType.INT64, is_primaryTrue, auto_idTrue), FieldSchema(nametext_chunk, dtypeDataType.VARCHAR, max_length65535), # 存储原始文本 FieldSchema(nameembedding, dtypeDataType.FLOAT_VECTOR, dim768), # 向量维度必须与你的模型匹配 FieldSchema(namesource, dtypeDataType.VARCHAR, max_length255), # 元数据来源 FieldSchema(namepage_num, dtypeDataType.INT64), # 元数据页码 ] # 3. 定义集合Schema schema CollectionSchema(fields, description我的产品知识库) # 4. 创建集合 collection_name product_knowledge_base if utility.has_collection(collection_name): utility.drop_collection(collection_name) # 如果已存在先删除仅演示用 collection Collection(namecollection_name, schemaschema) # 5. 插入数据假设chunks是文本列表embeddings是对应的向量列表 data [ [chunk for chunk in chunks], # text_chunk embeddings, # embedding (list of list) [user_manual.pdf] * len(chunks), # source list(range(len(chunks))) # page_num (示例) ] # 注意id字段是自增的所以不用提供。data列表的顺序必须与fields定义一致。 mr collection.insert(data) print(f插入完成ID为: {mr.primary_keys}) # 6. 将数据从内存刷新到磁盘确保可搜索 collection.flush() # 7. 创建索引以HNSW为例平衡了性能和精度 index_params { index_type: HNSW, metric_type: IP, # 内积因为我们使用了归一化向量内积等价于余弦相似度 params: {M: 16, efConstruction: 200} # HNSW参数M影响索引大小和精度efConstruction影响构建速度 } collection.create_index(field_nameembedding, index_paramsindex_params) # 8. 将集合加载到内存搜索前必须步骤 collection.load() # 9. 执行向量搜索 search_vectors [embeddings[0]] # 用第一段文本的向量作为查询向量 search_params {metric_type: IP, params: {ef: 50}} # 搜索时HNSW的参数ef results collection.search( datasearch_vectors, anns_fieldembedding, paramsearch_params, limit5, # 返回最相似的5条结果 output_fields[text_chunk, source, page_num] # 指定需要返回的字段 ) # 10. 解析结果 for hits in results: for hit in hits: print(fID: {hit.id}, 距离: {hit.score}, 文本: {hit.entity.get(text_chunk)[:100]}...) print(f来源: {hit.entity.get(source)}, 页码: {hit.entity.get(page_num)})关于索引参数的经验之谈HNSW是目前最流行的索引因为它查询速度快、精度高且支持增量插入。M每个节点的连接数和ef搜索时的动态候选集大小是关键参数。M越大、ef越大精度越高但索引体积和搜索耗时也增加。通常M在16-24之间ef在50-200之间调整。构建索引时用efConstruction搜索时用ef。IVF_FLAT和IVF_SQ8是经典算法。IVF_SQ8对向量进行标量化8位整型压缩能大幅减少内存占用约为原始的1/4精度损失很小是内存敏感场景的好选择。其参数nlist聚类中心数需要设置通常取sqrt(数据量)左右。如何选择数据量小100万追求极致精度用IVF_FLAT内存紧张用IVF_SQ8大部分场景尤其是需要平衡性能和精度的用HNSW准没错。最好的方法是用你的实际数据做基准测试。3. 超越简单向量检索提升召回质量的混合搜索策略如果你的RAG应用只做单纯的向量相似度搜索很快你就会发现一些问题对于包含具体名称、日期、代码的“精确匹配”类查询效果可能不如传统的全文检索如Elasticsearch的BM25算法。例如查询“2023年Q4财报中提到的‘北极星’项目”向量搜索可能找到一堆关于“星辰”、“指南针”的文档而BM25能精准锁定“2023”、“Q4”、“北极星”这些关键词。因此生产级的RAG系统普遍采用“混合搜索Hybrid Search”策略即同时进行向量检索和全文检索然后将两者的结果融合Rerank取长补短。3.1 实现混合搜索BM25 向量检索我们需要一个能进行全文检索的组件。你可以选择集成Elasticsearch或Meilisearch但对于想快速上手的项目rank_bm25这个Python库提供了一个轻量级的纯内存BM25实现非常适合原型验证和小规模数据。步骤一构建BM25索引在文档切分后除了生成向量我们还需要为文本块构建BM25索引。from rank_bm25 import BM25Okapi import jieba # 用于中文分词 # 假设 chunks 是文本块列表 tokenized_corpus [list(jieba.cut(chunk)) for chunk in chunks] # 对每个chunk进行分词 bm25_index BM25Okapi(tokenized_corpus)步骤二并行执行两种检索当用户查询到来时向量检索用同样的嵌入模型将查询语句转换为向量在Milvus中搜索。BM25检索对查询语句进行分词然后用BM25计算与所有文档的相关性得分。def hybrid_search(query, vector_collection, bm25_index, tokenized_corpus, chunks, top_k10): # 1. 向量检索 query_vector model.encode([query], normalize_embeddingsTrue)[0] vector_results vector_collection.search( data[query_vector], anns_fieldembedding, paramsearch_params, limittop_k, output_fields[id, text_chunk] ) vector_hits {hit.id: hit.score for hits in vector_results for hit in hits} # 2. BM25检索 tokenized_query list(jieba.cut(query)) bm25_scores bm25_index.get_scores(tokenized_query) # 获取BM25的top_k个结果 bm25_top_indices np.argsort(bm25_scores)[::-1][:top_k] bm25_hits {idx: bm25_scores[idx] for idx in bm25_top_indices} # 此时我们有两组ID和得分vector_hits (key: Milvus ID, value: 向量相似度分数) # 和 bm25_hits (key: 文本列表索引, value: BM25分数)。 # 我们需要一个公共的ID来融合它们。一个简单办法是使用文本块的哈希值作为唯一ID。 # 这里为了简化假设我们能用某种方式映射实际中需要更严谨的设计。 # 一种常见做法是在插入Milvus时为每个chunk生成一个唯一ID如chunk_hash # 同时将这个ID和chunk文本存储在BM25的索引结构中。这样两边就能通过chunk_hash关联。 # 假设我们已经建立了这种映射并得到了融合前的两个列表 # vector_results_list: [(chunk_hash, vector_score, text), ...] # bm25_results_list: [(chunk_hash, bm25_score, text), ...] return vector_results, bm25_top_indices, bm25_scores # 注意上述代码是概念演示融合逻辑需要更完整的数据关联设计。3.2 结果融合与重排序Rerank从相关到精准拿到向量搜索和BM25搜索的两组结果后我们不能简单粗暴地合并去重因为两者的分数分布完全不同向量相似度可能是0-1的余弦值BM25分数可能从0到正无穷。直接合并就像把摄氏度和华氏度直接相加一样没有意义。1. 分数归一化Normalization将两种分数统一到同一个量纲上常用方法有Min-Max归一化将分数线性缩放到[0, 1]区间。def min_max_normalize(scores): min_s, max_s min(scores), max(scores) if max_s min_s: return [0.5] * len(scores) # 防止除零 return [(s - min_s) / (max_s - min_s) for s in scores]Z-Score归一化标准化将分数转换为均值为0标准差为1的分布。def z_score_normalize(scores): mean_s np.mean(scores) std_s np.std(scores) if std_s 0: return [0] * len(scores) return [(s - mean_s) / std_s for s in scores]2. 加权融合为归一化后的向量分数和BM25分数分配权重进行加权求和得到最终分数。权重需要根据你的数据和查询类型进行调优。例如对于事实性、关键词明确的查询可以给BM25更高权重如0.7对于语义性、概括性的查询给向量搜索更高权重如0.7。final_score alpha * normalized_vector_score (1 - alpha) * normalized_bm25_score3. 使用交叉编码器Cross-Encoder进行精排这是目前提升RAG答案质量最有效的手段之一。交叉编码器是一种更强大的神经网络它同时接收“查询语句”和“候选文档”作为输入直接输出一个相关度分数。它比双编码器即我们之前用的向量化模型计算量更大、更慢但精度也高得多。通常流程是先用快速的向量/BM25混合搜索召回出100个候选文档粗排再用交叉编码器对这100个文档进行精排选出最相关的3-5个送入大模型生成答案。from sentence_transformers import CrossEncoder # 加载一个交叉编码器模型例如BAAI/bge-reranker-large reranker CrossEncoder(BAAI/bge-reranker-large) # 假设我们有查询query和粗排得到的候选文本列表candidate_texts pairs [[query, cand] for cand in candidate_texts] rerank_scores reranker.predict(pairs) # 得到每个(query, cand)对的分数 # 根据精排分数重新排序候选文本 ranked_indices np.argsort(rerank_scores)[::-1] top_reranked_texts [candidate_texts[i] for i in ranked_indices[:5]]加入重排序后整个检索流程的精度会有质的飞跃尤其能有效解决“语义相关但实际不相关”的问题。当然它也增加了延迟和计算成本需要在效果和效率之间权衡。4. 搭建可复用的检索服务工程化与性能考量当我们把上述所有组件——文档加载器、切分器、嵌入模型、向量数据库、混合搜索、重排序模块——组合在一起就构成了RAG的“检索端”。为了让这个系统健壮、可维护我们需要用工程化的思维来设计它。4.1 服务架构设计一个典型的检索服务可以分为离线管道和在线服务两部分离线管道知识库构建监听监控指定的文档源目录如NAS共享文件夹、S3桶、Confluence API。解析与切分对新文档或变更文档进行解析、切分。向量化调用嵌入模型API或本地服务生成向量。更新索引将向量和元数据含文本块写入向量数据库和全文检索索引。日志与监控记录处理状态、失败信息更新处理进度。这个管道可以设计成定时运行的批处理任务如Airflow DAG或者由文件事件触发的实时流处理如使用Watchdog库。在线服务查询处理API接收提供RESTful或gRPC接口接收用户查询。查询理解可选的步骤对查询进行预处理、纠错、扩展Query Expansion。混合检索并行调用向量检索和全文检索服务。结果融合与重排序对粗排结果进行分数归一化、加权融合并可选地调用重排序模型。返回结果将排序后的文本块及相关元数据来源、页码、得分返回给上游的“生成”服务。在线服务需要重点关注延迟和并发。向量搜索和重排序模型推理都可能成为瓶颈。4.2 关键性能优化点嵌入模型服务化不要在每次请求时都加载模型。使用TF Serving、Triton Inference Server或简单的FastAPI将嵌入模型封装成HTTP/gRPC服务实现模型加载一次、多请求复用。对于重排序模型也是如此。缓存层对于频繁出现的相同或相似查询可以引入缓存如Redis。将查询语句的嵌入向量或查询语句本身作为key将检索结果作为value缓存起来能极大降低后端负载和响应延迟。异步处理在线服务的各个步骤如向量检索、BM25检索、重排序如果彼此没有依赖应该使用异步并发如Python的asyncio来执行缩短整体响应时间。限制搜索范围充分利用Milvus的标量过滤Scalar Filtering功能。例如你可以只搜索“source‘最新产品手册.pdf’”的文档或者“update_time ‘2024-01-01’”的文档。这能大幅减少搜索空间提升性能。search_params {metric_type: IP, params: {ef: 50}} # 添加过滤条件只搜索来源为“user_manual_v2.pdf”的文档 expr source user_manual_v2.pdf results collection.search( datasearch_vectors, anns_fieldembedding, paramsearch_params, limit5, exprexpr, # 标量过滤表达式 output_fields[text_chunk, source] )索引参数调优如前所述根据数据规模和性能要求调整HNSW或IVF系列的参数。在Milvus中创建索引是一个代价较高的操作但一旦创建好搜索效率的提升是显著的。4.3 效果评估与迭代没有度量就没有改进RAG系统搭建好后你怎么知道它好不好不能只靠感觉。需要建立一套评估体系。离线评估构建一个测试集包含一系列问题Query和对应的标准答案Reference Answer或标准文档Relevant Document IDs。检索阶段评估指标命中率Hit Rate K在前K个检索结果中至少包含一个相关文档的概率。这衡量了检索的召回能力。平均精度均值Mean Average Precision, MAP考虑相关文档在结果列表中的排序位置是更精细的指标。端到端评估指标忠实度Faithfulness生成的答案是否严格基于检索到的文档没有虚构事实。可以用基于LLM的评估器来判断。答案相关性Answer Relevance生成的答案是否直接回答了问题。ROUGE/BLEU比较生成答案和标准答案在词重叠上的相似度但这对语义相似度衡量不够好。在线评估A/B测试在真实流量中将新版本的RAG系统如换了嵌入模型或调整了切分策略与旧版本对比核心指标可以是“人工评分”、“用户满意度反馈点赞/点踩”或下游业务指标如客服问题解决率。只有通过持续的评估和基于数据的迭代你的RAG系统才能越用越聪明真正成为业务中的生产力工具。