RAG 全名叫 Retrieval-Augmented Generation,三个单词翻译过来分别是检索、增强、生成,SOP 就是
- 先从资料库里检索(
Retrieval)相关内容 - 再基于这些内容来生成(
Generation)答案
能把它们串起来就是 Augmented
背景
说完了 RAG 的概念,就回到先有鸡还是先有蛋:我们为什么需要它?以 Vibe Coding 举个例子。
用户在平台上咔咔造,比如做一个 SVG 生成平台。聊着聊着他会甩出一些值得跨对话记住的话:
- 技术栈是 TypeScript
- 这个项目是「上预览、下代码、能导出」的 SVG 平台
- 后续 SVG 改动都打到
defaultSVG

下一轮请求来了,Agent 怎么知道这些?把整段对话、整个项目再读一遍当然行,但那不是记忆,那是每次都重跑一遍历史。
为什么用了 RAG?
实现过 Vibe Coding 的小伙伴都知道:虚拟机里 Agent 给用户造,数据多得不得了。记忆也会越积越多------身份、偏好、项目事实、做过的决策。
那为什么不把历史全塞进 systemPrompt?因为全量注入会把窗口打满,而且大部分内容和这一句无关。用户这轮只是说「再往左边移动一点」,你把三个月前的技术栈、公司名、十个项目的 context 一起倒进去,模型既贵又慢,还容易被噪声带跑。
所以才上 RAG。做法很朴素:对话结束后抽几条长期记忆写进 user_memories;下一轮还没开始干活 ,先按用户这句新问题去库里检索相关条目,塞进 systemPrompt 的 [RUSH_MEMORY] 块。模型看到的不是本轮对话的 76 条历史消息,而是几条和当前问题有关的记忆。
检索(Retrieval)→ 写进 prompt 增强这一轮(Augmented)→ 再生成回复(Generation)。窗口只涨几行,Agent 却能带着「这人用 TypeScript、在做 SVG 平台、改动打 defaultSVG」继续干。这就是 user_memory 里 RAG 在 systemPrompt 上的位置。

RAG 的基本运行流程
那么问题来了,生产环境怎么落地 RAG?
之前在背景里已经讲过,跨对话 用到 RAG,对应它的流程就是这样
- 旧对话请求结束:分片 -> 索引
- 新对话请求开始:召回 -> 重排 -> 生成
有点事后诸葛亮,但实际上就是这样,AI 嘎嘎写,但是分享的时候要符合大部分人的"刻板"印象,比如教科书级别的流程
分片
在对话结束后,会把 10 条 message 写个胶水代码丢给 GLM,结合你的 Prompt Engineering(说人话就是调 prompt 小子)

