「JS全栈AI学习实战」十三、从能回答到答得准:我给 RAG 加上重排、去噪和主证据

7 阅读14分钟

写在前面

github.com/Fridolph/re… Demo,为了不把项目弄太复杂,先练练手后续会慢慢加入实际项目中

上一篇,我把一份 Markdown 简历拆成了适合 RAG 检索的第一版知识库。

那一版解决的是:

怎么把简历变成可检索的语义数据。

当时链路已经能跑通:

Markdown 简历
   ↓
结构化 records
   ↓
chunks
   ↓
embedding
   ↓
Milvus
   ↓
用户提问
   ↓
召回片段
   ↓
LLM 回答

跑通以后,我一开始还挺开心。

因为系统确实能回答问题了。

但真正用起来,很快就发现了第二个问题:

能回答,不等于答得准。

这里的“准”,不是说模型有没有胡编。

而是它有没有把最重要、最适合作为回答依据的简历片段找出来。

这篇就记录我从 v1 到 v6 做的几轮优化。

如果你想对照代码看,重点可以从这几个文件开始:

  • /resume-memory-rag-qa/drafts/02-简历RAG检索针对性与回答质量优化记录.md:29
  • /resume-memory-rag-qa/src/ask-resume-2-perf-ask.mjs:1
  • /resume-memory-rag-qa/src/rag6/context-builder-v6.mjs:183
  • /resume-memory-rag-qa/src/rag7/config/rerank-config.json:1
  • /resume-memory-rag-qa/src/rag7/config/section-boost-config.json:1

这一篇的主线很简单:

从“向量相似”走向“证据选择”。


一、第一次真实翻车:回答没错,但不是我想要的

第一版跑通后,我问了一个很自然的问题:

这个候选人有哪些 AI Agent 开发相关经验?

当时 v1 的 Top matches 是这样的:

1. [0.6735] skills / AI Agent 开发 / skill_item
   content: 技术输出:持续进行 AI Agent 开发学习与内容输出,具备将实践沉淀为文章/教程的能力

2. [0.6502] core_strengths / 核心竞争力 / strength_item
   content: AI 工程化实践:从 0 到 1 搭建多 Agent 工作流,在真实开发中实践 Vibe Coding 协作模式,持续输出 AI Agent 技术专栏

3. [0.6389] work_experience / 成都澳昇能源科技有限责任公司 / experience_detail_3
   content: 实践 AI 辅助开发,工作流程清晰可回溯,代码生成效率提升

4. [0.6015] skills / AI Agent 开发 / skill_item
   content: AI 辅助开发:高频使用 Claude Code、Cursor、Codex、Coze 等工具,形成以需求拆解、代码生成、Review 为核心的协同开发流

5. [0.5808] projects / GreenSketch / project_detail_3
   content: AI 开发实践:skill creator,快速进行 API Fetch 及调用的模版生成,新页面的模版生成

这个结果看起来其实没问题。

它确实都和 AI Agent 有关。

但我读完回答后,第一反应是:

这回答对是对,但怎么有点“泛”?

因为我真正想看的,不是“简历里哪些地方出现了 AI Agent 相关字样”。

我想看的是:

候选人有哪些最有代表性的 AI Agent 实战经历?

这两件事不一样。

纯向量检索更擅长回答第一个问题:

哪里和 “AI Agent 开发” 这几个字最像?

但简历问答更需要回答第二个问题:

哪些项目 / 工作经历最能证明这个人做过 AI Agent 相关实践?

这就是第一版暴露出来的核心问题:

它召回的是“语义最像的片段”,但不一定是“业务上最该作为证据的片段”。


二、为什么不能只调大 topK

我当时第一个念头也很朴素:

要不要把 topK 调大一点?

比如从 5 改成 8,甚至改成 10。

这当然有用。

因为更多候选片段进来了,my-resume、项目经历、工作经历被捞出来的概率会更高。

但它不是根治方案。

如果只调大 topK,问题会变成:

原来是 5 条里有噪声
现在是 10 条里有更多噪声

也就是说,topK 变大解决的是“有没有机会被召回”。

但没有解决:

召回之后,谁应该排前面?谁应该进入最终 Prompt?

这时候我才意识到,RAG 里至少有两层排序:

第一层:向量库召回排序
  ↓
第二层:业务语义重排
  ↓
最终进入 Prompt 的证据

第一版只有第一层。

所以 v2 我做的不是简单调大 topK,而是:

先扩大候选召回,再自己做一层轻量 rerank。


三、v2:先召回更多,再按问题类型重排

v2 的脚本是:

src/ask-resume-2-perf-ask.mjs

