📌 系列简介:「JS全栈AI学习」记录 AI 应用开发的完整学习过程,篇数随进度持续更新。 前端转 JS 全栈,正在学 AI,理解难免有偏差,欢迎批评指正 ~
写在前面
做简历助手之前,我以为"让 AI 认识我的简历"是一件很简单的事。
把简历文本塞进 Prompt,AI 不就知道了吗?
后来才发现,这只是最粗糙的起点。
真正的问题是:简历会变。
换了工作、做了新项目、学了新技术——AI 的"记忆"也要跟着更新。 更麻烦的是,更新不能出错。 错误的记忆,比没有记忆更危险。
这一章,把设计 RAG 知识库的完整思路写下来: 为什么要这样设计,每一个决策背后的权衡是什么。
一、记忆的两种形态
在 AI 系统里,"记忆"分两种。 理解这个区别,是后面所有设计的基础。
短期记忆,就是 Prompt 里的上下文。 把简历文本直接塞进去,AI 在这次对话里"记得",对话结束就忘了。 上限是 Token 窗口——塞得越多,成本越高,超出就截断。
长期记忆,就是 RAG 知识库。 数据持久化存储,每次对话按需检索,只取相关片段。 不受 Token 限制,可以无限扩展,更新后永久生效。
两种记忆不是非此即彼,是配合使用:
长期记忆(RAG) → 检索相关 chunks
↓
短期记忆(Prompt) → 把 chunks 塞进上下文
↓
LLM 推理整合 → 输出回答RAG 解决"存哪里、怎么找",Prompt 解决"怎么用"。
二、查询模式:用语义检索"认识"简历
传统关键词搜索是字符串匹配—— 用户问"Monorepo",就去找包含"Monorepo"这个词的文本。
RAG 的语义检索不一样。核心是向量。
把一段文字交给 Embedding 模型,它会输出一个高维数字数组——比如 1536 个数字。 这个数组代表这段文字的"语义坐标": 语义相近的文本,坐标在空间里距离近;语义无关的,距离远。
"主导 Monorepo 工程化改造" → [0.23, -0.87, 0.45, ...]
"推动前端工程化建设" → [0.21, -0.91, 0.43, ...] ← 距离近
"负责用户增长数据分析" → [0.89, 0.34, -0.67, ...] ← 距离远查询时,用户的问题经过同样的 Embedding 模型变成向量, 和库里所有向量计算余弦相似度, 取相似度最高的 Top K 个片段召回,塞进 Prompt 给 LLM 推理。
用户问:"你做过工程化相关的工作吗?"
↓
向量化查询
↓
余弦相似度排序
↓
Top 3 召回:Monorepo 改造、技术规范制定、脚手架搭建
↓
LLM 整合回答这里有一个关键约束,容易踩坑:
写入和查询必须使用同一个 Embedding 模型。
换了模型等于换了坐标系,所有向量全部失效,需要全量重建。
三、更新模式:六步写入流程
查询模式解决"怎么找",更新模式解决"怎么改"。
简历更新时,不能简单地覆盖写入。 原因是:原始数据和向量索引是两张表,必须保持同步。 如果更新到一半程序崩溃,就会出现数据和向量不一致的状态。
正确的更新流程分六步:
Step 1 用户提交修改内容
↓
Step 2 置信度评估(后面详细讲)
高置信度 → 继续
中置信度 → 展示 Diff 预览,等用户确认
低置信度 → 拒绝,要求用户澄清
↓
Step 3 写入原表(experiences / projects 等)
持久化原始数据,这是最重要的一步
↓
Step 4 重新生成 chunks
把更新后的内容拆成语义片段
↓
Step 5 向量化新 chunks
调用 Embedding API,全部完成后才进行下一步
↓
Step 6 原子替换
在一个事务里:删除旧 chunks,写入新 chunks
要么全部成功,要么全部回滚Step 5 和 Step 6 的顺序至关重要—— 新向量全部准备好之后,才执行替换。 这样在替换完成之前,旧向量依然在服务查询,不会出现空窗期。
async function updateResume(id: string, newData: Experience) {
// Step 3:写原表
await db.run(
`UPDATE experiences SET content = ?, updated_at = ? WHERE id = ?`,
[JSON.stringify(newData), new Date().toISOString(), id]
)
// Step 4+5:生成并向量化新 chunks(还没写库)
const newChunks = splitIntoChunks(newData)
const chunksWithVectors = await Promise.all(
newChunks.map(async chunk => ({
...chunk,
vector: await embed(chunk.text)
}))
)
// Step 6:原子替换
await db.transaction(async () => {
await db.run(`DELETE FROM resume_chunks WHERE source_id = ?`, [id])
for (const chunk of chunksWithVectors) {
await insertChunk(chunk)
}
})
}四、两个隐患:置信度阈值与记忆污染
置信度阈值
更新流程里的 Step 2,是整个系统的第一道防线。
用户用自然语言描述修改意图时,AI 需要判断: 这句话的意图是什么、要改哪条数据、改成什么值。 这个判断不总是确定的,所以需要置信度评估。
高置信度 ≥ 0.85 直接更新
─────────────────────────────────────────
"把项目描述里的团队规模从 10 人改成 8 人"
意图明确,字段清晰,直接写入
中置信度 0.6~0.85 展示 Diff 预览,等确认
─────────────────────────────────────────
"那段经历写得太长了,帮我精简一下"
AI 生成修改建议,但不直接写入
让用户看到改动后再决定
低置信度 < 0.6 拒绝,要求澄清
─────────────────────────────────────────
"把那个项目更新一下"
哪个项目?更新什么?意图不明
返回提示,引导用户说清楚置信度本身也由 LLM 来评估—— 让它返回结构化的 JSON,包含意图类型、目标字段、新值和置信度分数。
记忆污染
置信度是预防手段。但如果预防失效了,就会出现记忆污染。
记忆污染的本质是:原表数据和向量索引不一致。
正常状态:
原表 "团队规模:8 人" ←→ 向量索引 "8 人" (一致)
污染状态:
原表 "团队规模:8 人" ≠ 向量索引 "10 人" (不一致)用户问"你带几个人",RAG 召回的是旧向量对应的旧文本,AI 给出错误的回答。 更危险的是,这种错误是隐性的——系统不会报错,只是悄悄说了错误的话。
解决方案是索引一致性检测: 定期比对原表和 chunks 的更新时间,发现不一致就自动重建。
// 检测:找出原表比 chunks 更新的条目
async function detectInconsistency() {
return await db.all(`
SELECT e.id, e.updated_at AS exp_updated,
MAX(c.created_at) AS chunk_updated
FROM experiences e
LEFT JOIN resume_chunks c ON c.source_id = e.id
GROUP BY e.id
HAVING exp_updated > chunk_updated
OR chunk_updated IS NULL
`)
}
// 修复:重建不一致条目的 chunks
async function repairInconsistency() {
const staleItems = await detectInconsistency()
for (const item of staleItems) {
await rebuildChunks(item.id)
}
}三道防线,层层兜底:
第一道:置信度阈值 → 低质量修改不进库
第二道:事务保证 → 更新要么全成功,要么全回滚
第三道:一致性检测 → 发现污染,自动修复五、实战:my-resume 知识库 Schema 设计
理论讲完,落到项目里。
现有结构的问题
my-resume 原本的数据结构是为展示和编辑设计的: 数据完整、分层清晰、支持国际化。
但 RAG 需要的是语义检索。 两者目标不同,原有结构有三个问题:
问题一:没有唯一标识。 每条经历没有 id 和 updatedAt,更新时只能靠公司名匹配。 不可靠,也无法做原子更新。
问题二:向量化粒度太粗。 一段 summary 里混了团队管理、技术升级、技术分享三个语义, 整段向量化后,任何相关问题都会召回这整段,精度低。
问题三:英文字段大量为空。 英文查询时向量化的是空字符串—— 这部分数据对英文 RAG,等于不存在。
三个调整
调整一:加身份。 给每个实体补 id 和 updatedAt,原表其他结构不动。
调整二:加粒度。 新建 resume_chunks 表,每条 highlight 单独作为一个向量单元。 原表负责展示和编辑,chunks 表专门服务检索,两层彻底解耦。
// 向量化时,不是把整段 summary 扔进去
// 而是每条 highlight 单独成为一个 chunk,并拼上上下文
const chunk = {
id: "chunk_exp002_h1",
sourceId: "exp_002",
text: "某互联网公司前端负责人 (2024.03-2024.08):" +
"主导核心系统 Vue2 → Vue3 架构升级,推动 Monorepo 工程化改造",
metadata: {
role: "前端负责人",
technologies: ["Vue3", "Monorepo"]
},
vector: [0.23, -0.87, ...]
}
// 用户问"你做过 Monorepo 吗" → 精准召回这一条,而不是整段调整三:补内容。 向量化时把中英文拼在一起, 不用等 en 字段全部补完也能先跑,补完后自动增强。
function buildChunkText(field: { zh: string; en: string }): string {
if (field.en) return `${field.zh}\n${field.en}`
return field.zh
}
// 中文查"架构升级" ✅ 英文查"architecture migration" ✅改动原则只有一条:
原表基本不动,加一张
resume_chunks表隔离展示层和检索层。 原表负责人看,chunks 表负责机器检索。
六、评估体系:怎么知道 AI 没有"乱发挥"
存进去了,取出来了,输出了。
但输出的对不对?
这是整个 RAG 链路里最容易被忽略的一环—— 系统不会报错,日志也是绿的,只是悄悄给了用户一个错误的答案。
问题的根源:作用域链越界
做前端的人对这个场景很熟悉。
JS 里有作用域链——找变量时,先找当前作用域,找到了就用,不往上找。 但如果当前作用域有值,AI 却没有"就近取值",而是继续往上找: 找完检索到的文档,又去翻预训练数据,又去整合不相关的背景知识, 最后把一堆"上层作用域"的内容混进回答里。
这就是 Hallucination(幻觉)。
// 期望的行为:就近取值,拿到就停
function getAnswer() {
const doc = "糖尿病患者应避免高糖食物" // ✅ 检索到了
return doc // 直接用,完事
}
// AI 实际的行为:不停往上找,污染了答案
function getAnswer() {
const doc = "糖尿病患者应避免高糖食物"
// 但 AI 说:我训练数据里还知道更多...
return doc + 自己编的 + 训练数据里的偏见 // ❌ 幻觉出现了
}RAG 场景的幻觉,专业名字叫 Faithfulness Violation(忠实度违反)—— 回答里出现了文档里没有的内容。
三个核心评估维度
要评估 RAG 的输出质量,需要从三个角度同时看:
普通评估只问: 回答对不对?
RAG 评估要问:
① 回答是否忠实于检索到的文档? → Faithfulness(忠实度)
② 检索到的文档和问题相关吗? → Relevance(相关性)
③ 回答是否覆盖了问题的关键点? → Answer Quality(回答质量)Faithfulness 防的是"乱发挥"——AI 说了文档里没有的话。 Relevance 防的是"找错文档"——召回的 chunk 根本和问题无关。 Answer Quality 防的是"答非所问"——文档是对的,但回答跑偏了。
三个维度缺一不可。只看其中一个,都会有盲区。
分数怎么算
Answer Quality 用余弦相似度—— 把标准答案和 AI 回答分别向量化,计算夹角:
标准答案 → Embedding → 向量A
AI 回答 → Embedding → 向量B
↓
cosine_similarity(A, B)
↓
0.95 → 语义高度一致 ✅
0.61 → 语义偏差较大 ❌Faithfulness 用的是一个更巧妙的方法——让 AI 来评估 AI:
Step 1 把检索到的文档交给 LLM
"从这段文档中,列出所有事实陈述"
↓
得到事实清单(标准答案大纲):
[✓] 糖尿病患者应避免高糖食物
[✓] 建议每日监测血糖
[✓] 运动有助于控制血糖
Step 2 把 AI 的回答逐句拆解
"这句话,能在事实清单里找到依据吗?"
↓
[✓] 避免高糖 → 有依据
[✓] 监测血糖 → 有依据
[✗] 推荐吃XX药 → ⚠️ 文档里没有!幻觉!
Step 3 计算分数
Faithfulness = 有依据的句子 / 总句子数 = 2/3 ≈ 0.67 ❌这个方法叫 Claim Decomposition + Verification, 用 LLM 来充当评委,评估另一个 LLM 的输出—— 工程上叫 LLM-as-a-Judge。
阈值怎么定:场景驱动 + 多维加权
有了分数,还需要一条线——过了才算通过。
这条线不是拍脑袋定的,也不是一刀切的。
不同场景,对"正确"的定义本来就不一样:
| 场景 | 核心指标 | 阈值严格度 | Temperature |
|---|---|---|---|
| 医疗问诊 | 准确性、Faithfulness | 极严(≥ 0.95) | ≈ 0 |
| 代码生成 | 可执行性、正确性 | 严(≥ 0.90) | 0.1~0.3 |
| RAG 问答 | Faithfulness、Relevance | 中等(≥ 0.80) | 0.3~0.5 |
| 娱乐闲聊 | 流畅度、创意性 | 宽松(≥ 0.60) | 0.8~1.0 |
医疗场景,准确性是第一原则,一点发挥空间都不留; 娱乐场景,创意和开放性才是核心,准确性反而没那么重要。 Temperature 的设定,本质上也是在控制这个边界。
单一阈值还不够,还要多维加权—— 就像综艺节目不能只有一个评委,每个评委的偏好不同, 加在一起才相对公平:
最终得分 =
Faithfulness × 40% (有没有乱发挥)
+ Relevance × 30% (找的文档对不对)
+ Answer Quality× 30% (回答质量如何)
↓
≥ 阈值 → 通过
< 阈值 → 进入重试流程权重怎么分,由业务场景决定。 简历助手的场景,Faithfulness 权重最高—— 因为说错了比没说更危险。
不通过怎么办:评估-反馈闭环
评估不是终点,是起点。
不通过的回答,不应该直接丢给用户,也不应该直接丢给人工—— 先让系统自己尝试修复:
输出结果
↓
评估打分
↓
< 阈值?
↓
自动重试(最多 3 次)
把"超出文档的部分"作为反馈注入 Prompt:
"只能基于以下事实回答,不得添加文档外的内容:..."
↓
还是不通过?
↓
人工介入 🧑
记录 case,调整权重或阈值三次重试都失败,才升级到人工。 人工的介入不是终点,而是下一轮优化的输入—— 每一个失败的 case,都是在帮系统校准"什么叫好的回答"。
这个闭环,工程上叫 Eval-Feedback Loop(评估反馈循环)。
四道防线,完整闭合:
第一道:置信度阈值 → 低质量修改不进库
第二道:事务保证 → 更新要么全成功,要么全回滚
第三道:一致性检测 → 发现污染,自动修复
第四道:评估反馈闭环 → 输出不达标,自动重试,人工兜底写在最后
设计这套系统的过程里,我一直在想一件事——
记忆是什么?
人的记忆也会出错,也会随时间衰减,也会被新信息覆盖旧信息。 这套 RAG 系统,其实在模拟同样的机制:写入、检索、更新、修复。
但有一点和人不同:
人的记忆没有事务回滚,AI 的可以有。
我们可以给 AI 的记忆加四道防线, 让它的遗忘和错误是可控的、可修复的。
这大概是做这件事最有意思的地方。
昇哥 · 2026年4月做简历助手途中,顺手把想清楚的事写下来。