写在前面
这个系列更到现在,剩下两个我最想认真补的主题:RAG 和 Agent 编排。
但如果只是写理论,其实意义不大。概念解释现在交给 AI 就够了,真正有价值的是:在一个具体项目里,遇到问题、拆问题、再一点点把链路跑起来。
所以我换了个写法。先不急着在 my-resume 正式项目里大改,而是单独开了一个实验仓,拿一份 Markdown 简历练手:
刚好 DeepSeek v4 最近也发了,后面可以顺手做一次模型切换实验。但这篇先不讲模型对比,先讲 RAG 最基础的一步:
为什么我没有直接把整份简历塞进 Prompt?
一开始我也觉得这事很简单。
把 Markdown 简历丢给模型,再问一句:
这个人做过哪些 AI Agent 相关项目?
AI 不就能答了吗?
后来真做起来才发现,问题不是“AI 能不能看懂一份简历”,而是:
它怎么稳定、持续、低成本地认识一份会变化的简历。
在我的规划里,my-resume 不应该只是一个静态简历展示项目。
它应该是能持续成长的:以后当我新增项目、调整经历、补充技能时,AI 能理解这些改动,并辅助更新简历内容和知识库。
这部分“自动更新”还没完成,本篇先做第一步:让 AI 能通过 RAG 稳定查询简历。
因为简历不是静态文档。
换了工作要更新,做了新项目要补充,学了新技术要加入,甚至连一段描述的写法,都会不断调整。
如果每次都把整份简历重新塞进 Prompt,问题会很快出现:
- 成本高
- 上下文长
- 命中不稳定
- 后面也没法做结构化更新
这时候我才真正意识到:
简历助手的核心,不是“怎么生成一句回答”,而是“怎么设计一份能被 AI 正确认识、正确检索、还能持续更新的知识库”。
这一篇,只讲第一步:
我是怎么把一份普通 Markdown 简历,拆成适合 RAG 使用的第一版知识库。
也就是这个实验项目的 v1。
一、记忆的两种形态
先说最本质的一点。
在 AI 系统里,“记忆”分两种。理解这个区别,是后面所有设计的基础。
短期记忆,就是 Prompt 里的上下文。
最直接的方式,就是把简历内容直接放进 Prompt:
你是一个简历助手。
以下是候选人的简历:
...
请回答:这个候选人有哪些前端工程化经验?
这样做当然能跑。在 demo 初期,它甚至是最容易想到的方法。
但问题也很明显。
简历一长,Prompt 就越来越大;每次提问都要重复传整份简历,成本很高;模型关注的是“整篇文本”,不是“哪一段最相关”;后面如果要支持更新,也没有地方做结构化写入。
也就是说,Prompt 更像是一次性的临时记忆。
这次对话里它记得,下一次又要重新塞进去。
长期记忆,就是 RAG 知识库。
把简历预处理后存进一个可检索的知识库,数据持久化存储,每次对话按需检索,只取相关片段。
它不是完全“不受限制”,而是不再直接受单次 Prompt 窗口限制。
用户提问时,流程大概是这样:
长期记忆(RAG) → 检索相关 chunks
↓
短期记忆(Prompt) → 把 chunks 塞进上下文
↓
LLM 推理整合 → 输出回答
RAG 解决“存哪里、怎么找”,Prompt 解决“怎么用”。
两种记忆不是非此即彼,是配合使用的。
所以从一开始,我就不想把这个项目做成“整份文本一把塞”的玩具版本。
我想要的是:
一个以后真的能接进 my-resume 的、能持续更新的简历知识库雏形。
二、查询模式:用语义检索“认识”简历
确定了用 RAG,下一个问题是:它怎么“找”?
传统关键词搜索是字符串匹配。
用户问 “Monorepo”,就去找包含 “Monorepo” 这个词的文本。
RAG 的语义检索不一样。核心是向量。
把一段文字交给 Embedding 模型,它会输出一个高维数字数组。
这个数组代表这段文字的“语义坐标”:
"主导 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 模型。
因为换了模型,就等于换了坐标系。
我这次真实踩过一次:集合 schema 里的向量维度和当前 embedding 模型实际返回维度不一致。
所以第一版后来做了一个很重要的兜底:启动写入时先探测真实向量维度。
// src/ingest-resume.mjs
const vectorDim = await resolveVectorDim()
console.log(`Embedding vector dim: ${vectorDim}`)
await ensureCollection(vectorDim)
查询时也再检查一次:
// src/ask-resume.mjs
const collectionVectorDim = getCollectionVectorDim(collectionDetail)
const queryVector = await getEmbedding(question)
if (queryVector.length !== collectionVectorDim) {
throw new Error(
`查询向量维度与集合不一致:集合为 ${collectionVectorDim},当前 embedding 为 ${queryVector.length}`
)
}
这一步看起来很啰嗦,但非常有必要。
因为 RAG 里的很多问题,不会在“生成回答”时才出现,而是早在“数据有没有正确入库”时就已经埋下了。
三、先别急着切 chunk:简历应该以什么粒度存进去
真正开始做的时候,我最先思考的不是“怎么检索”,而是:
简历这种数据,应该以什么粒度存进去?
因为简历不是普通文章,它天然是强结构化的。
我的简历里本来就有这些大块:
- 基本信息
- 核心竞争力
- 教育经历
- 专业技能
- 工作经历
- 核心项目经历
这些内容本身已经带有很强的业务语义。
如果不利用这个结构,直接像处理普通长文一样按固定字符长度去切,最后得到的 chunk 很可能是这样的:
- 前半段在讲项目背景
- 后半段在讲技术栈
- 中间夹着角色说明和成果描述
不是不能检索,但不够精准。
于是第一版做了一个最关键的决定:
不先按字符切,而是先按简历的业务结构拆。
四、第一版的核心设计:先拆 records,再生成 chunks
我没有把整份简历直接丢进 splitter。
而是先把 Markdown 简历解析成一条条结构化记录,也就是 records。
例如:
- 基本信息 → 一条
profile_summary - 核心竞争力里的每个 bullet → 一条
strength_item - 专业技能里的每个技能点 → 一条
skill_item - 工作经历 / 项目经历 → 拆成
summary和detail
这个设计在代码里是这样落的:
function mapSectionName(sectionHeading) {
const normalized = normalizeInlineMarkdown(sectionHeading)
switch (normalized) {
case '基本信息':
return 'profile'
case '核心竞争力':
return 'core_strengths'
case '教育经历':
return 'education'
case '专业技能':
return 'skills'
case '工作经历':
return 'work_experience'
case '核心项目经历':
return 'projects'
default:
return slugify(normalized) || 'misc'
}
}
不同 section 的拆法也不一样。
比如“专业技能”最适合一条 bullet 变成一条 record:
if (block.section === '专业技能') {
for (const bullet of block.bullets) {
records.push(
createRecord(
sourceId,
sectionKey,
subsectionKey,
subsectionTitle,
'skill_item',
normalizeInlineMarkdown(bullet),
[...tags, '专业技能', subsectionTitle]
)
)
}
}
工作经历和项目经历更复杂,所以拆成两层:
summary:整体介绍、角色、技术栈、项目概览detail:每一条亮点、成果、难点解决
这样做的好处是很直接的:
- 问“做过什么项目”时,容易召回 summary
- 问“具体解决了什么问题”时,容易召回 detail
- 问“有没有 AI Agent 经验”时,技能、项目亮点、核心竞争力都可能命中
这一步,是整个 v1 的地基。
五、只有长内容,才做二次 chunk
第一版不是每条 record 都继续切。
像技能点、核心竞争力、项目亮点这种短记录,本身已经很适合直接向量化。
真正需要二次切块的,是工作概述、项目概览这类长内容。
代码里对应的是这一段:
export async function chunkResumeRecords(
records,
{
chunkSize = DEFAULT_CHUNK_SIZE,
chunkOverlap = DEFAULT_CHUNK_OVERLAP,
} = {}
) {
const splitter = new RecursiveCharacterTextSplitter({
chunkSize,
chunkOverlap,
separators: ['\n\n', '\n', '。', ';', ',', ' '],
})
const chunks = []
for (const record of records) {
if (record.content.length <= chunkSize) {
chunks.push({
...record,
chunkIndex: 0,
chunkCount: 1,
})
continue
}
const splitContents = await splitter.splitText(record.content)
splitContents.forEach((content, index) => {
chunks.push({
...record,
id: `${record.id}:chunk:${index}`,
content,
chunkIndex: index,
chunkCount: splitContents.length,
})
})
}
return chunks
}
这段代码里有一个关键点:
不是对整份简历切块,而是对已经结构化好的 record 做二次切块。
这和“整篇切窗”最大的区别就在这里。
普通切窗关注的是长度,这版先关注的是语义边界。
落地之后,知识库里的数据不再是“几大段模糊文本”,而更像是一组带业务标签的语义单元。
六、每条 chunk 存了什么
第一版不是把文本和向量扔进去就完了。
我给每条 chunk 都保留了一组元信息:
| 字段 | 说明 |
|---|---|
id | 主键 |
source_id | 来自哪份简历 |
locale | 语言 |
section | 一级分类,比如 skills |
subsection_key / subsection_title | 二级分类 |
entity_type | 块类型,比如 skill_item / project_summary |
content | 正文 |
tags | 辅助检索标签 |
chunk_index / chunk_count | 分块位置 |
vector | 向量 |
Milvus collection 的 schema 大概是这样:
await client.createCollection({
collection_name: COLLECTION_NAME,
fields: [
{ name: 'id', data_type: DataType.VarChar, max_length: 200, is_primary_key: true },
{ name: 'source_id', data_type: DataType.VarChar, max_length: 120 },
{ name: 'locale', data_type: DataType.VarChar, max_length: 20 },
{ name: 'section', data_type: DataType.VarChar, max_length: 60 },
{ name: 'subsection_key', data_type: DataType.VarChar, max_length: 120 },
{ name: 'subsection_title', data_type: DataType.VarChar, max_length: 300 },
{ name: 'entity_type', data_type: DataType.VarChar, max_length: 80 },
{ name: 'content', data_type: DataType.VarChar, max_length: 12000 },
{ name: 'tags', data_type: DataType.Array, element_type: DataType.VarChar, max_capacity: 20, max_length: 200 },
{ name: 'chunk_index', data_type: DataType.Int32 },
{ name: 'chunk_count', data_type: DataType.Int32 },
{ name: 'vector', data_type: DataType.FloatVector, dim: vectorDim },
],
})
这一层设计,后来证明非常重要。
向量库如果只存文本和向量,后面虽然也能召回,但解释性很差。
你只能知道“它查到了一段话”,但你很难快速看懂:
- 这段话来自技能、项目还是工作经历?
- 它是 summary 还是 detail?
- 它属于哪一个 subsection?
带上这些字段之后,整个知识库就从“黑盒向量堆”变成了:
一份带业务结构的可检索语义索引。
这对后面所有版本都打了基础。
七、主键设计:不能只靠标题
这里还有一个真实踩坑。
最开始我很自然地想用标题拼主键。
比如:
sourceId + section + subsection + entityType + title
看起来很合理。
但真实数据一跑就出问题了。
比如“AI Agent 开发”这个小节下面有多条技能点,如果主键只靠标题生成,这几条都会撞在一起。
因为它们的标题一样,但内容不一样。
后来改成了这样的设计:
function createRecord(
sourceId,
section,
subsectionKey,
subsectionTitle,
entityType,
content,
tags = []
) {
const contentHash = crypto
.createHash('sha1')
.update(`${sourceId}:${content.trim()}`)
.digest('hex')
.slice(0, 10)
return {
id: `resume:${section}:${entityType}:${contentHash}`,
sourceId,
locale: 'zh',
section,
subsectionKey,
subsectionTitle: normalizeInlineMarkdown(subsectionTitle || subsectionKey || section),
entityType,
tags: [...new Set(tags)],
content: content.trim(),
}
}
这个主键有两个好处:
- 前半段还能看出来源结构,方便调试
- 后半段用 content hash 保证唯一性
这里没有直接用 UUID。
因为 UUID 虽然也能保证唯一,但它不利于调试。
学习阶段,我更希望打开数据时能看懂:
这条记录大概来自哪个 section、是什么 entity type。
八、第一版是怎么跑起来的
第一版只做了三个脚本,非常朴素。
1. inspect:先看数据,再写库
这个脚本不写库,只负责把解析后的 record 打印出来。
目的很简单:
- 看 section 拆得对不对
- 看 subsection 是否合理
- 看 content 有没有明显跑偏
因为在真正写 Milvus 之前,最重要的不是“向量化能不能成功”,而是:
你拆出来的数据,到底是不是你想要的数据。
这一步花了我不少时间,但值得。
否则后面检索结果不准,你根本不知道是:
- parser 拆错了
- embedding 不准
- Milvus 没写进去
- 还是 Prompt 没写好
inspect 其实就是给自己一个“肉眼验收数据结构”的入口。
2. ingest:把结构化简历写进 Milvus
写入流程是:
连接 Milvus
↓
探测 embedding 维度
↓
确保 collection 存在且 schema 正确
↓
解析简历,生成 records / chunks
↓
为每个 chunk 生成 embedding
↓
批量插入 Milvus
↓
flush 并确认 row_count
核心写入代码大概是这样:
const plan = await buildResumeChunkPlan(DEFAULT_RESUME_PATH)
const rows = []
for (const chunk of plan.chunks) {
const vector = await getEmbedding(chunk.content)
const { nonZeroCount } = validateEmbedding(vector, `chunk ${chunk.id} 的 embedding`)
if (vector.length !== vectorDim) {
throw new Error(`chunk ${chunk.id} 的向量维度异常`)
}
rows.push({
id: chunk.id,
source_id: chunk.sourceId,
locale: chunk.locale,
section: chunk.section,
subsection_key: trimByBytes(chunk.subsectionKey, 120),
subsection_title: trimByBytes(chunk.subsectionTitle, 300),
entity_type: chunk.entityType,
content: chunk.content,
tags: (chunk.tags || []).map((tag) => trimByBytes(String(tag), 200)),
chunk_index: chunk.chunkIndex,
chunk_count: chunk.chunkCount,
vector,
})
}
这里有一个细节:trimByBytes()。
一开始我以为字符串长度限制,看 JS 的 length 就差不多了。
后来发现不行。
Milvus 的 VarChar / Array<VarChar> 限制,本质上要按字节风险考虑,中文通常比英文占更多字节。
所以加了一个按字节裁剪的兜底:
function trimByBytes(value, maxBytes) {
if (Buffer.byteLength(value, 'utf8') <= maxBytes) {
return value
}
let trimmed = value
while (trimmed && Buffer.byteLength(trimmed, 'utf8') > maxBytes) {
trimmed = trimmed.slice(0, -1)
}
return trimmed
}
这不是为了好看,是为了防止整批写入被某个字段炸掉。
九、这次真实踩到的几个坑
第一版跑起来的过程并不丝滑。
坑一:向量维度不能写死
一开始我把 collection 的 vector dim 写死了。
后来切换 embedding 模型时发现,模型实际返回的维度和 collection schema 不一致。
这会导致:
- 插入时失败
- 查询时失败
- 或者后面检索行为完全不可信
所以后来改成:
- 写入前先
resolveVectorDim() - collection schema 用真实维度创建
- 查询前再校验 query vector 和 collection vector dim 是否一致
这个坑让我意识到:
RAG 不是只要模型能返回文本就行,embedding 这层才是知识库的坐标系。
坑二:insert_cnt = 0 不一定代表代码没跑
这次更隐蔽的问题是:
- 某条
tags的中文内容过长 - schema 里
tags.max_length设得太小 - 导致整批插入被 Milvus 拒绝
表面看起来像是:
代码执行了
但是 Inserted 0
很容易误判成 flush 时序问题,或者怀疑代码根本没跑到。
但真正的问题是:批量写入整批失败了,只是 SDK 没有直接 throw。
所以后来补了显式状态检查:
const result = await client.insert({
collection_name: COLLECTION_NAME,
data: rows,
})
if (result?.status?.code !== 0) {
throw new Error(
`Milvus insert failed: ${result?.status?.reason || result?.status?.error_code || 'unknown error'}`
)
}
然后再 flush,再查真实行数:
await client.flushSync({
collection_names: [COLLECTION_NAME],
})
const stats = await client.getCollectionStatistics({
collection_name: COLLECTION_NAME,
})
const rowCount = getRowCount(stats)
const visibleRowCount = await countVisibleRowsBySource(plan.chunks[0]?.sourceId || 'resume')
这一步之后,才算真正知道数据有没有写进去。
坑三:drop -> create -> load 之间有短暂时序问题
重建 collection 时,还有一个很像“玄学”的问题。
明明刚 create 完,马上 load,却偶尔报:
collection not found
这不是代码逻辑错,而更像是 Milvus 内部元数据传播有一点延迟。
所以后来加了轻量重试:
for (let attempt = 1; attempt <= 5; attempt += 1) {
try {
await client.loadCollection({ collection_name: COLLECTION_NAME })
return
} catch (error) {
const message = String(error?.message || '')
if (message.includes('already loaded')) return
if (attempt === 5 || !message.includes('collection not found')) {
throw error
}
await sleep(500 * attempt)
}
}
这个问题也让我更确定:
RAG demo 能不能稳定复现,很多时候取决于这些“不起眼的工程兜底”。
坑四:主键不能只靠标题
这个前面已经讲过。
真实简历里,同一个小节下会有很多条 bullet。
比如 AI Agent 开发 下面的每条技能点,标题都一样。
如果主键只靠标题,就会撞。
最后用 contentHash 解决。
这件事也很像前端里的 key:
看起来只是一个标识,设计不好,后面整个列表渲染都会出问题。
十、ask:最朴素的问答链路
写入之后,第一版的问答脚本也很简单。
流程是:
用户提问
↓
问题向量化
↓
Milvus TopK 检索
↓
打印 Top matches
↓
拼接召回片段
↓
喂给模型生成答案
搜索部分大概是这样:
const searchResult = await client.search({
collection_name: COLLECTION_NAME,
vector: queryVector,
limit: TOP_K,
metric_type: MetricType.COSINE,
output_fields: [
'source_id',
'locale',
'section',
'subsection_key',
'subsection_title',
'entity_type',
'content',
'tags',
'chunk_index',
'chunk_count',
],
})
我特意在回答前打印了 Top matches:
matches.forEach((item, index) => {
console.log(
`${index + 1}. [${Number(item.score).toFixed(4)}] ${item.section} / ${item.subsection_title} / ${item.entity_type}`
)
console.log(` subsectionKey: ${item.subsection_key}`)
console.log(` tags: ${Array.isArray(item.tags) ? item.tags.join(', ') : ''}`)
console.log(` content: ${String(item.content).slice(0, 180)}\n`)
})
这一步对学习特别重要。
因为不要一上来只看最终 AI 回答。
否则很容易把问题判断错:
- 明明是召回错了,却以为是 Prompt 没写好
- 明明是 Prompt 没约束好,却以为是向量库不准
- 明明是数据没写进去,却以为是模型不理解
先看证据,再看回答。
这是我后面调 RAG 时一直保留的习惯。
十一、第一版 Prompt:先限制它不要乱说
第一版的 Prompt 也很朴素。
核心只有一句:
你是一个简历问答助手。请只基于给定的简历片段回答问题,不要脑补。
完整结构大概是:
const context = matches
.map(
(item, index) => `[片段 ${index + 1}]
section: ${item.section}
subsectionKey: ${item.subsection_key}
subsectionTitle: ${item.subsection_title}
entityType: ${item.entity_type}
content: ${item.content}`
)
.join('\n\n-----\n\n')
const prompt = `你是一个简历问答助手。请只基于给定的简历片段回答问题,不要脑补。
简历片段:
${context}
问题:${question}
回答要求:
1. 先给结论。
2. 尽量简短、概括引用片段中的具体事实。
3. 如果是经历或技能总结,请指出来自哪些 section。
回答:`
这版 Prompt 还很粗。
没有模板化,没有按问题类型切换,也没有证据分层。
但它至少先立住了一个底线:
只能基于召回片段回答,不要脑补。
后面所有 Prompt 优化,都是在这个基础上继续长出来的。
十二、关于“更新模式”:第一版还没做完,但方向已经很清楚
这里要补一句说明。
第一版项目里,主要做的是:
- Markdown 简历解析
- 语义 records / chunks
- embedding 入库
- TopK 检索问答
它还没有完整实现“自然语言更新简历”的闭环。
但我在设计知识库时,已经开始考虑后面接入 my-resume 时会遇到的问题:
简历会变,知识库也必须跟着变。
后续真正接进产品时,更新流程不能是“随便覆盖一下向量”。
比较稳的流程应该是:
Step 1 用户提交修改内容
↓
Step 2 置信度评估
高置信度 → 继续
中置信度 → 展示 Diff 预览,等用户确认
低置信度 → 拒绝,要求用户澄清
↓
Step 3 写入原表(experiences / projects 等)
↓
Step 4 重新生成 chunks
↓
Step 5 向量化新 chunks
↓
Step 6 原子替换
删除旧 chunks,写入新 chunks
这部分更像是后续 my-resume 的架构预埋。
第一版先做了一件更小但更关键的事:
先证明简历能被稳定拆分、写入、检索和回答。
有了这条链路,后面才有资格继续讨论更新、置信度、一致性检测这些更完整的能力。
十三、链路通了,但马上发现下一个问题
第一版跑起来后,我很快发现一个现实:
系统确实能召回内容,AI 也确实能给答案。
但很多时候,回答会出现一种很熟悉的情况:
不能说错,但也不是我真正想要的答案。
比如问:
这个候选人有哪些 AI Agent 开发相关经验?
它可能会查到技能里提到的 AI Agent,也可能查到项目里的一部分内容,还可能混进一些相关但不够核心的片段。
然后模型拼出一个“看起来合理”的回答。
但这个回答并不一定抓住了最关键的代表性经历。
这时候我才意识到:
第一版解决的是:
怎么把简历变成可检索知识库。
但下一步必须解决的是:
怎么让系统更精准地找到“最该回答的证据”。
这也是下一篇要讲的内容。
写在最后
设计这套系统的过程里,我一直在想一件事:
记忆是什么?
人的记忆也会出错,也会随时间衰减,也会被新信息覆盖旧信息。
这套 RAG 系统,其实在模拟同样的机制:
- 写入
- 检索
- 更新
- 修复
但有一点和人不同:
人的记忆没有事务回滚,AI 的可以有。
回头看,第一版最重要的收获,不是“做出了一个问答 demo”。
而是我终于想清楚了一件事:
RAG 的起点,不是模型,不是 Prompt,而是数据怎么组织。
这一步其实很像做前端时的“组件拆分”。
不是页面不能先堆出来,而是如果一开始不先想清楚边界,后面就会越来越难维护。
简历 RAG 也是一样:
先把边界拆清楚,再让 AI 介入。
下一篇,讲第二版:当链路跑通之后,怎么让召回更精准。
昇哥 · 2026年4月
做简历助手途中,顺手把想清楚的事写下来。