今天这期聊RAG基础,起因是我们内部做知识库问答时踩了一堆检索相关的坑,加上不少朋友私信问怎么把大模型接到私有文档上,干脆把这套底层的链路完整梳理一遍。RAG全称Retrieval-Augmented Generation,检索增强生成,翻译成大白话就是:大模型回答之前,先到外部知识库里查参考资料,再基于参考内容组织答案。它解决的是大模型两个让所有人头疼的问题,一是知识停留在训练截止日期、新东西不知道,二是上下文不足时一本正经地胡说八道。这篇文章适合两类读者:一类正准备把大模型接入企业文档、产品手册、内部Wiki这类私有数据;另一类是被幻觉折腾到崩溃、想搭建一套答案可追溯、可解释问答系统的开发者。内容以我实操过程中的真实经历为线索,把原理、链路、代码、调优和避坑一次性拆开讲,尽量让新手看完能动手搭一个最小可用版本,也让有经验的老手在检索细节上有新的参照。
1. 为什么需要RAG:先搞清楚它解决什么问题
1.1 大模型的两个硬伤:知识停更和一本正经地瞎编
先摆一个很多人都遇到过的场景。某天我在做一个项目,需要让模型基于公司内部的设备维护手册回答问题。手册里明确写着某型号设备在温度超过40度时必须降额运行,我把这个问题抛给一个通用模型,它的回答非常流畅,从散热原理讲到环境控制,最后还给了一个完全错误的操作建议——把设备搬到空调出风口下面就行。整段回答逻辑通顺、语气笃定,要不是我自己看过手册,差点就信了。
这就是大模型的两个核心问题。
第一个是知识过期。模型的知识边界等于训练数据的截止时间,它只知道训练时见过的内容。企业内部文档里的最新流程、某个产品的版本变更、刚发布的规范,这些内容模型一概不知。你以为它在帮你读文档,其实它只是根据问题在猜答案。
第二个是幻觉。大模型本质上是概率语言模型,它在逐字生成回复时,优化目标是“下一个词像不像人话”,而不是“这句话对不对”。当上下文里没有足够信息支撑时,模型就只能沿着语言习惯继续编,而语言通顺和事实正确之间没有必然联系。
RAG解决的就是这两个问题。它把“模型自己记住一切”改成“模型只需要会阅读”——知识放在外部数据库里,每次回答前先检索相关内容,把检索结果塞进提示词,模型基于给定的材料进行总结和回答。知识更新只需要更新数据库,不需要重新训练模型;回答内容来源于检索到的真实资料,幻觉概率大幅下降,而且每条回答都能追溯到原始出处。
1.2 RAG与微调:技术路线怎么选
很多人的第一反应是:既然模型不懂新知识,那我去微调一个模型不就行了?微调确实是另一个方向,但和RAG解决的问题并不完全重叠。这里我给一个非常实际的对比表,方便大家按需求选择:
| 对比维度 | RAG(检索增强生成) | 微调(Fine-tuning) |
|---|---|---|
| 知识更新成本 | 更新文档库即可,分钟级生效 | 重新训练,按天甚至按周计算 |
| 所需数据量 | 少量文档即可起步 | 通常需要成百上千条高质量样本 |
| 幻觉控制 | 较好,答案可追溯到具体文档 | 有限,模型依然可能自由发挥 |
| 可解释性 | 强,能展示引用了哪些片段 | 弱,模型内部决策不可见 |
| 技术门槛 | 较低,核心是检索管线的搭建 | 较高,需要训练经验和算力 |
| 适用场景 | 知识问答、私域数据接入、动态更新 | 风格迁移、指令遵循、特定格式输出 |
从这张表可以看出,如果你的核心诉求是“让模型知道某个特定知识库里的内容”,RAG几乎是优先选择。更新知识只需要换文档,不需要重新训练,这意味着企业内部那些每天都在变的SOP、产品参数、政策文件,都能以极低成本保持同步。
微调适合的场景也有,比如你需要模型始终以特定语气回复、始终按照固定模板输出、模仿某个特定风格,这时候微调比RAG更直接。但在知识获取这件事上,靠改权重去记事实,成本高、效果差、更新慢,走这条路的人大多后悔了。补充一句,RAG和微调并不是互斥的,实际生产环境中完全可以先用RAG解决知识来源问题,再针对输出风格做微调,两件事各管各的。
2. RAG完整链路拆解:七分检索,三分生成
2.1 文档解析与清洗:喂给系统的第一口饭决定上限
RAG链条的起点不是向量化,是文档解析。很多入门教程跳过了这个环节,直接拿现成的公开数据集演示,但真实项目里的文档永远不像教程一样干净。我遇到的实际情况是:一套PDF里有扫描件、有图文混排、有三栏排版,甚至还有表格跨页。
PDF解析是第一个大坑。文本型PDF可以直接提取文字,扫描型PDF需要走OCR,排版复杂的PDF用常规库提取经常把正文和页脚混在一起。我的做法是优先用支持布局分析的解析器,先识别页面里的标题、正文、表格区域,再按区域提取内容,而不是从头到尾一行行读。表格数据尤其难处理,直接转成纯文本会丢失行列对应关系,检索时经常出现语义错乱。如果表格是你的核心资产,建议在切分前先把表格转成Markdown格式或结构化的键值对,后面检索和生成的准确性都会好很多。
清洗环节同样不能省。我从公开网页抓取或从旧手册里转出来的文本,经常带着多余的空格、全角半角混用、乱码字符、无关页眉页脚。这些噪声不清理,切分时会把一个完整句子切成碎片,嵌入向量也会被无关字符污染。我习惯在进入切分之前做一层统一清洗:全角转半角、去除零宽字符和特殊控制符、压缩连续空白行、删除重复的页眉页脚。清洗规则不用一次做全,先处理当前文档集里最明显的几种问题,后续遇到新问题再补规则。
这里有个非常重要的经验:文档处理的精细程度,直接决定了检索质量的上限。如果原始文档解析得支离破碎,后面无论怎么调参数都是白费力气。这个环节没有捷径,只能根据自己文档的样子逐步打磨。
2.2 文本切分:chunk大小影响检索精度的底层逻辑
文档清洗完成后,接下来是把长文本切成一个个片段,也就是chunk。为什么必须切?两个原因:第一,嵌入模型有最大输入长度限制,超长内容会被截断,语义信息丢失;第二,向量检索返回的是整段文本,如果把整本手册当成一个向量,查询时你得到的是整本书,模型无法定位到具体段落。
切分方案直接影响检索效果,这个参数没有绝对正确答案,只能根据内容形态来定。我的经验值如下:
| 文档类型 | 建议chunk大小 | 建议重叠长度 | 理由 |
|---|---|---|---|
| 工整的规范文档、论文 | 500-800字 | 50-100字 | 语义块完整,答案信息量足 |
| 客服对话、FAQ | 200-300字 | 0-30字 | 问答对短小精悍,大了反而混淆 |
| 代码文档、接口文档 | 300-500字符 | 30-50字符 | 代码块和说明需就近绑定 |
| 新闻、公众号文章 | 800-1200字 | 100字 | 段落独立性强,可容忍粗粒度 |
看到这些数字,很多人会问,为什么不直接用固定字符数硬切?硬切的问题在于会拦腰截断句子和段落,导致一个片段里前半段讲A、后半段讲B,语义混杂。我自己的操作是:以段落为基本单位,段落太短就把相邻段落合并,段落太长再按句号边界拆分,最后通过一个重叠窗口保留上下文接力。重叠的作用是防止边界处的关键信息被切断,让相邻chunk之间保留交叉内容。
补充一个重要细节:标题信息要保留。很多文档的正文内容是依附于章节标题存在的,如果切分时把标题丢掉了,一个片段里的内容就没有了上下文锚点。比如手册里写“请参考下表进行设置”,如果不知道它属于哪个章节,模型根本不知道要参考什么表。我的做法是在切分时把当前章节的标题作为前缀拼接到每个chunk里,格式类似“【设备维护-温度控制标准】当环境温度超过40度时……”,这样检索时既匹配正文,也匹配章节语义,效果提升非常明显。
2.3 向量化:从文本到语义坐标的转换
切分文本是RAG系统的骨架,向量化则是灵魂,这一环节负责把文本转换为可以在向量空间中比较距离的坐标。嵌入模型(Embedding Model)做的事情,是把一段文本映射为一个固定维度的数值向量,语义相近的文本在向量空间里距离更近。
嵌入模型的选择有几个考虑维度。首先是语言适配性,纯中文场景下,中英双语模型和纯中文模型的表现在细节上有差异,不过近两年主流模型的中文能力都已经跟上来了,不必过度焦虑。其次是向量维度,维度越高,表达细腻语义的能力越强,但存储和计算成本也越高,常见的有768维、1024维、1536维几档,个人项目和中小团队选768或1024就够用。然后是单条支持的长度,有些模型只支持512字符,有些支持到8192,需要根据你切分后的chunk长度来选择。
向量化过程本身没什么黑魔法,就是把所有chunk批量调用嵌入接口,把返回的向量存起来。但有一个关键操作很多人会忽略:查询语句和文档片段要用同一个嵌入模型做向量化。有些朋友图省事,文档用A模型embedding,查询时用B模型embedding,结果两个向量根本不在同一个语义空间里,余弦相似度全是乱数,检索效果惨不忍睹。这一点务必严格遵守,模型一旦选定,全流程保持一致。
批量向量化时的并发控制和失败重试也很重要,几千个片段调用嵌入接口,网络超时几乎是必然事件。我的做法是写一个带指数退避重试的批量处理函数,每批次控制并发数,失败任务自动重试三次,跑完整个库再检查有没有遗漏。这些小细节在生产环境里能帮你省掉大量排查时间。
2.4 向量检索与生成融合:最后的临门一脚
向量检索本身是一件很纯粹的事情。把用户的查询语句向量化,然后在向量数据库里做最近邻搜索,找出与之最相似的top-k个文档片段。向量数据库的选择取决于数据规模:小型项目用轻量级文件型数据库,比如本地faiss或chroma,一个Python脚本就能跑通;数据量上了百万级,再用服务型数据库,支持分布式和更复杂的过滤条件。
关于相似度的计算方式,最常见的两个选择是余弦相似度和内积。在向量经过归一化处理后,这两者的结果是等价的,很多向量数据库内部默认用归一化后的内积来加速计算。实际选型时不需要过度纠结数学定义,只要保证数据库存储和查询时用的相似度算法一致即可。
检索完只是得到了候选片段,下一步是把这些片段和原始问题组装成一个“有依据的提示词”,交给大模型生成答案。我常用的提示词结构大致是这样:
你是一个严谨的问答助手。请严格依据以下资料回答问题,如果资料中不包含答案,请直接回答“资料中未找到相关信息”,严禁编造。 资料内容: 【来源1】…… 【来源2】…… 用户问题:…… 要求:回答中明确标注出处来源编号。
这里有两个生成参数很值得注意。第一个是temperature,它控制随机性,知识问答场景建议调到0.1以下,让模型尽量忠实于资料,不要自由发挥。很多人用默认temperature回答问题,结果模型在资料基础上加了一堆自己的“合理想象”,幻觉就是这么来的。第二个是max tokens,需要根据期望答案长度合理设置,设置得太短答案被截断,设置得太长模型容易在一段结束后继续补充没有依据的内容。
RAG链路到这里就形成了一个闭环:解析、清洗、切分、向量化、检索、组装、生成。七分检索三分生成这句行话听起来极端,但在我实操的项目里,生成环节只要提示词写得及格,最终效果的好坏几乎完全取决于前面检索链路的质量。
3. 从零搭建一个最小可用的RAG系统
3.1 环境准备与方案选型
搭一套最小可用的RAG系统,不需要一开始就上重型框架。很多人被各种RAG框架的全家桶吓住了,其实核心链路完全可以自己拼出来。
我的建议是:第一版用最朴素的方案跑通全流程,这个阶段的主要目标是理解数据流转,而不是炫技。具体依赖库包括:
- 文档解析:pdfplumber(处理文本型PDF)、pypdf(轻量提取)
- 文本清洗与切分:自己写规则函数,不依赖框架,逻辑清晰可控
- 嵌入模型:调用主流大模型服务商的embedding接口,或本地部署开源嵌入模型
- 向量存储:faiss(CPU版即可,安装方便)
- 大模型推理:直接调用主流大模型API,或本地部署开源模型
等你把这条朴素链路跑通了,理解了数据在各个环节的形态变化,再切换到生产级框架或者自己封装服务都来得及。直接一上来套框架,反而会因为框架对你隐藏了太多细节,出问题时根本不知道去哪排查。
3.2 核心实现:从文档到向量的完整落地
下面这段代码是我第一版最小系统的核心结构,去掉了工程化包装,只保留主干逻辑,方便看清每一步到底在做什么。
import os import re import numpy as np import faiss from typing import List, Dict # ---------- 1. 文档加载 ---------- def load_documents(file_path: str) -> str: """加载本地txt/md文本文件,PDF需额外用pdfplumber解析""" with open(file_path, "r", encoding="utf-8") as f: return f.read() # ---------- 2. 清洗 ---------- def clean_text(text: str) -> str: """基础清洗:全角转半角、压缩空白、去掉控制符""" text = text.replace("\u3000", " ").replace("\xa0", " ") # 全角字符转半角 def full2half(s): result = [] for ch in s: code = ord(ch) if code == 0x3000: code = 0x20 elif 0xFF01 <= code <= 0xFF5E: code -= 0xFEE0 result.append(chr(code)) return "".join(result) text = full2half(text) text = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", "", text) text = re.sub(r"\n{3,}", "\n\n", text) return text.strip() # ---------- 3. 按段落感知的结构化切分 ---------- def split_text(text: str, max_chunk: int = 500, overlap: int = 80) -> List[str]: """先按段落聚合,再按句子边界切分到目标长度""" paragraphs = [p.strip() for p in re.split(r"\n+", text) if p.strip()] chunks = [] current = "" for para in paragraphs: if len(current) + len(para) < max_chunk: current = current + "\n" + para if current else para else: # 超长段落内部按句号切分 if not current and len(para) > max_chunk: sentences = re.split(r"(?<=[。;])", para) buffer = "" for sent in sentences: if len(buffer) + len(sent) <= max_chunk: buffer += sent else: if buffer: chunks.append(buffer) buffer = sent continue if current: chunks.append(current) current = para if current: chunks.append(current) # 加重叠窗口 final_chunks = [] for i, c in enumerate(chunks): final_chunks.append(c) if i < len(chunks) - 1 and overlap > 0: final_chunks.append(c[-overlap:] + "\n" + chunks[i + 1][:overlap]) return final_chunks # ---------- 4. 向量化与存储 ---------- class EmbeddingClient: """嵌入接口封装,生产环境替换为你的嵌入服务""" def __init__(self, dim: int = 768): self.dim = dim def embed(self, texts: List[str]) -> np.ndarray: # 这里应替换为真实的嵌入模型调用 # 为了演示,生成随机向量,实际使用时绝不能用这种方式 return np.random.rand(len(texts), self.dim).astype("float32") def build_index(chunks: List[str], embed_client) -> faiss.IndexFlatIP: vectors = embed_client.embed(chunks) index = faiss.IndexFlatIP(vectors.shape[1]) faiss.normalize_L2(vectors) index.add(vectors) return index这段代码里有两个地方务必注意:一是嵌入部分我用了随机数占位,真实项目中必须替换成实际的嵌入模型,随机向量不具备任何语义;二是这里用了内积索引配合L2归一化,等价于余弦相似度,是faiss里最常用的检索配置。
3.3 检索与生成:把答案“有据可依”地拼出来
索引构建完成后,就进入查询环节。这个环节的代码逻辑很直观:查询文本向量化,在索引里搜索top-k,拼提示词,调生成接口。
def retrieve(query: str, index: faiss.IndexFlatIP, chunks: List[str], embed_client, top_k: int = 5) -> List[Dict]: query_vec = embed_client.embed([query]) faiss.normalize_L2(query_vec) distances, indices = index.search(query_vec, top_k) results = [] for score, idx in zip(distances[0], indices[0]): if idx < 0: continue results.append({ "text": chunks[idx], "score": float(score) }) return results def generate_answer(question: str, retrieved_docs: List[Dict], llm_fn) -> str: context = "\n\n".join( f"【来源{i + 1}】{doc['text']}" for i, doc in enumerate(retrieved_docs) ) prompt = f"""你是一个严谨的问答助手,请严格依据以下资料回答问题。 如果资料中不含答案,请直接回答“资料中未找到相关信息”,严禁编造。 资料内容: {context} 用户问题:{question} 要求:请用简洁的语言回答,并在句末标注对应来源编号。 """ return llm_fn(prompt) def rag_pipeline(question: str, index, chunks, embed_client, llm_fn, top_k: int = 5) -> str: docs = retrieve(question, index, chunks, embed_client, top_k) return generate_answer(question, docs, llm_fn)整套流程就这么长,没有更复杂的魔法。你需要做的只是把llm_fn替换成真实的大模型调用接口,把embed替换成真实的嵌入接口,一份内部知识库问答系统的最小核心就跑起来了。
3.4 三个非常值得调的基础参数
跑通之后,优先尝试调整三个参数,它们对效果的影响权重最高。
第一个是top_k。这个参数决定每次检索取回几个片段。取回太少,答案可能信息不足;取回太多,无关片段挤占上下文,模型会被噪声干扰。我的经验是从5开始,观察检索结果的相关性,再做增减。
第二个是相似度阈值。向量检索永远会返回“最相似”的top-k,哪怕库里根本没有相关文档,它也会把距离最近的垃圾片段返回给你。所以一定要设置一个最低相似度阈值,低于阈值的片段直接丢弃。这个阈值受嵌入模型影响很大,需要先跑一批真实查询,观察相关片段和不相关片段的分数分布,再做决定。
第三个是生成时的temperature。前面提过,知识问答场景我直接调到0.1以下,甚至0。0.1和0之间我习惯取0.1,保留一点点表达多样性,防止模型对同一问题每次答案完全相同,但绝不鼓励自由发挥。
4. 从“能跑”到“好用”:检索质量优化的四个着力点
4.1 混合检索:向量语义和关键词命中互补
纯向量检索在捕捉语义相似度上很优秀,但有一个明显的盲区:精确匹配。比如用户查询一个特定的产品型号“A-300型”,向量检索可能检索到“A300型号”相关的描述,但对精确编号的匹配能力有限。反过来,传统的关键词检索不能理解同义表达,“如何降低设备噪音”和“噪声抑制手段”之间没有关键词重合。
混合检索的思路很简单:同时跑向量检索和关键词检索,然后把两路结果合并后再排序。实现方式也不复杂,先用向量检索取top-k,再用BM25或倒排索引取top-k,两路结果做去重和分数归一化合并。我试过多套混合策略,最朴素有效的一种:向量检索权重占0.6,关键词检索占0.4,合并分数后取前5作为最终上下文。这个比例不是绝对的,如果文档里专有名词多,可以调高关键词权重;如果文档口语化表达多,向量权重可以再加大。
4.2 重排序:把真正需要的片段顶到最前面
向量检索的粗排序基于向量相似度,它擅长筛选候选集,但不擅长精排。实际场景里,一个问题往往能召回十几个相关片段,但真正能支撑回答的可能只有两三个。如何把这两个片段顶到最前面?答案是重排序(Rerank)。
重排序模型是一种专门做“给定查询和文档对,输出相关性分数”的模型,它把查询和文档拼在一起,做深度交叉编码。相比向量检索的双塔结构,重排序模型能看到查询和片段之间的词级交互,对细微语义关系的判断更准确。代价是计算量大,不能所有片段都过一遍,所以要先用向量检索粗筛出20个候选,再交给重排序模型精排取前5。这个“粗筛+精排”的两段式结构,是目前RAG系统提升效果最立竿见影的手段,没有之一。
4.3 查询改写:用户问得模糊,先帮他拆明白
用户提问是千奇百怪的,直接拿原始问题去检索常常效果不佳。以“设备坏了怎么办”为例,这个查询太模糊,任何文档片段跟它相似度都不高。查询改写专门解决这类问题。
有两种最实用的改写策略。第一种是假设式回答:先让大模型基于原始问题生成一个初步的、可能不准确的答案,再拿这份答案去检索相关资料,再回到生成阶段给出最终回复,相当于“先想后查”。第二种是子问题分解:把复杂问题拆成多个简单子问题,分别检索后再汇总。比如“设备启动失败且伴有报警声是什么原因”,可以拆成“设备启动失败的原因”和“设备报警声的含义”两个子查询,各自检索,再合并结果。
我实际用过的改写方案是:预处理阶段用一个大模型的轻量调用,把用户问题改写成更利于检索的句式,去掉口语杂质,补全缺失信息。这一层会增加一次模型调用,如果你非常追求响应速度,可以改成规则改写,比如提取关键词、补充同义词,成本更低,效果也不错。
4.4 评估闭环:不跑数据调优就是闭眼开车
没有评估指标,优化方向就完全靠感觉,这是RAG项目最容易陷入的泥潭。每次调整参数,都要有好坏判断的标准。我建议最少盯住以下三个指标:
回答忠实地基于检索资料,验证方法是人工抽检答案里的信息点是否都能在检索片段里找到原文对应。相关检索片段确实出现在最终上下文里,验证方法是对检索片段做人工相关性标注。答卷是否回答了用户问题且信息完整,验证方法同样以人工抽检为主。
我自己的做法是准备一份50到100条的真实问答测试集,每次修改任何参数,都跑一遍测试集,对比答案质量变化和检索命中率变化。如果没有这份测试集,每一次优化都只是主观感觉,无法判断是变好了还是变差了。
5. 常见问题与排查技巧实录
5.1 高频问题速查
下面列出的问题类型,都是我和周围团队在RAG项目里实际遇到过的,每一条都能对号入座去找原因。
| 问题现象 | 常见原因 | 解决方案 |
|---|---|---|
| 检索结果与问题无关 | 切分粒度不合适、嵌入模型未统一 | 调整chunk大小、检查查询和文档是否用同一嵌入模型 |
| 答案仍然产生幻觉 | 检索片段未被有效利用、temperature偏高 | 调低temperature、强化提示词中“禁止编造”的约束、检查top_k过小 |
| 检索为空或极少结果 | 相似度阈值过高、文档中无相关内容 | 调低阈值、补充文档、检查embedding归一化 |
| 答案冗长且丢失重点 | top_k过大、无关片段干扰 | 缩小top_k、引入重排序精排 |
| 同一问题每次答案不同 | temperature设置过高 | 降到0.1以下,甚至直接用0 |
| 查询性能慢 | 向量化和生成串行调用 | 预计算文档向量、增加缓存、引入批量接口 |
| PDF文档解析后乱码 | 扫描件未走OCR、排版复杂 | 用OCR工具识别、按布局区域提取 |
5.2 案例复盘:检索结果相关但答案错得离谱
这个案例比较有意思,值得单独讲。某次问答系统上线后,用户反馈模型对某个技术问题的回答来源标注没问题,但结论是错的。我排查了很久,最终发现原因出在top_k选择上。
检索链路取回的前4个片段里,前两个准确命中了问题所在章节,但后两个来自另一个相似章节,包含过时版本的旧参数。由于提示词里没有强调“以最新版本为准”,模型在拼接多个片段信息时,选择了大部分资料里的旧参数,生成了一个有依据但过时的错误答案。
解决办法有两个方向:一是在切分或清洗阶段把版本号、发布日期作为元数据保留,在检索时优先筛选最新版本;二是在提示词中明确指定“如果资料存在版本冲突,以来源编号最小且标记为最新版本的资料为准”。这个坑让我养成了一个习惯:RAG系统的每个文档片段必须保留元数据标签,即便第一版用不上,后面做版本过滤、来源筛选时也会有大用处。
5.3 关于成本和性能的一点心得
RAG项目的性能瓶颈通常在两个环节:向量化一批大文档时耗时较长,生成阶段受大模型接口响应速度制约。我的方案是文档入库离线批量处理,充分利用夜间低峰期;在线问答阶段只做向量检索和模型调用。另外,给常见查询做缓存收益很高,用户问相同问题的概率远比你想象的大,缓存命中能省下大量模型调用成本。
还有一个细节值得提:向量库的索引类型。数据量小的时候Flat索引(暴力搜索)足够快,数据量大了务必换成IVF或HNSW这类近似最近邻索引。我在一个项目里直观感受过差距,5万条向量用Flat索引每次查询耗时几十毫秒,换成HNSW之后毫秒级返回,质量损失几乎可以忽略,这笔优化非常划算。
写在最后的一点体会
玩RAG这段时间,我的最大感受是:这套系统的效果上限不完全取决于大模型的生成能力,而取决于前面检索链路喂进去的材料质量。模型本身很强,但如果检索回来的内容是错的、碎的、过时的,再强的模型也只会把错误包装得更流畅。从最小闭环跑通开始,再到检索质量优化,RAG是一个很典型的效果逐步累积的项目。如果你也在搭建类似系统,先别急着上复杂框架,把文档清洗、文本切分、检索评估这几件基础事做扎实,效果自然会超过大多数直接套框架的团队。最后分享一个建议:从一开始就给文档保留元数据、从一开始就准备测试集,这两个习惯会帮你避开很多后期完全无法追溯的坑。