什么是 RAG?如何用 RAG 实现一个用户记忆?

RAG 全名叫 Retrieval-Augmented Generation,三个单词翻译过来分别是检索、增强、生成,SOP 就是

  1. 先从资料库里检索(Retrieval)相关内容
  2. 再基于这些内容来生成(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_memoryRAGsystemPrompt 上的位置。

RAG 的基本运行流程

那么问题来了,生产环境怎么落地 RAG

之前在背景里已经讲过,跨对话 用到 RAG,对应它的流程就是这样

  1. 旧对话请求结束:分片 -> 索引
  2. 新对话请求开始:召回 -> 重排 -> 生成

有点事后诸葛亮,但实际上就是这样,AI 嘎嘎写,但是分享的时候要符合大部分人的"刻板"印象,比如教科书级别的流程

分片

在对话结束后,会把 10message 写个胶水代码丢给 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 做,有三个原因

  1. 我们是菜鸡,不知道怎么分片合理,只会调教 prompt
  2. 公司有钱,GLM token 随便用
  3. 对话结束后能够以异步任务执行,不会影响到对话的速度

因此,从减少维护工作量这一块,丢给了 GLM 分片

索引

索引这块较为简单,一共干两件事

  1. 通过 Embedding 将片段文本转换为向量
  2. 将片段文本和片段向量存入向量数据库中

我们通过智谱的 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 里混排两路:

  1. 向量pgvector<=> 是余弦距离,1 - (embedding <=> queryVec) 转成余弦相似度,越大越像。不用欧氏,也不用点积。
  2. 全文ts_rank(fts, query)ftscontent 生成,词对不对得上

默认 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 = 30OVER_FETCH_FACTOR),混排分排完后,本地做两步启发式:

  1. 时间衰减score *= 2^(-天数 / 30)identity / preference 标了 is_evergreen,不衰减------三个月前的技术栈还该在,三个月前的「往左移一点」该降权。
  2. MMRMaximal 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_atis_evergreenMMR 吃的是各条之间的 embedding 余弦,都不读 content 文本content 在召回的全文里已经用过了。

为什么不上 cross-encoder

因为延迟 ,我们的重排发生在新对话请求开始 ,不像分片、索引用异步任务,再加一次 rerank 推理会直接拉长用户等待,而且我们的 TopK10,本地计算梭哈就完事了

生成

重排完的 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 输出纯属虚构,如有雷同实属巧合

时序图

sequenceDiagram autonumber actor User participant Agent participant GLM as GLM 分片 participant Embed as embedding-3 participant DB as PostgreSQL + pgvector participant Main as GLM Note over User,Main: 旧对话请求结束:分片 → 索引 User->>Agent: 对话结束 onFinish Agent->>Agent: 取最近 10 条 message Agent->>GLM: generateText(extraction prompt) GLM-->>Agent: JSON 数组(0~N 条记忆) Agent->>DB: INSERT content / category / is_evergreen Agent->>Embed: embedText(content) Embed-->>Agent: vector(1024) Agent->>DB: UPDATE embedding<br/>fts 由 content 生成 Note over User,Main: 新对话请求开始:召回 → 重排 → 生成 User->>Agent: 新的 user prompt Agent->>Agent: extractUserPromptText → query Agent->>Embed: embedText(query) Embed-->>Agent: queryVec Agent->>DB: 余弦 <=> + ts_rank 混排<br/>0.7 向量 + 0.3 全文<br/>LIMIT 30 DB-->>Agent: 30 条候选 Agent->>Agent: 时间衰减 + MMR → TopK=10 Agent->>Agent: 拼 [RUSH_MEMORY] 进 systemPrompt Agent->>Main: system + messages Main-->>User: 生成回复

参考文档

  1. RAG 工作机制详解------一个高质量知识库背后的技术全流程 - 马克的技术工坊 - bilibili
  2. pgvector - github
相关推荐
面向Google编程1 小时前
Apache Iceberg Variant 类型:v3 半结构化数据不再「二选一」
大数据·后端
沧沧凉凉1 小时前
同一个 Blender 建模,Claude 两个模型都翻车,GPT-6 一次过
人工智能·游戏·ai编程
passerby60611 小时前
如何自己造一个时间处理库
前端·javascript·github
jason成都2 小时前
Spring WebFlux 适配达梦新方案|dm‑r2dbc:Netty 异步传输的实验性 R2DBC 驱动
java·后端·spring
走到天涯海角2 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!2 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰3 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
Cx330❀3 小时前
【Linux网络】网络层协议 IP :从网络层原理到 Linux 内核源码
linux·运维·服务器·网络·tcp/ip·ai·ai编程
stormzhangV4 小时前
DeepSeek 降价,扩招 150 人
openai·ai编程