它的目标不是推翻 v1,而是在 v1 基础上做一次“检索质量实验”。

核心变化有三个:

1. 召回更多候选
2. 判断问题类型
3. 给不同 section 加业务权重

代码入口里先把候选数量和最终数量拆开:

const FINAL_TOP_K = Number(process.env.RESUME_RAG_TOP_K || 8);
const CANDIDATE_TOP_K = Number(
  process.env.RESUME_RAG_CANDIDATE_TOP_K || Math.max(FINAL_TOP_K * 2, 10)
);

这一步很关键。

以前是:

Milvus topK = 最终 topK

现在变成:

Milvus 先召回更多候选
   ↓
本地 rerank
   ↓
再截取最终 topK

这样有价值的项目片段不会太早被挡在门外。


四、先判断问题类型:不是所有问题都该用同一套排序

比如同样问简历:

这个候选人有哪些 AI Agent 开发相关经验?

这明显是“经验类问题”。

经验类问题应该优先看:

projects
work_experience

而不是一上来就让 skills 抢主位。

所以 v2 加了一个很简单的问题策略识别:

function detectQuestionStrategy(question) {
  const normalized = normalizeText(question);

  if (/经验|经历|做过|负责过|项目|实战|案例|落地|主导|参与|开发相关经验/.test(normalized)) {
    return 'experience';
  }

  if (/技能|擅长|会什么|技术栈|掌握|熟悉/.test(normalized)) {
    return 'skill';
  }

  if (/项目|作品|案例/.test(normalized)) {
    return 'project';
  }

  return 'general';
}

这段代码很粗糙,但它解决了一个关键问题:

同一份简历,在不同问题下,证据优先级应该不同。

如果问“会什么技术”,那 skills 本来就应该靠前。

但如果问“做过哪些经验”,skills 更适合作为补充,而不是主证据。


五、重排公式:raw score + section boost + keyword boost

v2 的 rerank 没有上复杂模型。

我先用了一套最容易理解的学习版公式:

rerankScore = rawScore + sectionBoost + keywordBoost

对应代码大概是这样:

function rerankMatches(matches, question, strategy) {
  const keywordHints = getKeywordHints(question);

  return matches
    .map((item, index) => {
      const baseScore = Number(item.score || 0);
      const sectionBoost = scoreSectionBoost(item, strategy);
      const { boost: keywordBoost, matchedHintCount } =
        scoreKeywordBoost(item, keywordHints);

      const rerankScore = baseScore + sectionBoost + keywordBoost;

      return {
        ...item,
        _rawIndex: index,
        _baseScore: baseScore,
        _sectionBoost: sectionBoost,
        _keywordBoost: keywordBoost,
        _matchedHintCount: matchedHintCount,
        _rerankScore: rerankScore,
      };
    })
    .sort((a, b) => b._rerankScore - a._rerankScore);
}

这里的思路很直白:

  • rawScore:Milvus 给的原始向量相似度
  • sectionBoost:这个 section 在当前问题类型下重不重要
  • keywordBoost:是否命中 AI、Agent、SSE、工作流等主题词

比如经验类问题里,我希望:

if (strategy === 'experience') {
  if (section === 'projects') return entityType.includes('summary') ? 0.12 : 0.1;
  if (section === 'work_experience') return entityType.includes('summary') ? 0.1 : 0.08;
  if (section === 'core_strengths') return 0.02;
  if (section === 'skills') return -0.02;
}

这不是“让项目永远第一”。

而是给系统一个业务先验:

问经验时,项目和工作经历更应该被优先检查。

这个小改动很有效。

因为它让系统不再只看“文字像不像”,而是开始考虑“这条证据属于哪一类”。


六、Prompt 也要告诉模型:不要让技能喧宾夺主

光重排还不够。

因为最终回答是 LLM 生成的。

如果 Prompt 没告诉它“经验类问题要优先讲项目/工作经历”,它仍然可能把技能点写得很重。

所以 v2 在 Prompt 里加了策略说明:

你现在处理的是“经验/经历类”问题。
回答时请遵守以下优先级:

1. 优先引用 projects 和 work_experience;
2. 如果 skills 或 core_strengths 能补充背景,可以补充,但不能喧宾夺主;
3. 如果项目或工作经历中证据不足,再明确说明不足。

这个点我后来越做越觉得重要。

RAG 不只是 retrieval。

它其实有两段控制:

检索阶段:决定哪些证据进来
生成阶段:决定怎么使用这些证据

如果只优化检索,不约束生成,模型可能还是会按自己的语言习惯组织答案。

如果只约束生成,但检索进来的证据本身不对,那 Prompt 再漂亮也救不了。

所以 v2 的本质是:

