写在前面
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 项目途中,顺手把一次次跑偏的问题记下来。