RAG 解决的问题

LLM 训练数据有 cutoff,它不知道你公司的代码库、客户工单、产品文档、合同条款。要让它知道,有两条路:

  1. Fine-tuning:把数据训进模型权重(贵、慢、改一次重训一次)
  2. RAG (Retrieval-Augmented Generation):每次提问时先去外部存储找相关内容,把找到的塞进 prompt

99% 场景选 RAG。理由:实时(数据改了立刻生效)、可控(哪条信息回答的可追溯)、便宜(不用 GPU 训练)、能权限隔离(用户 A 看不到用户 B 数据)。

RAG 的最小流程

┌─────────┐         ┌──────────┐         ┌──────────┐
│ User Q  │ ──1──▶  │ Embedding│ ──2──▶  │ Vector DB│
└─────────┘         └──────────┘         └──────────┘
                                              │ 3 (top-k)
                                              ▼
                  ┌────────────┐         ┌──────────┐
                  │ Final ans  │ ◀──5──  │   LLM    │ ◀──4── retrieved chunks + Q
                  └────────────┘         └──────────┘
  1. 用户提问转成向量
  2. 在向量库里搜相似度最高的 top-k 文档片段
  3. 拼成 prompt(基于以下资料回答:[chunks]\n用户问:[Q]
  4. 喂给 LLM 生成最终答案
  5. 返回给用户

听起来 5 步很简单。但每一步细节都能让你 RAG 翻车——下面挨个拆。

Step 1:把文档切成 Chunks(最容易翻车的一步)

向量库存的不是整本文档,是文档切片(chunks)。切的好搜得准,切的烂答非所问。

三种切法

策略怎么切适合场景翻车点
固定大小每 500 token 切一块长一致风格的文档切断句子 / 切断代码块
按结构(Markdown / HTML)按 H1/H2/H3 切结构化文档section 太长就废
递归切(langchain RecursiveCharacterTextSplitter)先按章节,章节太大再按段落,再按句子大部分场景默认调参数烦

黄金参数(默认起手)

chunk_size = 1000       # 每块约 1000 字符 ≈ 250 token
chunk_overlap = 200     # 相邻块重叠 200 字符(防切断关键信息)

Overlap 必须有。如果一个事实横跨切片边界,没 overlap 就两块都答不全。20% overlap 是经验值。

代码(递归切)

import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';

const splitter = new RecursiveCharacterTextSplitter({
  chunkSize: 1000,
  chunkOverlap: 200,
  separators: ['\n\n', '\n', '。', '. ', ' ', ''] // 中英文混合
});

const docs = await splitter.createDocuments([fullText]);
// docs = [{ pageContent: "...", metadata: {} }, ...]

进阶:保留 metadata

每个 chunk 必须带 metadata:来源文件、页码、章节、updated_at、access_level。这些之后会救命

{
  pageContent: "Claude Sonnet 4.6 的 input 价格是 $3/M tokens...",
  metadata: {
    source: "anthropic-pricing.md",
    section: "Sonnet pricing",
    last_updated: "2026-04-01",
    access_level: "public"
  }
}

Step 2:把 Chunk 转成向量(Embedding)

Embedding model 把一段文字映射成一个固定维度的向量(比如 1536 维),语义相近的文字在向量空间里距离近

主流 embedding model(2026)

ModelDimensions$/1M tokens备注
OpenAI text-embedding-3-large3072$0.13综合最强、最常用
OpenAI text-embedding-3-small1536$0.02性价比版
Cohere embed-multilingual-v31024$0.10多语言好
Voyage voyage-31024$0.06Anthropic 推荐
BGE-M3 (开源)1024自部署中文好、可私有化

默认起手用 text-embedding-3-small:便宜、够好、生态最熟。中文重的项目可以换 BGE-M3 或 Voyage-3。

import OpenAI from 'openai';
const openai = new OpenAI();

const res = await openai.embeddings.create({
  model: 'text-embedding-3-small',
  input: ['Claude Sonnet 4.6 的价格是...'],
});

const embedding = res.data[0].embedding; // [0.012, -0.045, ...] 1536 维

黄金规则:query 和 doc 用同一个 model

把文档 embedding 用 model A,查询时 embedding 用 model B —— 距离计算完全没意义。听起来废话但生产环境常见 bug,特别是后期想换 embedding model 时必须重新 embed 全部文档

Step 3:存进向量库

向量库主打适合
pgvector(PostgreSQL 插件)不引入新 infra已经在用 Postgres 的项目,<10M chunks
Pinecone全托管、成熟不想运维、预算够
Weaviate自托管 + GraphQL中型团队、需要混合查询
Qdrant速度快、Rust 写的高 QPS、关心延迟
Chroma嵌入式、文件存储本地开发、demo
MongoDB Atlas Vector SearchMongoDB 原生已经 mongo 的项目(如匠人学院 )

匠人内部 RAG 实际就用 MongoDB Atlas Vector Search——已经有 MongoDB 不想引入 Pinecone。pgvector 是另一个推荐,特别是 < 1M chunks。

pgvector 例子

-- 建表
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE doc_chunks (
  id BIGSERIAL PRIMARY KEY,
  content TEXT NOT NULL,
  embedding vector(1536),
  source TEXT,
  section TEXT,
  access_level TEXT,
  created_at TIMESTAMP DEFAULT now()
);

-- 建 HNSW 索引(百万级数据必备)
CREATE INDEX ON doc_chunks USING hnsw (embedding vector_cosine_ops);

-- 查询
SELECT content, source, 1 - (embedding <=> $1) AS similarity
FROM doc_chunks
WHERE access_level = 'public'    -- 权限过滤
ORDER BY embedding <=> $1         -- cosine distance
LIMIT 5;

<=> 是 pgvector 的 cosine distance 操作符。1 - distance 就是相似度(0-1)。

Step 4:检索 + 融合(最影响效果的一步)

Naive RAG 就是"top-5 相似度最高的 chunks 塞 prompt"。但真实数据下这个简单方案准确率 60-70%,离生产可用还远。

提升准确率的三连击

a. Hybrid Search(向量 + 关键词)

向量搜偏语义,关键词搜(BM25)偏精确匹配。两个一起用,融合 score

Query: "Claude API 限流是多少 RPM?"

向量搜:找到 "Anthropic 速率限制说明..." (语义匹配)
BM25 搜:找到 "Claude API rate limit RPM..." (词匹配)

融合后 top-5 比单独哪个都好。

实现:用 Reciprocal Rank Fusion (RRF) 算法,不用调参:

def rrf_merge(vector_results, keyword_results, k=60):
    scores = {}
    for rank, doc in enumerate(vector_results):
        scores[doc.id] = scores.get(doc.id, 0) + 1 / (k + rank)
    for rank, doc in enumerate(keyword_results):
        scores[doc.id] = scores.get(doc.id, 0) + 1 / (k + rank)
    return sorted(scores.items(), key=lambda x: -x[1])

匠人简历 / 工单 RAG 用 hybrid 后准确率从 ~70% → ~88%,没换模型没换 embedding。

b. Reranking(用专门 model 把 top-50 重排成 top-5)

第一次检索用 embedding 召回 top-50(recall 优先),然后用 reranker 模型给这 50 个重新打分挑出真正相关的 top-5。

Reranker价格备注
Cohere rerank-3$1/1K queries最常用
Voyage rerank-2$0.05/1M tokens性价比
BGE-reranker-v2 (开源)自部署中文好
import { CohereClient } from 'cohere-ai';
const cohere = new CohereClient();

const reranked = await cohere.v2.rerank({
  model: 'rerank-3',
  query: userQuery,
  documents: top50Chunks.map(c => c.content),
  topN: 5
});

Reranker 是 RAG 准确率的另一个 step function。从 88% 再提到 ~94%。

c. Query Rewriting(用 LLM 改写 query 再搜)

用户问 "我能用 Claude 写论文吗?" → embedding 出来不一定匹配你库里的 "学术使用条款"。让 LLM 先改写:

原 query: 我能用 Claude 写论文吗?
改写后:
- Claude 学术写作政策
- Claude 论文使用条款
- 用 Claude 生成 academic 内容是否允许

3 个改写后的 query 各搜一次,结果合并。这一招对中文 RAG 提升尤其大(中文 embedding 比英文略弱)。

Step 5:把检索结果塞 Prompt 喂 LLM

你是匠人学院的客服。基于以下资料回答用户问题。
如果资料里没有相关信息,**直接说"这个问题我没有资料"**,不要瞎编。

资料:
[chunk 1 — 来源: pricing-faq.md]
...

[chunk 2 — 来源: refund-policy.md]
...

用户问题:{user_query}

两个生死线

  1. 必须给"不知道时怎么办"的指令。不写的话模型会硬编。
  2. 必须带 source attribution。chunk 头部加来源标识,让模型在答案里能引用 → 用户能验证。

匠人简历功能的真实 prompt 长这样(精简版):

你是简历优化助手。基于以下匠人内部规范回答用户问题。

规范:
[chunk 1 — 来源:resume-format-guide.md]
...

[chunk 2 — 来源:australian-resume-best-practices.md]
...

用户简历摘要:
{resume_summary}

用户问题:
{user_query}

要求:
1. 答案必须基于上面规范,不要编造
2. 每个建议后用 [来源: filename] 标注
3. 如果规范里没说,回复"匠人规范暂无该项明确建议"

一个完整的 RAG 系统架构

┌──────────────┐      ┌──────────────┐      ┌──────────────┐
│ Documents    │─────▶│ Ingestion    │─────▶│ Vector DB +  │
│ (PDF/MD/Web) │      │ (split + embed)     │ Postgres BM25│
└──────────────┘      └──────────────┘      └──────┬───────┘
                                                    │
┌──────────────┐                                    │
│ User Query   │──┬─Vector ────┐    ┌─Reranker─┐   │
└──────────────┘  ├─BM25      ─┼───▶│ top-5    │◀──┘
                  └─Rewrite ───┘    └────┬─────┘
                                         │
                                  ┌──────▼──────┐
                                  │ LLM + Prompt│
                                  └──────┬──────┘
                                         │
                                  ┌──────▼──────┐
                                  │ Final Ans   │
                                  └─────────────┘

匠人内部知识库 RAG 就是这个架构,跑了一年多,每天 1000+ 查询,准确率稳定在 90%+。

RAG 翻车清单(生产事故复盘)

症状真因修法
查询返回相似但答非所问chunk 太大或没 overlap调 chunk_size + overlap
中文 query 召回质量差用了英文 embedding model换 BGE-M3 或 Voyage
经常返回旧信息metadata 没存 updated_at,没按时间排加 metadata,retrieval 时按时间排
答案权威性不够没 source attributionprompt 里强制要求带 [来源: ...]
用户 A 看到 B 数据没在 retrieval 加 access_level filter必须按权限过滤
上下文窗口爆top-k 设太大 / chunks 太长减 top-k 或开 reranker 后只取 5

本章小结

  • RAG = embed → store → retrieve → augment prompt → generate,5 步缺一不可
  • chunk 切法决定召回上限,hybrid search + reranker 决定准确率上限
  • 必须存 metadata(source / access_level / updated_at)+ 强制 source attribution
  • 默认起手:text-embedding-3-small + pgvector + recursive splitter(1000/200)+ Cohere rerank-3
  • prompt 必须给"不知道时回复"的兜底指令

下一章进 Agent + Tool Use——让 LLM 不只是回答,而是去执行动作

本章目录
    Lightman Wang
    Reviewer: Lightman WangFounder of JR Academy