检索侧加重排,生成侧加回答优先级。


七、v3:脚本开始变胖,于是抽成 Pipeline

v2 跑完以后,效果确实比 v1 好。

但新问题来了:

ask-resume-2-perf-ask.mjs 变胖了。

里面同时塞了:

CLI 参数
Milvus 检索
问题策略识别
关键词提示
rerank
Prompt 构建
LLM 调用
调试打印

这对学习实验来说没问题。

但如果继续往后做,会越来越难维护。

所以 v3 做了一件工程化的事:

把 RAG 主链路抽成 pipeline。

大概拆成这样:

retriever          负责 Milvus 查询
prompt-builder    负责构建 Prompt
rag-pipeline      负责编排流程
ask script         只负责 CLI 入口和打印

这一步没有让回答质量立刻飞跃。

但它让后面的优化有了位置。

比如:

  • 想换 Prompt,只改 prompt-builder
  • 想加去噪,只改 context 构建
  • 想支持多轮历史,不用污染检索逻辑
  • 想打印调试信息,也不会塞进 pipeline

这时候我第一次感觉,这个项目开始从“脚本实验”变成“小型 RAG 应用”了。


八、v4:多轮历史、Prompt 目录化,以及第一次正式处理噪声

继续跑下去后,又出现几个更工程化的问题。

1. 历史消息不能塞进简历向量库

多轮对话肯定要做。

但用户的聊天历史,不能和简历知识一起写进 Milvus。

因为这两类数据不是一回事:

简历知识:长期记忆,稳定、可检索、代表候选人事实
聊天历史:短期上下文,只服务当前 session

如果把聊天历史也写进简历向量库,很容易污染知识库。

所以 v4 开始把 session 单独存成本地文件。

这个设计后来延续到 v7,只是 v7 又把路径和版本依赖收口了。

2. Prompt 不能继续写死在代码里

Prompt 一长,写在 JS 字符串里就非常痛苦。

所以 v4 把 Prompt 模板目录化。

这样后面不同问题类型可以对应不同模板:

resume_experience_qa
resume_skill_qa
resume_project_qa
resume_general_qa

这一步的价值不是“优雅”。

而是让 Prompt 成为可以被单独迭代的工程资产。

3. 噪声不能只靠固定分数判断

这里有一个很容易踩的坑:

不相关片段,不一定分数低。

比如问 AI Agent 相关经验时,某些项目因为技术栈、工程化、协作这些词和问题有点相似,也可能被召回。

所以噪声判断不能只看:

score < 0.5

还要看:

它有没有命中当前问题主题?
它是不是当前问题优先 section?
它和头部结果差距大不大?

这就是后面 v5、v6 继续优化的方向。


九、v5:把硬编码规则移到配置里

v2 到 v4 的规则越来越多:

  • 哪些关键词算 AI Agent 主题?
  • 不同问题类型下,section 怎么加权?
  • keyword boost 每命中一个加多少?
  • raw score 低于多少算可疑?
  • 哪个 Prompt 模板对应哪个策略?

一开始这些都写在代码里。

但写着写着就会出现一个问题:

规则越来越像配置,但还散落在代码里。

所以 v5 做了一次收口,把它们移到 JSON 配置。

比如 rerank 配置:

{
  "keywordBoostPerHit": 0.015,
  "keywordBoostMax": 0.09,
  "rawScoreNoiseThreshold": 0.5,
  "rerankScoreNoiseThreshold": 0.6,
  "preferredSectionKeepScore": 0.63
}

section boost 配置:

{
  "experience": {
    "projects": { "summary": 0.12, "default": 0.1 },
    "work_experience": { "summary": 0.1, "default": 0.08 },
    "core_strengths": { "default": 0.02 },
    "skills": { "default": -0.08 },
    "__default": { "default": -0.05 }
  }
}

这一步很重要。

因为它让“调参”和“改逻辑”分开了。

以后我想调整经验类问题中 skills 的权重,不需要进函数里翻代码,只改配置就行。

这也更接近真实项目里的 RAG 调优方式:

代码负责机制,配置负责策略。


十、v6:不是所有项目都该进 finalMatches

v5 之后,效果已经明显比 v1 好。

但继续跑 AI Agent 开发相关经验 这个问题时,又发现了一个新问题:

有些项目虽然属于 projects,但和 AI Agent 关系不强。

比如:

LC 安全分析大屏

它是项目经历,按经验类问题确实属于优先 section。

但它不是 AI Agent 相关项目。

如果只因为它是 projects 就给高权重,它还是可能混进最终上下文。

这说明 v5 的策略还不够细:

projects 是优先 section
≠
所有 projects 都是当前问题的主证据