ts
import { generateText } from 'ai';
// 调教 prompt
const EXTRACTION_SYSTEM_PROMPT = `你是一个记忆提取助手。从对话中提取有长期价值的信息,输出 JSON 数组。
...`;
...
// 胶水代码
const conversationText = buildConversationText(messages);
// 分片
const { text } = await generateText({
model: createFastModel(),
system: EXTRACTION_SYSTEM_PROMPT,
prompt: conversationText,
});
我们的分片是交给了 GLM 做,有三个原因
- 我们是菜鸡,不知道怎么分片合理,只会调教
prompt - 公司有钱,
GLM token随便用 - 对话结束后能够以异步任务执行,不会影响到对话的速度
因此,从减少维护工作量这一块,丢给了 GLM 分片
索引
索引这块较为简单,一共干两件事
- 通过
Embedding将片段文本转换为向量 - 将片段文本和片段向量存入向量数据库中
我们通过智谱的 embedding-3 模型来进行转换
ts
function createEmbeddingModel() {
const zhipu = createZhipu({
apiKey: process.env.ZHIPU_EMBEDDING_API_KEY,
});
return zhipu.textEmbeddingModel('embedding-3', { dimensions: 1024 });
}
...
const _model = createEmbeddingModel();
...
export async function embedText(
...
try {
const result = await withTimeout(
embed({
model: _model,
value: text,
}),
...
关于向量的维度不过多赘述,项目中使用的是 1024 维向量,如果读者对向量维度很感兴趣,你们可以去了解下,然后再去做一道 1024D 接雨水
对于向量数据库其实不用接入,我们用的是 PostgreSQL,向量存在同一行的 embedding 列里,靠 pgvector 扩展,不是 Postgres 原生类型,无需再引入新的数据库
ts
export async function upsertMemory(
...
): Promise<DbUserMemory | null> {
const rows = withProjectId
? await rawQuery<DbUserMemory>(
`INSERT INTO user_memories (user_id, content, category, source, is_evergreen, project_id)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (user_id, md5(content))
DO UPDATE SET category = $3, source = $4, is_evergreen = $5, project_id = COALESCE($6, user_memories.project_id), updated_at = now()
RETURNING *`,
[userId, content, category, source, isEvergreen, safeProjectId]
)
...
updateEmbeddingAsync(memory.id, content);
...
}
function updateEmbeddingAsync(memoryId: string, content: string): void {
embedText(content)
.then(async (vec) => {
if (!vec) return;
const pgVec = `[${vec.join(',')}]`;
await rawQuery(
'UPDATE user_memories SET embedding = $1::vector WHERE id = $2',
[pgVec, memoryId]
);
})
...

召回
召回发生在下一轮还没开始干活 的时候:从最近这条用户消息抽出 query,去 user_memories 里找相关条目,准备塞进 systemPrompt。其它 conversation 里你到底干了啥,靠的就是这一跳。 
胶水代码比分片还短:当前用户那句话当 query,limit=10(即 TopK)丢进混合搜索。
ts
const query = extractUserPromptText(messages) ?? '';
const results = await hybridSearchMemories({
userId,
query,
limit: 10,
});
hybridSearchMemories 里先把这句 query 再走一遍 embedding-3(和索引是同一套模型(即 embedText),维度才能对齐)
ts
export function createHybridSearcher<T extends ScoredRow>(
config: HybridSearchConfig
) {
...
return async function search(opts: SearchInput): Promise<T[]> {
...
const queryVec = await embedText(query);
}
}
然后在 PostgreSQL 里混排两路:
- 向量 :
pgvector的<=>是余弦距离,1 - (embedding <=> queryVec)转成余弦相似度,越大越像。不用欧氏,也不用点积。 - 全文 :
ts_rank(fts, query),fts从content生成,词对不对得上
默认 0.7 向量 + 0.3 全文,ORDER BY score DESC
ts
function vectorAndFtsSearchGeneric<T extends ScoredRow>(
...
): Promise<T[]> {
const pgVec = `[${queryVec.join(',')}]`;
...
const params: unknown[] = category
? [pgVec, ownerId, limit, queryText, vectorWeight, ftsWeight, category]
: [pgVec, ownerId, limit, queryText, vectorWeight, ftsWeight];
const sql = `
SELECT *,
COALESCE(1 - (embedding <=> $1::vector), 0) AS vec_score,
ts_rank(fts, plainto_tsquery('simple', $4)) AS fts_score,
($5::float8 * COALESCE(1 - (embedding <=> $1::vector), 0)
+ $6::float8 * ts_rank(fts, plainto_tsquery('simple', $4))) AS score
...
`;
重排
教科书里的 rerank 经常是 cross-encoder:query 和每条候选拼成一对,再过一遍模型打分,但是我们没上
hybridSearchMemories 里 SQL 实际 LIMIT 的是 10 × 3 = 30(OVER_FETCH_FACTOR),混排分排完后,本地做两步启发式:
- 时间衰减 :
score *= 2^(-天数 / 30)。identity/preference标了is_evergreen,不衰减------三个月前的技术栈还该在,三个月前的「往左移一点」该降权。 - MMR (
Maximal Marginal Relevance):从30条里贪心挑10条。相关性和「跟已选条目太像」做权衡,避免10条全是同一句话的变体。
ts
const queryVec = await embedText(query);
const fetchLimit = limit * OVER_FETCH_FACTOR; // 10 * 3 = 30
// ... 向量 + 全文混排 ...
const decayed = applyTimeDecay(rows, decayHalfLifeDays);
return applyMMR(decayed, limit, DEFAULT_MMR_LAMBDA);
时间衰减吃的是 updated_at 和 is_evergreen,MMR 吃的是各条之间的 embedding 余弦,都不读 content 文本 。content 在召回的全文里已经用过了。
为什么不上 cross-encoder?
因为延迟 ,我们的重排发生在新对话请求开始 ,不像分片、索引用异步任务,再加一次 rerank 推理会直接拉长用户等待,而且我们的 TopK 是 10,本地计算梭哈就完事了
生成
重排完的 10 条,拼成 [RUSH_MEMORY] 块,接到 systemPrompt 后面。窗口只涨几行,但优先级很高,不会被 userPrompt 干扰,主模型可以带着这些记忆去干活------这就是 Augmented 之后的 Generation
ts
if (results.length > 0) {
const items = results
.map((r) => `- [${r.category}] ${r.content}`)
.join('\n');
return `[RUSH_MEMORY]\nRelevant memories (${results.length} of ${count} total):\n\n${items}\n[/RUSH_MEMORY]`;
}
ts
effectiveSystem = `${effectiveSystem}\n\n${memoryBlock}`;
GLM 的情绪输出如下
如果是 Grok 可能是😂:
Grok 输出纯属虚构,如有雷同实属巧合