Skip to content

📌 系列简介:「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 的顺序至关重要—— 新向量全部准备好之后,才执行替换。 这样在替换完成之前,旧向量依然在服务查询,不会出现空窗期。

typescript
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 的更新时间,发现不一致就自动重建。

typescript
// 检测:找出原表比 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 需要的是语义检索。 两者目标不同,原有结构有三个问题:

问题一:没有唯一标识。 每条经历没有 idupdatedAt,更新时只能靠公司名匹配。 不可靠,也无法做原子更新。

问题二:向量化粒度太粗。 一段 summary 里混了团队管理、技术升级、技术分享三个语义, 整段向量化后,任何相关问题都会召回这整段,精度低。

问题三:英文字段大量为空。 英文查询时向量化的是空字符串—— 这部分数据对英文 RAG,等于不存在。

三个调整

调整一:加身份。 给每个实体补 idupdatedAt,原表其他结构不动。

调整二:加粒度。 新建 resume_chunks 表,每条 highlight 单独作为一个向量单元。 原表负责展示和编辑,chunks 表专门服务检索,两层彻底解耦。

typescript
// 向量化时,不是把整段 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 字段全部补完也能先跑,补完后自动增强。

typescript
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月做简历助手途中,顺手把想清楚的事写下来。