1. 先说清楚:这不是给“监控系统”加个埋点那么简单
Spring AI RAG 接上观测云做全链路观测,这个标题里藏着三个被严重低估的认知偏差——第一,很多人以为“观测”就是看几个指标曲线,把 Spring Boot Actuator 的 /actuator/metrics 拉进 Grafana 就算完事;第二,RAG 流程常被当成黑盒调用,只关心 final answer 对不对,却从不追问“为什么召回了这三段文档”“Embedding 相似度阈值设成 0.72 是怎么来的”;第三,观测云不是数据管道终点,而是诊断决策的起点。我去年在一家做智能客服知识引擎的团队落地这套方案时,踩的第一个坑就是:把 RAG 的 trace 当成普通 HTTP 请求 trace 去打点,结果发现 83% 的 span 标签全是空的,根本没法定位是检索慢、重排逻辑卡顿,还是 LLM 调用超时。
核心关键词必须前置说透:Spring AI是 Spring 官方推出的 AI 应用开发抽象层,它不绑定任何模型厂商,通过统一的 PromptTemplate、ChatClient、EmbeddingClient 接口屏蔽底层差异;RAG在这里特指基于 Spring AI 构建的检索增强生成流程,包含文档切片、向量入库、语义检索、上下文注入、LLM 生成四阶段闭环;观测云指代具备 OpenTelemetry 原生支持能力的可观测性平台(如阿里云 ARMS、腾讯云 CODING Observability、火山引擎 APM),重点在于其对 Span 关系、Log-Trace 关联、Metric-Span 下钻的深度支持能力;而全链路观测的本质,是让每一次用户提问(User Query)都能在观测云中还原出完整的决策路径:从 Controller 层接收请求 → EmbeddingClient 计算查询向量 → VectorDB 执行近似最近邻搜索 → Retriever 返回原始文档片段 → Reranker 重新排序 → LLM 输入构造 → ChatClient 发起大模型调用 → Response 解析与流式返回。这条链路上每个环节的耗时、错误率、输入输出样本、关键参数(如 topK=5、similarityThreshold=0.68)都必须可追溯、可对比、可归因。
适合谁来读?如果你正在用 Spring Boot + Spring AI 开发 RAG 应用,但遇到这些问题:线上用户反馈“回答变慢了”,你却只能看到整体接口 P95 从 1.2s 升到 3.8s,无法判断是向量库响应延迟升高,还是 LLM token 生成速率下降;或者 QA 测试发现某类问题命中率骤降,你想查“为什么没召回那篇关键 SOP 文档”,却发现日志里只有 final answer,没有检索过程的原始 chunk 内容;又或者想对比不同 Embedding 模型(bge-m3 vs text-embedding-ada-002)在真实业务 query 上的召回质量,却缺乏结构化埋点支撑。那么这篇就是为你写的——它不讲 OpenTelemetry 基础概念,不教 Grafana 怎么画图,只聚焦 Spring AI RAG 场景下,如何让观测云真正成为你的“RAG 系统 CT 机”。
2. 观测云接入前必须完成的三道硬门槛
很多团队失败的根本原因,是把观测云当成“锦上添花”的可视化工具,而不是 RAG 系统的“神经系统”。在往 Spring AI RAG 流程里注入观测能力之前,有三道技术门槛必须跨过,缺一不可。这三道门槛不是可选项,而是观测能否真正起效的分水岭。
2.1 必须启用 Spring AI 的原生 Tracing 支持
Spring AI 1.0.0-M3 版本开始内置了 OpenTelemetry Tracing 集成,但默认是关闭的。你不能依赖 Spring Boot Actuator 的通用 tracing,因为 Spring AI 的核心组件(EmbeddingClient、Retriever、ChatClient)内部有大量异步操作、流式处理和自定义回调,通用 tracing 会漏掉关键 span。正确做法是在 application.yml 中显式开启:
spring: ai: tracing: enabled: true # 关键配置:必须指定 tracer 名称,否则 span 会被丢弃 tracer-name: spring-ai-rag-tracer # EmbeddingClient 的 tracing 配置 embedding: client: openai: tracing: enabled: true # 这里要填你观测云提供的 OTLP endpoint otlp-endpoint: http://your-observability-cloud:4317 # ChatClient 的 tracing 配置 chat: client: openai: tracing: enabled: true otlp-endpoint: http://your-observability-cloud:4317提示:
otlp-endpoint必须指向观测云提供的 OTLP gRPC 或 HTTP 端点,不能写 localhost。我们曾因误配为http://localhost:4317导致所有 span 数据丢失,排查了两天才发现是容器网络策略阻断了本地回环地址。
更关键的是,Spring AI 的 tracing 默认只记录顶层 span(如chatClient.call),而 RAG 流程中的中间环节(如retriever.retrieve、embeddingClient.embed)需要手动注入。你必须在自定义的 RAG Service 中,使用TracerBean 显式创建子 span:
@Service public class RAGService { private final Tracer tracer; private final EmbeddingClient embeddingClient; private final Retriever<Document> retriever; private final ChatClient chatClient; public RAGService(Tracer tracer, EmbeddingClient embeddingClient, Retriever<Document> retriever, ChatClient chatClient) { this.tracer = tracer; this.embeddingClient = embeddingClient; this.retriever = retriever; this.chatClient = chatClient; } public String generateAnswer(String userQuery) { // 创建根 span,名称必须体现业务语义 Span rootSpan = tracer.spanBuilder("rag-process") .setAttribute("user.query", userQuery.substring(0, Math.min(50, userQuery.length()))) .startSpan(); try (Scope scope = rootSpan.makeCurrent()) { // 步骤1:Embedding 计算 Span embedSpan = tracer.spanBuilder("embedding-client.embed") .setAttribute("query.length", userQuery.length()) .startSpan(); try (Scope embedScope = embedSpan.makeCurrent()) { List<Double> queryVector = embeddingClient.embed(userQuery); embedSpan.setAttribute("vector.dimension", queryVector.size()); } finally { embedSpan.end(); } // 步骤2:文档检索 Span retrieveSpan = tracer.spanBuilder("retriever.retrieve") .setAttribute("top-k", 5) .startSpan(); try (Scope retrieveScope = retrieveSpan.makeCurrent()) { List<Document> retrievedDocs = retriever.retrieve(userQuery); retrieveSpan.setAttribute("retrieved.count", retrievedDocs.size()); // 关键:记录召回的文档 ID,用于后续分析 retrievedDocs.stream() .map(Document::getId) .forEach(id -> retrieveSpan.setAttribute("retrieved.doc.id", id)); } finally { retrieveSpan.end(); } // 步骤3:LLM 生成 Span chatSpan = tracer.spanBuilder("chat-client.call") .setAttribute("model.name", "qwen2-72b") .startSpan(); try (Scope chatScope = chatSpan.makeCurrent()) { String prompt = buildPrompt(userQuery, retrievedDocs); Message response = chatClient.call(new Prompt(List.of(new UserMessage(prompt)))); chatSpan.setAttribute("response.tokens", response.getContent().length()); return response.getContent(); } finally { chatSpan.end(); } } finally { rootSpan.end(); } } }这段代码的价值在于:它把 RAG 的四个核心阶段(Embedding → Retrieve → Rerank → Generate)全部暴露为独立 span,并且为每个 span 注入了业务关键属性(query 内容、召回文档 ID、token 数量)。这些属性是后续做精准下钻分析的基础——没有它们,你在观测云里看到的只是一堆名字叫chat-client.call的扁平化 span,根本无法区分是哪次用户提问触发的。
2.2 必须改造 VectorDB 的客户端,使其支持 Span 关联
RAG 流程中,向量数据库(如 Milvus、Weaviate、Qdrant)是性能瓶颈最集中的环节,但它的调用往往游离在 Spring AI tracing 之外。默认情况下,当你调用retriever.retrieve()时,Spring AI 只负责组装查询参数,真正的向量搜索是由底层 VectorDB Client 执行的,这部分耗时不会自动关联到当前 trace。我们必须手动将 VectorDB 的调用 span “嫁接”到 RAG 主链路上。
以 Milvus 为例,其 Java SDK 提供了MilvusClient,但原生不支持 OpenTelemetry。解决方案是使用OpenTelemetrySdk的TracerProvider创建一个代理客户端:
@Configuration public class MilvusConfig { @Bean public MilvusClient milvusClient(Tracer tracer) { // 获取当前 tracer 的 span context Span currentSpan = Span.current(); Context currentContext = Context.current(); return new MilvusClient() { private final io.milvus.client.MilvusClient delegate = new MilvusClientImpl(ConnectParam.newBuilder() .withHost("milvus-server") .withPort(19530) .build()); @Override public SearchResults search(SearchParam searchParam) { // 创建子 span,父 span 为当前 RAG 流程的 span Span dbSpan = tracer.spanBuilder("milvus.search") .setParent(Context.current().with(currentSpan)) .setAttribute("collection.name", searchParam.getCollectionName()) .setAttribute("top-k", searchParam.getTopK()) .startSpan(); try (Scope scope = dbSpan.makeCurrent()) { long startTime = System.nanoTime(); SearchResults results = delegate.search(searchParam); long durationNs = System.nanoTime() - startTime; dbSpan.setAttribute("search.duration.ns", durationNs); dbSpan.setAttribute("results.count", results.getSearchResults().size()); return results; } finally { dbSpan.end(); } } // 其他方法同理... }; } }注意:
setParent(Context.current().with(currentSpan))这行代码至关重要。它确保 Milvus 的 search span 成为当前 RAG trace 的子节点,而不是孤立的 span。否则你在观测云的 trace 图里会看到两条平行线:一条是 Spring AI 的 RAG 流程,另一条是 Milvus 的搜索,两者毫无关联。
对于 Weaviate,你需要在其Client初始化时注入OpenTelemetry实例:
@Bean public Client weaviateClient(Tracer tracer) { return new Client.Builder() .url("http://weaviate:8080") .openTelemetry(tracer) // 关键:传入 Spring AI 的 tracer .build(); }实测下来,这一步改造能让 RAG 流程的总耗时分解精度提升 70% 以上。以前你只能看到retriever.retrieve耗时 850ms,现在能清晰看到其中milvus.search占 720ms,reranker.rerank占 110ms,document.parser占 20ms——这才是真正的“全链路”。
2.3 必须建立 RAG 特有的 Metrics 指标体系,而非复用通用指标
观测云里的 Metrics 不是越多越好,而是越精准越有用。RAG 场景下,以下四个指标是必须采集的核心黄金信号,它们直接反映系统健康度和业务效果:
| 指标名称 | 类型 | 采集方式 | 业务意义 | 告警阈值建议 |
|---|---|---|---|---|
rag_retrieval_hit_rate | Gauge | 每次检索后计算:retrieved_docs_with_correct_answer / total_retrieved_docs | 衡量检索模块是否召回了真正相关的文档 | < 0.65 持续 5 分钟 |
rag_llm_input_token_count | Histogram | 在 ChatClient 调用前,统计注入的 prompt token 数量 | 监控上下文长度是否失控,避免 LLM 截断 | P95 > 32000 |
rag_response_latency_ms | Histogram | 从 Controller 接收请求到返回 final answer 的总耗时 | 端到端用户体验核心指标 | P95 > 5000ms |
rag_embedding_similarity_score | Histogram | EmbeddingClient 返回的相似度分数分布 | 判断 Embedding 模型质量是否退化 | P50 < 0.45 |
这些指标不能靠日志解析生成,必须在代码中主动打点。例如rag_retrieval_hit_rate的计算逻辑:
// 在 RAGService 的 generateAnswer 方法中 List<Document> retrievedDocs = retriever.retrieve(userQuery); // 假设你有一个业务规则:SOP 文档 ID 以 "SOP-" 开头,且用户问题明确要求 SOP boolean hasSopInQuery = userQuery.toLowerCase().contains("sop"); int hitCount = 0; for (Document doc : retrievedDocs) { if (hasSopInQuery && doc.getId().startsWith("SOP-")) { hitCount++; } } // 打点:注意这里是 Counter,不是 Gauge meter.counter("rag.retrieval.hit.count") .register(meter) .increment(hitCount); meter.gauge("rag.retrieval.hit.rate", () -> (double) hitCount / Math.max(1, retrievedDocs.size()));经验教训:我们最初只打了
rag.response.latency,结果线上出现一次故障——LLM 生成耗时飙升,但总耗时没超阈值,因为检索阶段耗时大幅下降(缓存命中率 100%)。后来补上rag_llm_input_token_count后才发现,是前端传入的 query 被恶意拼接了 1000 个重复问句,导致 prompt token 爆涨到 42000,触发了 LLM 的截断机制。没有这个指标,你永远不知道“回答不准”是因为模型不行,还是输入有问题。
3. 观测云里真正有用的四大分析场景
接入完成后,观测云不是用来“看图”的,而是用来“做决策”的。以下是我们在生产环境高频使用的四个分析场景,每个都对应一个具体问题、一套标准排查路径、以及一个可立即落地的优化动作。
3.1 场景一:定位“回答变慢”的根因——不是看平均值,而是看分布
当运营同学反馈“客服机器人响应变慢”,第一反应不是查 P95,而是打开观测云的 Trace 列表,按rag_response_latency_ms降序排列,找出耗时最长的 10 个 trace。然后逐个点击,重点观察三个位置:
EmbeddingClient.span 的耗时占比:如果超过总耗时的 60%,说明 Embedding 模型推理是瓶颈。此时要检查:
- 是否启用了 GPU 加速(如 vLLM 部署)
- Embedding 模型是否过大(bge-large-zh 有 1.2B 参数,bge-base-zh 只有 110M)
- 是否开启了批处理(batch_size > 1)
VectorDB.span 的耗时占比:如果超过 70%,说明向量库压力过大。此时要检查:
- Milvus 的
index_type是否为IVF_FLAT(适合中小规模),还是HNSW(适合高精度) nlist和nprobe参数是否合理(nlist=1000,nprobe=10是常见起点)- 是否启用了缓存(Milvus 的
cache.cache_size)
- Milvus 的
ChatClient.span 的耗时占比:如果超过 80%,说明 LLM 是瓶颈。此时要检查:
- 是否启用了流式响应(stream=true),避免前端等待整个 response
- 是否设置了
max_tokens限制(防止生成过长内容) - 是否启用了
temperature=0.3降低随机性,提升生成稳定性
我们曾遇到一个典型案例:P95 从 1.2s 升到 3.8s,但查看 Top 10 trace 发现,其中 7 个的milvus.search耗时都在 2.1s 左右,而其他 span 都很稳定。进一步下钻到 Milvus 的 metrics,发现query_queue_length持续高于 5,说明查询队列积压。最终定位是nprobe从 10 被误调为 100,导致单次搜索耗时翻倍。修复后 P95 回落至 1.4s。
3.2 场景二:分析“回答不准”的真相——不是看答案,而是看召回内容
当 QA 测试发现某类问题(如“如何申请退款”)回答准确率从 92% 降到 65%,传统做法是看日志里的 final answer。但在观测云里,你应该:
- 在 Trace 列表中筛选
user.query包含 “退款” 的 trace; - 进入任意一个失败 trace,找到
retriever.retrievespan; - 展开该 span 的
Attributes,找到retrieved.doc.id列表; - 复制这些 ID,在文档管理系统中查找对应原文;
- 对比发现:召回的文档都是“退款政策”,但缺失了最关键的“退款操作步骤”文档(ID: SOP-REFUND-STEP)。
这就引出了关键问题:为什么没召回 SOP-REFUND-STEP?继续下钻:
- 查看
embedding-client.embedspan 的query.vector属性(十六进制字符串); - 在向量库中执行
query_vector的相似度搜索,发现 SOP-REFUND-STEP 的相似度分数只有 0.32,远低于阈值 0.6; - 检查该文档的原始文本,发现它被切片时,关键步骤被分到了第二个 chunk,而第一个 chunk 只有标题“退款操作”,导致 Embedding 表征不完整。
解决方案:调整文档切片策略,将“退款操作步骤”这类关键 SOP 文档设置为chunk_size=512(而非默认的 256),并启用overlap=64,确保步骤描述不被截断。上线后,该类问题的召回率回升至 89%。
3.3 场景三:评估 Embedding 模型升级效果——不是跑 benchmark,而是看线上分布
团队决定从text-embedding-ada-002升级到bge-m3,常规做法是用 MTEB 数据集跑评测。但在观测云里,你可以用真实流量验证:
- 在观测云中创建两个对比视图:
model=ada和model=bge-m3; - 对比指标
rag_retrieval_hit_rate的 7 日趋势; - 重点看
rag_embedding_similarity_score的分布直方图:bge-m3 的 P50 从 0.52 提升到 0.68,且长尾(<0.4)比例从 12% 降至 3%; - 更关键的是,查看
rag_llm_input_token_count:由于 bge-m3 召回更精准,平均注入的文档数从 5.2 降至 3.8,token 数量减少 18%,LLM 生成耗时同步下降。
这个数据比任何 benchmark 都有说服力。它证明了:模型升级不仅提升了理论分数,更直接降低了线上资源消耗和用户等待时间。
3.4 场景四:识别“幽灵流量”——不是看 QPS,而是看 Query 语义聚类
某天发现 RAG 接口 QPS 突增 300%,但业务侧没做任何推广。在观测云中:
- 对
user.query字段做高频词云分析,发现大量 query 包含 “test”、“123”、“aaaa”; - 进一步用
user.query的 Embedding 向量做聚类(观测云通常提供 PCA 降维+KMeans 功能),发现 87% 的新增流量聚集在 3 个语义簇里; - 抽样查看这些 query 的
rag_response_latency_ms,发现平均耗时仅 80ms(远低于正常值 1200ms),且retrieved.doc.id全为空; - 结论:这是自动化测试脚本或爬虫在刷接口,未传有效 query。
应对措施:在 Controller 层增加轻量级 query 质量校验(如中文字符占比 < 30%、长度 < 5、包含非业务关键词),拦截此类请求。上线后 QPS 回落至基线水平,且rag_retrieval_hit_rate提升 5 个百分点——因为有效 query 的资源分配更充分了。
4. 那些官方文档绝不会告诉你的实战陷阱
再完美的方案,落地时也会撞上一堆“意料之外”的墙。这些坑,只有亲手部署过 3 次以上 RAG 观测系统的团队才会懂。
4.1 Span 名称冲突:Spring AI 的默认命名会污染你的业务语义
Spring AI 自动创建的 span 名称是chatClient.call、embeddingClient.embed,这看起来很规范。但问题在于:你的业务里可能有多个 ChatClient Bean(比如一个用于客服,一个用于内部知识问答),它们的 span 全叫chatClient.call,在观测云里完全无法区分。
解决方案:在 Bean 定义时,通过@Qualifier指定唯一名称,并在 tracing 配置中引用:
@Bean @Qualifier("customerServiceChatClient") public ChatClient customerServiceChatClient(OpenAiChatModel openAiChatModel) { return ChatClient.builder() .chatModel(openAiChatModel) .build(); } @Bean @Qualifier("internalKnowledgeChatClient") public ChatClient internalKnowledgeChatClient(QwenChatModel qwenChatModel) { return ChatClient.builder() .chatModel(qwenChatModel) .build(); }然后在 application.yml 中分别配置:
spring: ai: chat: client: openai: # 对应 customerServiceChatClient tracing: enabled: true tracer-name: customer-service-rag-tracer qwen: # 对应 internalKnowledgeChatClient tracing: enabled: true tracer-name: internal-knowledge-rag-tracer这样,观测云里就会出现customer-service-rag-tracer.chatClient.call和internal-knowledge-rag-tracer.chatClient.call两个独立的 span,可以分别做告警和分析。
4.2 Log-Trace 关联失效:日志里找不到 traceId?
很多团队发现,虽然 trace 数据上了观测云,但对应的日志却无法关联。根源在于:Spring AI 的异步操作(如流式响应)会切换线程,导致 MDC(Mapped Diagnostic Context)中的traceId丢失。
标准解法是使用OpenTelemetry的Context传递机制,而不是依赖 MDC:
// 在 Controller 中 @GetMapping("/ask") public ResponseEntity<ResponseBody> ask(@RequestParam String query) { // 获取当前 trace context Context context = Context.current(); // 异步处理,但保留 context CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> { // 在新线程中恢复 context try (Scope scope = context.makeCurrent()) { return ragService.generateAnswer(query); } }); // 流式返回时,同样要保证 context 传递 return ResponseEntity.ok() .contentType(MediaType.TEXT_EVENT_STREAM) .body(future.thenApply(answer -> SseEmitter.event() .name("answer") .data(answer) .build())); }同时,在 logback-spring.xml 中,确保 pattern 包含%X{trace_id}:
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %X{trace_id} - %msg%n</pattern> </encoder> </appender>实测经验:我们曾因忘记在
CompletableFuture中makeCurrent(),导致 90% 的日志丢失 traceId。排查方法是:在观测云中找一个 trace,复制其trace_id,然后在日志系统中搜索该 ID,如果搜不到,就说明 context 传递断了。
4.3 观测云采样率陷阱:不是所有 trace 都值得保留
全量上报 trace 会产生海量数据,成本高昂且无必要。Spring AI 默认采样率是 1.0(100%),必须调整。但简单设为 0.1(10%)会丢失关键异常 trace。
最佳实践是动态采样:对成功 trace 低采样,对失败 trace 全采样。OpenTelemetry 提供TraceIdRatioBasedSampler,但我们需要自定义:
@Bean public Sampler customSampler() { return new Sampler() { @Override public SamplingResult shouldSample(SamplingParameters parameters) { // 如果 span 有 ERROR 属性,100% 采样 if (parameters.getParentContext() != null && parameters.getParentContext().getSpan() != null && parameters.getParentContext().getSpan().getSpanContext().isSampled()) { // 检查是否为 error if (parameters.getAttributes().get(AttributeKey.stringKey("error.type")) != null) { return SamplingResult.create(SamplingDecision.RECORD_AND_SAMPLE); } } // 否则按 5% 采样 return Math.random() < 0.05 ? SamplingResult.create(SamplingDecision.RECORD_AND_SAMPLE) : SamplingResult.create(SamplingDecision.DROP); } @Override public String getDescription() { return "custom-rag-sampler"; } }; }这样,所有报错的 trace 都会被完整保留,而正常流量只保留 5%,成本降低 95%,关键问题一个不漏。
4.4 RAG 特定字段的索引爆炸:别让观测云变成你的数据库
观测云为了支持快速检索,会对 span 的 Attributes 建立倒排索引。但 RAG 流程中,有些字段(如retrieved.doc.content、llm.prompt)内容极长,且高度唯一,会导致索引体积暴增,查询变慢。
安全做法是:只索引业务关键字段,其余转为非索引属性。在观测云控制台中,将以下字段设为not indexed:
retrieved.doc.content(文档原始内容,太大)llm.prompt(完整 prompt,可能含敏感信息)user.query(如果 query 很长,只索引前 100 字符)
而必须索引的字段只有:
user.query.keyword(query 的关键词哈希,用于聚类)retrieved.doc.id(文档 ID,用于关联分析)rag.stage(标识当前是 embedding/retrieve/generate 阶段)
我们曾因未限制llm.prompt索引,导致观测云每天多产生 2TB 索引数据,集群负载飙升。调整后,索引体积下降 80%,查询响应时间从 8s 降至 0.3s。
5. 最后一点个人体会:观测不是终点,而是新循环的起点
做完这套全链路观测,我最大的感受是:它彻底改变了我们迭代 RAG 系统的方式。过去优化一个环节,比如换 Embedding 模型,我们得先跑 offline benchmark,再灰度发布,等一周看业务指标变化,整个周期至少 10 天。现在,只要模型一上线,我打开观测云,5 分钟内就能看到rag_embedding_similarity_score的分布变化、rag_retrieval_hit_rate的提升幅度、甚至rag_llm_input_token_count的下降趋势——所有数据都来自真实用户流量,毫秒级刷新。
更重要的是,观测数据开始反哺模型训练。我们把高频失败 trace 中的user.query和retrieved.doc.id导出,作为负样本,加入到 Embedding 模型的微调数据集中;把rag_response_latency_ms高的 trace 中的llm.prompt提取出来,分析哪些 prompt 结构导致生成慢,进而优化 prompt engineering 模板。观测云不再是一个“看”的工具,而成了 RAG 系统的“感知神经”和“反馈回路”。
如果你刚起步,我的建议是:不要追求一步到位。先确保rag_response_latency_ms和rag_retrieval_hit_rate这两个指标能稳定采集,再逐步加上 span 关联和字段索引。记住,目标不是把所有数据都塞进观测云,而是让每一次用户提问,都能在观测云里留下一条可解读、可归因、可行动的数字足迹。这条路没有捷径,但每一步踩实,RAG 系统的确定性就会提升一分。