所以 v6 做了一个关键调整:

section 优先级必须和 topicHit 结合。

代码里有一段逻辑很能代表这个思路:

if (
  (strategy === 'experience' || strategy === 'project') &&
  (item.section === 'projects' || item.section === 'work_experience') &&
  !topicHit
) {
  return baseBoost * Number(
    precision.sectionBoostAttenuationWithoutTopicHit || 0.35
  );
}

翻译成人话就是:

你是项目没错,但你没命中当前主题,所以不能拿满额加分。

这个改动对我启发挺大。

因为它把“业务结构”和“问题主题”分开了。

业务结构:你是不是项目 / 工作经历?
问题主题:你是不是 AI Agent 相关?

两者都满足,才更像主证据。


十一、finalMatches 也不能再直接 slice(topK)

早期的 finalMatches 很简单:

finalMatches = denoisedMatches.slice(0, topK)

这看起来合理,但有个隐患:

排名前几的,不一定都适合作为主证据。

所以 v6 开始把最终证据分层:

primary evidence   主证据
support evidence   辅助证据

核心逻辑变成:

const isPrimary =
  isPreferredSection &&
  (item._topicHit || Number(item._matchedHintCount || 0) > 0 || item.section === 'core_strengths') &&
  Number(item._rerankScore || 0) >= primaryMinRerankScore &&
  noiseReasons.length < hardDropMinNoiseReasons;

const isSupport =
  isPreferredSection &&
  Number(item._rerankScore || 0) >= supportMinRerankScore;

这一步的意义是:

不再把“排序靠前”直接等同于“可以作为主证据”。

而是先判断它属于哪种证据,再决定怎么进入 Prompt。

这也是我觉得 v6 最关键的变化。

因为它让 RAG 从“检索片段列表”往“证据系统”迈了一步。


十二、这一轮优化后,我对 RAG 的理解变了

做完 v1 到 v6,我对 RAG 的理解发生了变化。

最开始我以为:

RAG = embedding + vector search + prompt

后来发现更准确的说法应该是:

RAG = 数据组织 + 召回 + 重排 + 去噪 + 证据选择 + Prompt 使用

尤其在简历这种场景里,最难的不是“找相关内容”。

而是:

从一堆相关内容里,选出最适合回答当前问题的证据。

这和搜索引擎很像。

搜到一堆页面不难。

难的是知道哪几条最应该排在前面。


十三、如果你也想读这个仓库,可以从这条线看

这个项目是开源学习仓,所以我觉得比较适合按版本读。

不要一上来就看最新 v7。

可以按这个顺序:

v1:先看最朴素的 ask
  ↓
v2:看为什么加 candidateTopK 和 rerank
  ↓
v3:看怎么从脚本抽成 pipeline
  ↓
v4:看 session、prompt 模板、noiseCheck
  ↓
v5:看配置外置和 context-builder
  ↓
v6:看 primary/support 和主证据优先

每一版都不是“为了炫技加功能”。

基本都是因为前一版真实跑出了问题。

这也是我觉得这个学习项目比较有意思的地方:

它不是一开始就设计成完整架构,而是被一次次真实提问推着长出来的。


十四、这一篇先停在 v6

到 v6 为止,系统已经解决了几个关键问题:

1. 不再只依赖纯向量相似度
2. 能按问题类型调整 section 优先级
3. 能把硬编码规则配置化
4. 能识别部分噪声
5. 能区分主证据和辅助证据

但它还没有结束。

因为 v6 之后我又发现:

skills / AI Agent 这种高相关片段,有时会被压得太狠
my-resume 虽然进了 final,但回答里不一定被点名
finalMatches 更精了,但信息面也变窄了

这就引出了 v7。

v7 要解决的是:

在主证据优先的基础上,怎么做跨区补证、兜底证据,以及更清晰的流式调试体验。

这个放到第三篇讲。


写在最后

这轮做下来,我最大的体会是:

RAG 的难点,不是让 AI “知道更多”,而是让它“用对证据”。

第一版像是把资料都放进了书架。

第二阶段做的事情,则是在给书架加索引、加标签、加优先级,还要告诉 AI:

这几本是主参考书
这几本只能当补充材料
这几本看着相关,但这次别用

这其实很像前端工程里的状态管理。

数据都在,不代表页面就会正确。

还要知道:

哪个状态是 source of truth?
哪个只是 derived state?
哪个只是 UI 辅助信息?

简历 RAG 也是一样。

召回结果都在,不代表答案就会好。

还要知道:

谁是主证据,谁是辅助证据,谁只是看起来像。


昇哥 · 2026年4月
做 my-resume 项目途中,顺手把一次次跑偏的问题记下来。