「JS全栈AI学习实战」十二、从 Prompt 到 RAG:把 md 简历变成可检索知识库

13 阅读11分钟

写在前面

这个系列更到现在,剩下两个我最想认真补的主题:RAG 和 Agent 编排。

但如果只是写理论,其实意义不大。概念解释现在交给 AI 就够了,真正有价值的是:在一个具体项目里,遇到问题、拆问题、再一点点把链路跑起来。

所以我换了个写法。先不急着在 my-resume 正式项目里大改,而是单独开了一个实验仓,拿一份 Markdown 简历练手:

resume-memory-rag-qa

刚好 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
  • 工作经历 / 项目经历 → 拆成 summarydetail

这个设计在代码里是这样落的:

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