我把 RAG 从基础实现一路优化到更稳定的检索效果。文章会讲清楚每个优化动作为什么做、在什么场景有效、以及在真实项目里要注意哪些取舍。
一、先补齐背景:什么是 RAG?为什么很多业务都绕不开它?
如果你之前没系统了解过 RAG,可以先用 3 分钟把概念对齐。
(1)RAG 是什么
RAG(Retrieval-Augmented Generation,检索增强生成)可以理解为:
- 先查资料(检索):从你的知识库/文档库里找出与问题最相关的片段
- 再让模型写答案(生成):把这些片段作为上下文交给大模型,让它在“证据”基础上作答
它的核心价值是:让模型回答时更像“开卷考试”,而不是只靠参数记忆。
(2)RAG 解决什么问题(以及不解决什么)
- 适合
- 知识更新频繁:政策、产品说明、FAQ、SOP、运维手册
- 需要引用依据:希望答案可追溯到文档
- 长尾问题多:无法靠固定意图/模板覆盖
- 不太适合(或要谨慎)
- 问题需要强推理但资料缺失:再好的检索也找不到不存在的证据
- 权限隔离/合规很严格但 metadata 做得差:容易误召回
- 数据噪声大且没有评估闭环:会出现“看似有理但不可信”的回答
(3)RAG vs 微调 vs 传统搜索:怎么选
- RAG
- 优点:知识可更新、可追溯、成本可控
- 缺点:效果高度依赖检索链路(分块、索引、召回、融合、重排)
- 微调(Fine-tune)
- 优点:对固定风格/固定任务可能更稳、更快
- 缺点:更新成本高、难追溯、容易“记错”且难纠正
- 传统搜索(关键词检索)
- 优点:精确匹配强、可解释性好
- 缺点:语义泛化弱,用户问法变化大时体验差
本文主要聚焦:RAG 里最容易出问题、也最值得优化的“检索链路”。
二、这篇分享会按什么主线展开
- 先把最小闭环跑通:分块 → 向量化 → 检索 →(生成)
- 再定义“好不好”:相关性(relevance)与完整性(coverage/recall)
- 最后按三条链路逐个加固
- 数据源侧:智能分块、QA 增强、知识图谱、元数据
- 输入侧:扩展 + 融合、抽象改写
- 检索侧:混合检索(关键词 + 向量)
三、一张图看清:RAG 的最小闭环
基础 RAG 可以抽象成 4 步:
- 分块(chunking):把文档切成可检索的粒度
- 向量化(embedding):把 chunk 变成向量
- 检索(retrieval):根据用户问题在向量库里找相似 chunk
- 生成(generation):把检索到的 chunk 作为上下文喂给 LLM
下面开始进入主线:围绕分块、输入改写、检索融合、元数据与图谱等关键环节,逐个解释为什么它们会影响 RAG 的稳定性。
(1)架构图(最小闭环)
flowchart LR
U[User Query] --> Q[Query Processing]
Q --> R[Retriever]
R --> V[(Vector Store / Faiss)]
V --> C[TopK Chunks]
C --> G[LLM Generate]
G --> A[Answer]
D[Documents] --> S[Splitter / Chunking]
S --> E[Embeddings]
E --> V
四、先把指标说清楚:RAG 好坏怎么看?
先给出一个我认为非常关键的判断框架:
- 相关性(检索准确性):TopK 里是不是“问的那个点”的答案
- 召回率(完整性):需要的关键证据是否被找全(尤其是多条件、多步骤问题)
实际做下来,一个很常见的现象是:RAG “看起来能回答”,但线上体验不稳定;根因往往就落在这两个指标的拉扯上。
我在复盘时通常会这样判断:
- 相关性差,经常是 chunk 被切碎、query 表达与语料表达不一致、噪声文档太多
- 召回不全,经常是 检索路径单一、query 太短/太具体、缺少多路召回与融合
这就是为什么本文的优化会沿着三条链路展开:
- 数据源侧(让“能被检索到的东西”更像知识)
- 输入侧(让“问法”更容易命中语料)
- 检索侧(让召回路径更丰富、更鲁棒)
五、一个小工程技巧:为什么这些示例可以“纯本地”跑通
为了让你在没有外部 Embedding API Key 的情况下也能把检索链路跑通,我用了一个“教学用 embedding”:把文本 token 通过哈希映射到固定维度向量。
下面是一个最小可读版本(不要直接用于生产):
// 纯本地可运行的 Embeddings(教学演示用,不用于生产)
export class LocalHashEmbeddings {
constructor(dim = 128) {
this.dim = dim;
}
async embedDocuments(texts) {
return texts.map((text) => this.embedText(text));
}
async embedQuery(text) {
return this.embedText(text);
}
embedText(text) {
const vec = new Array(this.dim).fill(0);
const tokens = tokenize(text);
for (const token of tokens) {
const h = stableHash(token);
const idx = Math.abs(h) % this.dim;
vec[idx] += 1;
}
const norm = Math.sqrt(vec.reduce((sum, v) => sum + v * v, 0)) || 1;
return vec.map((v) => v / norm);
}
}
export function tokenize(text) {
const normalized = String(text).toLowerCase();
// 同时提取:连续英文/数字词 + 单个中文字符(让中文查询也能稳定命中)
return (normalized.match(/[a-z0-9]+|[\u4e00-\u9fa5]/g) || []).filter(Boolean);
}
export function stableHash(str) {
let hash = 0;
for (let i = 0; i < str.length; i += 1) {
hash = (hash * 31 + str.charCodeAt(i)) | 0;
}
return hash;
}
(1)LocalHashEmbeddings 的价值(仅用于演示,不要直接上生产)
它不是生产级 embedding,但它解决了做分享/学习时最常见的阻塞点:
- 不依赖外部 API(没有 key、没有限流、没有费用)
- 让你可以专注在 RAG 流程与优化方法,而不是被环境问题卡住
核心思路:
tokenize(text):用正则把英文词与中文单字拆出来stableHash(token):把 token 哈希到固定维度(如 128)- 归一化向量:让相似度计算稳定
这会让“包含相同 token 的文本”更容易相似,从而支撑 demo 的检索对比。
六、数据源侧优化(一):智能分块 Smart Chunking(段落优先 + 句子兜底)
我做智能分块时遵循一个简单原则:优先尊重文本的自然结构(标题/段落/句子),最后才做“硬边界约束”。
(1)为什么固定长度切片常常“看似能用但效果不稳”?
固定长度 + overlap 的切法(滑动窗口)在工程上简单,但有一个结构性问题:
- 它不尊重语义边界
- 一段规则被切成两半,检索只捞到上半段/下半段,就会:
- 相关性降低(命中的 chunk 不完整)
- 召回率降低(必须命中两个 chunk 才够用,但 TopK 往往不够)
(2)这套示例的实现:三段式分块流程
在 intelligentChunkByParagraphAndSentence 里,流程很清晰:
normalizeText:清洗文本,保留段落结构(双换行)- 先按段落切:
split(/\n{2,}/) - 段落太长再按句子切:
splitIntoSentences+mergeSentencesBySize - 最后用 LangChain 的
RecursiveCharacterTextSplitter做“最终边界约束”
下面是核心代码的最小可读版本(省略了依赖导入与运行入口):
function normalizeText(rawText) {
return rawText
.replace(/\r\n/g, "\n")
.replace(/[ \t]+/g, " ")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function splitIntoSentences(paragraph) {
return paragraph
.split(/(?<=[。!?.!?])\s+/)
.map((s) => s.trim())
.filter(Boolean);
}
function mergeSentencesBySize(sentences, maxSize) {
const merged = [];
let current = "";
for (const sentence of sentences) {
const candidate = current ? `${current} ${sentence}` : sentence;
if (candidate.length <= maxSize) {
current = candidate;
continue;
}
if (current) merged.push(current);
current = sentence;
}
if (current) merged.push(current);
return merged;
}
export async function intelligentChunkByParagraphAndSentence({
text,
chunkSize = 300,
chunkOverlap = 60,
source = "demo-source",
}) {
const cleaned = normalizeText(text);
const paragraphs = cleaned
.split(/\n{2,}/)
.map((p) => p.trim())
.filter(Boolean);
const semanticUnits = [];
for (const paragraph of paragraphs) {
if (paragraph.length <= chunkSize) {
semanticUnits.push(paragraph);
continue;
}
const sentences = splitIntoSentences(paragraph);
semanticUnits.push(...mergeSentencesBySize(sentences, chunkSize));
}
// 这里通常会把 semanticUnits 包装成 Document,并写入 metadata
// 然后交给 RecursiveCharacterTextSplitter 做最终 chunkSize/overlap 控制
return { semanticUnits, chunkSize, chunkOverlap, source };
}
flowchart TD
A[Raw Text] --> B[normalizeText]
B --> C[Split by Paragraph]
C --> D{paragraph length <= chunkSize?}
D -- yes --> E[semantic unit]
D -- no --> F[Split by Sentence]
F --> G[mergeSentencesBySize]
G --> E
E --> H[Document + metadata]
H --> I[RecursiveCharacterTextSplitter]
I --> J[Final Chunks]
(3)关键工程点:separators 的顺序决定“语义优先级”
你在 splitter 里配置了:
separators: ["\n\n", "\n", "。", "!", "?", ". ", "! ", "? ", " ", ""]
这背后的原则是:
- 从强语义边界到弱语义边界逐级回退
- 只有在更强边界无法满足 chunkSize 时,才退到更弱边界
(4)metadata 的意义:让后续优化“有抓手”
你在语义单元阶段就写入了:
sourcesemanticUnitIndexchunkStrategy: "paragraph-sentence"
真实项目里,建议至少要能追溯:
- 文档 ID / 标题 / 章节
- chunk 在文档中的位置
- chunk 生成策略版本(以后你换策略要能回溯)
七、数据源侧优化(二):文档 → 问答对(QA Pair)增强
“QA 增强”的直觉很朴素:
- 用户更常用问句表达需求
- 而知识库更常是陈述句/规则条款
所以我会把 chunk 转成若干“可能的问法”,让向量库里同时存在“陈述表达”和“问句表达”。
(1)为什么“QA 增强”能提升检索相关性?
很多语料是“陈述式”的:
- 文档写:
退款通常在3个工作日内原路返回 - 用户问:
退货一般多久到账?
问法与语料表达不一致时,纯向量检索可能没那么稳(尤其是 embedding 较弱、或语料短小)。
QA 增强做的是:
- 把 chunk 包装成更像“用户会问的问题”
- 让向量库里出现更多“问句表达”
(2)这份示例的做法
- 先用
RecursiveCharacterTextSplitter切 chunk generateQaPairsFromChunks用规则生成问句:关于${title},核心规则是什么?${title}有什么注意事项?如何理解:${title}?
- 建两份索引对比:
- A:仅原始 chunk
- B:原始 chunk + synthetic QA
关键逻辑其实就一段:从 chunk 生成 QA 文本,并把它当成“可检索文档”一并入库。
function generateQaPairsFromChunks(chunks) {
const qaDocs = [];
for (const chunk of chunks) {
const title = chunk.metadata?.title || "未命名文档";
const answer = chunk.pageContent;
const questions = [
`关于${title},核心规则是什么?`,
`${title}有什么注意事项?`,
`如何理解:${title}?`,
];
for (const q of questions) {
qaDocs.push({
pageContent: `问题:${q}\n答案:${answer}`,
metadata: {
...chunk.metadata,
docType: "synthetic_qa",
syntheticQuestion: q,
},
});
}
}
return qaDocs;
}
flowchart LR
D[Docs] --> S[Splitter]
S --> C[Chunks]
C -->|index| V1[(Base Vector Store)]
C --> Q[Generate QA Docs]
Q -->|index| V2[(Enhanced Vector Store)]
U[Query] --> V1
U --> V2
(3)生产注意事项(很重要)
- 合成 QA 一定会引入噪声:
- 问句过泛,会把检索“拉向泛化 chunk”
- 问句过多,会稀释向量空间、增大索引
- 工程建议:
- 用高质量 LLM 生成 + 人工抽检
- 对 QA 样本打
docType: synthetic_qa,生成时可加权或筛选
八、数据源侧优化(三):知识图谱 RAG(Graph + Vector)
当问题更像“关系查询”(适用/不适用/时效/条件)时,我会考虑把规则抽成三元组(实体-关系-实体/属性),用“图谱事实”补强上下文结构化程度。
(1)什么时候“图谱”比纯向量更稳?
当问题是 关系型/规则型,例如:
- 适用条件:
退款政策适用哪些商品? - 不适用条件:
哪些不支持无理由退货? - 时效:
到账时效是多少?
这种问题的答案往往是结构化事实(实体-关系-实体/属性)。向量检索能找到相关段落,但不一定能稳定、完整地组织成结构化答案。
(2)这份示例做了什么
- 手写一个教学三元组集合
TRIPLES buildGraphIndex把实体/关系词映射到三元组列表graphRetrieve用tokenize(query)做命中召回triplesToDocuments把三元组变成可用于生成的“事实文本”- 与向量召回融合:
fused = [...graphDocs, ...vectorHits]
下面是一个教学版的最小实现(用 token 命中来做图召回,重点是流程):
const TRIPLES = [
["退款政策", "适用", "7天无理由"],
["退款政策", "不适用", "生鲜商品"],
["退款政策", "不适用", "定制商品"],
["退款政策", "到账时效", "3个工作日"],
["VIP会员", "享受", "95折"],
["VIP会员", "享受", "快速退款通道"],
];
function buildGraphIndex(triples) {
const index = new Map();
const add = (key, triple) => {
if (!index.has(key)) index.set(key, []);
index.get(key).push(triple);
};
triples.forEach((triple) => {
const [s, p, o] = triple;
add(s, triple);
add(p, triple);
add(o, triple);
});
return index;
}
function graphRetrieve(query, graphIndex, tokenize) {
const hits = [];
const qTokens = tokenize(query);
for (const [key, triples] of graphIndex.entries()) {
if (qTokens.some((t) => key.toLowerCase().includes(t) || t.includes(key.toLowerCase()))) {
hits.push(...triples);
}
}
const dedup = new Map();
hits.forEach((triple) => dedup.set(triple.join("|"), triple));
return [...dedup.values()];
}
function triplesToContext(triples) {
return triples.map((t, i) => `图谱事实${i + 1}:${t[0]} - ${t[1]} - ${t[2]}`).join("\n");
}
flowchart TD
U[User Query] --> T[tokenize]
T --> GR[Graph Retrieve]
GR --> GD[Graph Facts as Docs]
U --> VR[Vector Retrieve]
VR --> VD[Vector Docs]
GD --> F[Fuse Context]
VD --> F
F --> G[Generate Answer]
(3)生产实践建议
- 图谱不一定要“重型图数据库”,可以先从:
- 规则表、配置表
- FAQ 的结构化字段
- 商品/订单/政策的结构化数据
- 图谱上下文与向量上下文要分区(提示词里标注来源),便于模型引用与去冲突
九、输入侧优化(一):Query Expansion + RRF 融合
输入侧优化里,我最常先做的是 query expansion:把一个问题扩展成多种问法/角度,然后多路检索,再把结果融合。
(1)为什么要扩展 query?
用户问题往往只是一种表达方式,而语料可能用另一种表达:
- 用户:
会员退款有什么规则? - 语料:
VIP会员享受快速退款通道
Query expansion 的目标:
- 生成多个“等价/近义”的子查询
- 多路检索提高覆盖
(2)RRF(Reciprocal Rank Fusion)是什么?
RRF 是一种非常常见的“多路排序融合”方法:
- 每个检索器输出一个排序列表
- 最终分数是各路排名的倒数之和:
score += 1 / (k + rank)
下面是一个可直接复用的 RRF 融合器(按 id 去重融合多路排序结果):
// rankedLists: Array<Array<{id: string, text: string}>>
export function rrfFuse(rankedLists, k = 60) {
const scoreMap = new Map();
rankedLists.forEach((list) => {
list.forEach((item, rankIndex) => {
const add = 1 / (k + rankIndex + 1);
scoreMap.set(item.id, (scoreMap.get(item.id) || 0) + add);
});
});
const merged = [];
for (const [id, score] of scoreMap.entries()) {
const found = rankedLists.flat().find((x) => x.id === id);
if (found) merged.push({ ...found, score });
}
return merged.sort((a, b) => b.score - a.score);
}
工程上可以用现成的 Ensemble/RRF 实现(很多框架都内置),也可以像上面一样自己写一个轻量融合器:关键是多路召回 + 融合这个范式。
flowchart LR
U[User Query] --> E[Expand to Sub-Queries]
E --> R1[Retriever q1]
E --> R2[Retriever q2]
E --> R3[Retriever q3]
R1 --> L1[Ranked List 1]
R2 --> L2[Ranked List 2]
R3 --> L3[Ranked List 3]
L1 --> RRF[RRF Fuse]
L2 --> RRF
L3 --> RRF
RRF --> TopK[Final TopK Docs]
(3)生产实践建议
- Expansion 最好由 LLM 生成,但要控制:
- 子查询数量(常见 3-8)
- 子查询多样性(同义改写 + 角度扩展)
- 子查询安全(避免引导越权/注入)
- RRF 的优势:
- 对分数标定不敏感(只看 rank)
- 多路融合稳定,工程上“很抗脏”
十、输入侧优化(二):具体问题 → 抽象查询(Abstraction Rewrite)
另一个很“便宜但有效”的手段是:把过于具体、带噪声的问法做抽象化,让它更容易命中“规则型文档”。
(1)这个优化解决什么问题?
当用户问得极其具体时:
- 带订单号:
ORD003 - 带时间状语:
昨天 - 带口语:
能不能、怎么
这些词对“规则类文档”往往是噪声,反而会干扰召回。
(2) demo 实现
abstractQuery(query) 用规则把具体信息替换成抽象槽位:
ORD\d+→订单昨天/今天/...→时间能不能/怎么/如何→规则- 并且如果包含“退”,追加
退款规则 退货条件
最小实现如下(规则只是示例,生产中更常用 LLM 做抽象改写):
function abstractQuery(query) {
const rules = [
{ pattern: /(ORD\d+|订单\s*\d+)/gi, replace: "订单" },
{ pattern: /(昨天|今天|明天|这周|下周|本月|下月)/g, replace: "时间" },
{ pattern: /(能不能|可不可以|怎么|如何)/g, replace: "规则" },
];
let rewritten = query;
rules.forEach((r) => {
rewritten = rewritten.replace(r.pattern, r.replace);
});
if (rewritten.includes("退")) {
rewritten = `${rewritten} 退款规则 退货条件`;
}
return rewritten.replace(/\s+/g, " ").trim();
}
生产中这一步通常由 LLM 来做(更强的语义抽象);规则版示例的价值在于你可以直观看到“去掉具体噪声词后,检索更容易命中规则”。
十一、检索侧优化:混合检索(关键词 + 向量)
检索侧我很推荐尽早把“单路向量检索”升级成“混合检索”:关键词负责精确,向量负责语义。
(1)为什么混合检索很常用?
向量检索擅长语义相似,但对:
- 数字、型号、专有名词
- 精确短语匹配
可能不够稳。
关键词检索(BM25/TF-IDF/甚至简单词频)则刚好相反:
- 精确匹配强
- 语义泛化弱
所以混合检索通常能获得更平衡的效果。
(2)你的 demo 做法:加权融合
你实现了:
keywordRetrieve:对每个 doc 计算keywordScore(query, doc.pageContent)vectorRetrieve:向量召回并用 rank 近似映射分数1/(idx+1)hybridFuse:归一化后加权final = alpha * keyword_norm + beta * vector_norm- demo 默认
alpha=0.2, beta=0.8
下面给出一个最小的融合实现(关键词分数 + 向量分数分别归一化,再线性加权):
function hybridFuse(keywordList, vectorList, alpha = 0.2, beta = 0.8) {
const map = new Map();
const maxKeyword = Math.max(...keywordList.map((x) => x.keyword), 1e-9);
const maxVector = Math.max(...vectorList.map((x) => x.vector), 1e-9);
keywordList.forEach((item) => {
if (!map.has(item.id)) {
map.set(item.id, { id: item.id, title: item.title, text: item.text, keyword: 0, vector: 0 });
}
map.get(item.id).keyword = item.keyword / maxKeyword;
});
vectorList.forEach((item) => {
if (!map.has(item.id)) {
map.set(item.id, { id: item.id, title: item.title, text: item.text, keyword: 0, vector: 0 });
}
map.get(item.id).vector = item.vector / maxVector;
});
const fused = [];
for (const value of map.values()) {
const score = alpha * value.keyword + beta * value.vector;
fused.push({ ...value, score });
}
return fused.sort((a, b) => b.score - a.score);
}
flowchart LR
U[Query] --> KW[Keyword Retrieve]
U --> VE[Vector Retrieve]
KW --> KWL[Keyword Ranked List]
VE --> VEL[Vector Ranked List]
KWL --> F[Normalize + Weighted Fuse]
VEL --> F
F --> TopK[Final TopK]
(3)生产实践建议
- 关键词检索建议用成熟实现(BM25 / Elastic / OpenSearch)
- 融合策略从易到难:
- 线性加权(你 demo 的方式)
- RRF(更“分数无关”)
- Learning-to-Rank(需要标注数据)
十二、数据源侧优化(四):元数据(Metadata)过滤与加权
当语料混杂(官方/社区、版本新旧、适用人群不同)时,metadata 往往是“让 RAG 稳起来”的分水岭:先过滤掉不该参与竞争的候选,再检索/重排。
(1)为什么 metadata 能显著提升稳定性?
在真实业务里,语料往往混杂:
- 官方政策 vs 社区帖子
- 新版本 vs 老版本
- VIP 专属 vs 全量用户
如果你让检索在“全量语料”里自由竞争,噪声来源很容易在某些 query 上跑到前面。
(2)你的 demo:过滤后再建索引
buildDocsWithMetadata()构造 docs,包含:topic、audience、channel、updatedAt
filterByMetadata做过滤:- 只看
topic=refund - 只看官方
channel in [official] - 只看
updatedAfter >= 2025-01-01
- 只看
- 过滤后再
FaissStore.fromDocuments(filteredDocs, embeddings)
最小可读实现如下:
function filterByMetadata(docs, { topic, channels, updatedAfter }) {
return docs.filter((doc) => {
const byTopic = topic ? doc.metadata.topic === topic : true;
const byChannel = channels?.length ? channels.includes(doc.metadata.channel) : true;
const byTime = updatedAfter ? new Date(doc.metadata.updatedAt) >= new Date(updatedAfter) : true;
return byTopic && byChannel && byTime;
});
}
// 用法示例:只看官方渠道 + 2025年后的文档 + 退款主题
const filteredDocs = filterByMetadata(allDocs, {
topic: "refund",
channels: ["official"],
updatedAfter: "2025-01-01",
});
这是非常典型的工程做法:先缩小候选集合,再做昂贵/复杂的向量检索与重排。
(3)生产实践建议
- 优先做“硬过滤”:
- 可信来源、时间版本、租户隔离、权限控制
- 再做“软加权”:
- 对
updatedAt新的文档加分 - 对
official加分
- 对
十三、把这些优化串起来:我会怎么把它落到真实项目里
如果把上面的优化当成“技能点”,真实项目里更需要的是一个可执行的落地顺序。这里给出一个我比较推荐的优先级(从易到难、从低成本到高收益):
- 智能分块 + metadata(最推荐先做)
- 混合检索(关键词 + 向量)
- 输入改写(抽象 + 扩展)
- 多路融合(RRF / Ensemble)
- 数据增强(QA Pair)
- 知识图谱(规则/关系型问题效果很明显)
这套顺序背后的原则是:
- 先减少“切碎/误召回/噪声”(分块与 metadata 往往是最高性价比)
- 再扩展召回路径(混合检索、扩展查询、融合)
- 最后上“结构化知识与数据增强”(QA、图谱会带来更大系统复杂度)
flowchart TD
L0[Baseline: fixed chunk + vector search] --> L1[Smart Chunking]
L1 --> L2[Metadata filter/boost]
L2 --> L3[Hybrid Search]
L3 --> L4[Query Rewrite: abstract]
L4 --> L5[Query Expansion + RRF]
L5 --> L6[QA Pair augmentation]
L6 --> L7[Knowledge Graph RAG]
十四、小结
这套渐进式优化的价值在于:
- 每个优化点都能单独跑通、可对比、能复盘动机
- 用本地 embedding 避开外部依赖,把注意力放在“RAG 检索链路怎么优化”上
- 覆盖了 RAG 最常见的三大类优化:
- 数据源侧(chunk/metadata/graph/QA)
- 输入侧(rewrite/expansion)
- 检索侧(hybrid/fusion)
如果接下来要把这套 demo 往“生产版”推进,通常最先补的不是更多技巧,而是把“效果上限”和“可控性”补齐:
- 真实 embedding + reranker(提升相关性上限)
- metadata 的权限/租户隔离与版本控制(避免误召回)
- 评估集与自动化指标(让优化从“体感”变成“可量化”)
十五、开源代码
最后这里放一下适合AI Agent开发学习的开源代码库: github.com/mingle98/AI…