用 TypeScript + PostgreSQL + pgvector 从零搭建一套完整 RAG 系统的实战笔记。涵盖 Embedding 原理、Chunking 策略、向量检索、HNSW 索引、防幻觉 Prompt、Hybrid Search 与 Reranker。看完能独立实现一个生产可用的检索增强问答系统。
目录
- 一、RAG 是什么
- 二、Embedding:把语义变成向量
- 三、pgvector:让 PostgreSQL 存向量
- 四、Chunking:长文档切分策略
- 五、HNSW:向量检索索引
- 六、完整 RAG 端到端
- 七、防幻觉 Prompt 设计
- 八、Hybrid Search:向量 + 全文搜索
- 九、RRF 融合算法
- 十、Reranker:粗排到精排
- 十一、常见坑与排查
- 十二、技术选型与延伸
一、RAG 是什么
RAG = Retrieval-Augmented Generation(检索增强生成)。一句话:LLM 不知道的事,先从知识库检索相关内容,再喂给 LLM 让它基于材料回答。
为什么需要 RAG
LLM 单独使用有几个硬伤:
- 知识截止到训练时间,不知道最新信息
- 不知道你的私有数据(内部文档、专业知识库)
- 会幻觉,即"自信地编造"
- 微调(fine-tune)成本高,且无法即时更新
RAG 给 LLM 装了一个"外挂知识库",比微调便宜得多,且知识库随时可更新。
RAG 的两个阶段
makefile
离线阶段(灌库):
原始文档 → Chunking 切分 → Embedding 向量化 → 存入数据库
在线阶段(检索 + 生成):
用户提问 → Query 向量化 → 检索 top-K → 拼 Prompt → LLM 生成答案
二、Embedding:把语义变成向量
核心原理
Embedding 模型把一段文本转换成一个定长的数字数组(向量),关键特性是:语义相近的文本,向量在空间中距离也近。
scss
"什么是合同?" → [0.12, -0.34, 0.56, ..., 0.89] (1024 维)
"合同的定义是什么?" → [0.13, -0.33, 0.57, ..., 0.88] (1024 维)
↑ 两个向量很接近
这样,判断"两段文字是否相关"就变成了"计算两个向量的距离"。这是语义搜索的底层。
余弦相似度
衡量两个向量距离最常用的是余弦相似度:
- 值接近 1:语义高度相近
- 值接近 0:基本无关
- 值为负:语义相反
实测中,同义句的相似度约 0.85+,完全无关的句子约 0.3 以下。
主流 Embedding 模型
| 模型 | 维度 | 特点 |
|---|---|---|
| OpenAI text-embedding-3-small | 1536 | 主流,便宜 |
| OpenAI text-embedding-3-large | 3072 | 更准,贵 6 倍 |
| 智谱 embedding-2 | 1024 | 国内可用,中文好 |
| BGE 系列 | 768/1024 | 开源,可本地部署 |
TypeScript 实现
调用 Embedding API 的核心代码。注意:有些厂商虽然声称"OpenAI 兼容",但 SDK 解析可能有偏差,直接用 fetch 更稳:
typescript
const ZHIPU_BASE_URL = 'https://open.bigmodel.cn/api/paas/v4'
export async function createEmbedding(text: string): Promise<number[]> {
const apiKey = process.env.ZHIPU_API_KEY
if (!apiKey) throw new Error('API key not set')
const response = await fetch(`${ZHIPU_BASE_URL}/embeddings`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'embedding-2',
input: text,
}),
})
if (!response.ok) {
throw new Error(`Embedding API error: ${response.status}`)
}
const data = await response.json()
if (!data.data?.[0]?.embedding) {
throw new Error('Invalid embedding response')
}
return data.data[0].embedding
}
经验 :接入任何新的 LLM/Embedding API,第一步先 console.log 打印完整响应,确认字段结构,再写解析代码。"OpenAI 兼容"不等于 100% 一致。
三、pgvector:让 PostgreSQL 存向量
pgvector 是什么
pgvector 是 PostgreSQL 的扩展,装上之后 PostgreSQL 就能:
- 存储向量字段(
vector类型) - 计算向量距离(
<->、<=>、<#>操作符) - 用向量索引加速检索(HNSW / IVFFlat)
这是"AI 时代为什么选 PostgreSQL"的核心理由:一个数据库同时搞定关系数据、JSON、全文搜索和向量,不用额外搭专门的向量数据库。
启用扩展
用预装 pgvector 的 Docker 镜像 pgvector/pgvector:pg16,然后:
sql
CREATE EXTENSION IF NOT EXISTS vector;
Drizzle Schema 定义
css
import { pgTable, uuid, varchar, text, timestamp, vector } from 'drizzle-orm/pg-core'
export const documents = pgTable('documents', {
id: uuid('id').defaultRandom().primaryKey(),
title: varchar('title', { length: 200 }).notNull(),
source: varchar('source', { length: 200 }),
content: text('content').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
})
三种距离操作符
| 操作符 | 含义 | 适用场景 |
|---|---|---|
<-> |
欧几里得距离 (L2) | 通用向量 |
<=> |
余弦距离 | 文本语义搜索(主流) |
<#> |
内积 | 性能优先 |
用 Drizzle 原生 API 做检索
不要手写 raw SQL,Drizzle 提供了 cosineDistance 原生函数:
typescript
import { cosineDistance, sql, desc } from 'drizzle-orm'
const queryEmbedding = await createEmbedding(query)
// distance: 0=最近, similarity = 1 - distance
const similarity = sql<number>`1 - (${cosineDistance(chunks.embedding, queryEmbedding)})`
const results = await db
.select({
content: chunks.content,
similarity,
})
.from(chunks)
.orderBy(desc(similarity))
.limit(5)
对应的还有 l2Distance 和 innerProduct。用原生 API 而非 raw SQL,能保留完整 TypeScript 类型推导,代码也更清晰。
四、Chunking:长文档切分策略
为什么要切分
真实文档动辄几万字,不能整篇 Embedding:
- 超 token 限制:Embedding 模型一次最多处理约 8K token
- 语义被平均:整篇文档的向量会把所有主题混在一起,失去精确检索能力
- 检索后太大:即使匹配上,整篇喂给 LLM 会超长且浪费 token
核心思想:让检索的颗粒度匹配用户问题的颗粒度。
三种切分策略
策略 1:固定字符切分
每 N 个字符切一段,简单粗暴。优点是长度可预测,缺点是可能在句子中间切断。
策略 2:段落切分
按段落分隔符(双换行)切。优点是保持段落完整,缺点是段落长度差异大。
策略 3:递归切分(主流)
按"分隔符层级"递归切:优先用大分隔符(段落),某段还太大就用下一级分隔符(句号、逗号),最后兜底固定切。这是 LangChain / LlamaIndex 的默认策略。
scss
export function chunkByRecursive(text: string, opts: ChunkOptions = {}): Chunk[] {
const { chunkSize, overlap } = validateOptions(opts)
if (text.length === 0) return []
// 分隔符按语义大小排序
const separators = ['\n\n', '\n', '。', '!', '?', ',', ' ', '']
const splits = recursiveSplit(text, separators, chunkSize)
// 合并切片 + 加 overlap
const chunks: Chunk[] = []
for (let i = 0; i < splits.length; i++) {
let content = splits[i]
if (i > 0 && overlap > 0) {
content = splits[i - 1].slice(-overlap) + content
}
chunks.push({ content, index: i, charCount: content.length })
}
return chunks
}
关键参数
- chunkSize:目标长度,典型 200-1000 字
- overlap:相邻 chunk 的重叠长度,典型为 chunkSize 的 10-20%,作用是避免关键句被切断
- separators:分隔符层级
防御性编程的重要性
切分逻辑里有一个隐蔽的死循环陷阱:如果 overlap >= chunkSize,每次循环的步长会变成 0 或负数,导致无限循环、内存暴涨、进程 OOM 崩溃。
必须加参数校验:
javascript
function validateOptions(opts: ChunkOptions): Required<ChunkOptions> {
const chunkSize = opts.chunkSize ?? 500
const overlap = opts.overlap ?? 50
if (chunkSize <= 0) throw new Error('chunkSize 必须大于 0')
if (overlap < 0) throw new Error('overlap 不能为负')
if (overlap >= chunkSize) {
throw new Error('overlap 必须小于 chunkSize,否则会死循环')
}
return { chunkSize, overlap }
}
看到 JavaScript heap out of memory,优先怀疑死循环。
五、HNSW:向量检索索引
为什么需要索引
没有索引时,向量检索是暴力扫描:对每条数据计算与 query 的距离再排序,复杂度 O(N)。
yaml
1 万条: 暴力 ~100ms
100 万条: 暴力 ~10s ← 用户无法忍受
1000 万条: 暴力 ~100s
HNSW 是什么
HNSW(Hierarchical Navigable Small World,分层导航小世界图)是一种近似最近邻算法。类似图书馆的分层目录,搜索时跳跃式接近目标,复杂度从 O(N) 降到 O(log N)。
代价:
- 索引占用额外空间(约数据量的 1-2 倍)
- 写入稍慢
- 是近似查询,约 1% 的概率漏掉真正的最近邻
对 RAG 来说,top-K 检索损失 1% 精度完全可接受。
Drizzle 里建 HNSW 索引
注意用 Drizzle 当前推荐的"数组"写法((table) => [...]),旧的"对象"写法已弃用:
css
import { pgTable, ..., index } from 'drizzle-orm/pg-core'
export const chunks = pgTable(
'chunks',
{
id: uuid('id').defaultRandom().primaryKey(),
documentId: uuid('document_id')
.notNull()
.references(() => documents.id, { onDelete: 'cascade' }),
chunkIndex: integer('chunk_index').notNull(),
content: text('content').notNull(),
embedding: vector('embedding', { dimensions: 1024 }),
createdAt: timestamp('created_at').defaultNow().notNull(),
},
(table) => [ index('chunks_embedding_idx').using( 'hnsw', table.embedding.op('vector_cosine_ops') ), ]
)
vector_cosine_ops 对应余弦距离,要和检索时用的操作符一致。
索引的通用认知
索引不是向量专属概念,是数据库通用能力。该加索引的字段:经常出现在 WHERE、JOIN、ORDER BY 的字段,外键,以及向量字段。验证索引是否生效用 EXPLAIN ANALYZE,看到 Index Scan 是走了索引,Seq Scan 是全表扫描。
六、完整 RAG 端到端
把前面的零件组装起来,就是完整 RAG。核心三个函数:检索、拼 Prompt、生成。
php
// 1. 检索:query 转向量,查 top-K 相似 chunks
export async function retrieveChunks(query: string, topK = 5) {
const queryEmbedding = await createEmbedding(query)
const similarity = sql<number>`1 - (${cosineDistance(chunks.embedding, queryEmbedding)})`
return db
.select({
content: chunks.content,
similarity,
documentTitle: documents.title,
documentSource: documents.source,
})
.from(chunks)
.innerJoin(documents, eq(chunks.documentId, documents.id))
.orderBy(desc(similarity))
.limit(topK)
}
// 2. 完整流程:检索 + 生成
export async function ragAsk(question: string, topK = 5) {
const retrievedChunks = await retrieveChunks(question, topK)
const systemPrompt = buildRAGPrompt(retrievedChunks)
const response = await llm.chat.completions.create({
model: 'glm-4-flash',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: question },
],
temperature: 0.1, // 低温度,减少创造性,提高严谨性
})
return {
question,
answer: response.choices[0]?.message.content || '',
sources: retrievedChunks,
}
}
temperature: 0.1 是关键:RAG 场景要的是"基于材料严谨回答",不是"创造性发挥",所以用接近 0 的温度。
七、防幻觉 Prompt 设计
为什么 Prompt 是 RAG 的核心
很多人以为 RAG 就是"接 Embedding API + 接 LLM API"。实际上生产中:约 50% 时间在调 chunking 策略,约 30% 在调 Prompt。
即使把检索到的材料喂给了 LLM,如果 Prompt 写得弱,LLM 仍可能"无视材料,用自己的知识回答",照样幻觉。
弱 Prompt vs 强 Prompt
弱 Prompt:"你是法律助手,这是参考材料,请回答。" ------ LLM 会"参考"材料,但仍可能补充自己的知识。
强 Prompt 要用明确的强约束:
javascript
function buildRAGPrompt(retrievedChunks: RetrievedChunk[]): string {
if (retrievedChunks.length === 0) {
return `你是法律助手。当前没有找到相关参考材料,
请直接告诉用户"没有找到相关法律依据",不要编造内容。`
}
const materials = retrievedChunks
.map((c, i) => `[材料 ${i + 1}] 来源:${c.documentSource}\n${c.content}`)
.join('\n\n')
return `你是一个严谨的法律助手。你必须严格遵守以下规则:
1. 只基于下面提供的"参考材料"回答用户问题
2. 如果材料中没有答案,明确说"参考材料中未涉及此内容",不要用你自己的知识补充
3. 回答时必须标注引用的材料编号,格式如"[材料 1]"
4. 如果用户问题与材料无关,礼貌说明"此问题不在我的知识范围内"
【参考材料】
${materials}
请基于以上材料,用中文准确、简洁地回答用户问题。`
}
四个关键设计点
- 只用提供的材料:明确禁止用模型自己的知识
- 没答案就说不知道:给 LLM 一个"台阶",避免它为了回答而编造
- 强制引用来源 :输出里标注
[材料 N],实现可追溯 - 不相关就说不相关:处理 query 与知识库无关的情况
把 chunks 拼成带编号的 [材料 N] 格式,LLM 就能在回答里引用,用户能反查每句话的依据。
八、Hybrid Search:向量 + 全文搜索
纯向量的盲区
纯向量检索擅长语义匹配,但有明显盲区:
- 数字精确匹配("第 502 条"、日期、ID)
- 专有名词、产品型号、代码标识符
- 任何需要"字面命中"的场景
原因是 Embedding 关注语义,会把"502"这样的数字"模糊化"。
Hybrid Search 思路
两路检索并行,再融合:
scss
用户问题
│
┌─────┴─────┐
↓ ↓
向量检索 全文搜索
(语义相近) (字面精确)
│ │
└─────┬─────┘
↓
RRF 融合排序
↓
top-K
PostgreSQL 全文搜索 + 中文分词
英文有空格天然分词,中文需要分词器。PostgreSQL 原生对中文支持弱,一个简单实用的方案是 bigram(二元分词):
arduino
"违约责任" → "违约 约责 责任" (每相邻两字一组)
bigram 工具:
typescript
export function toBigrams(text: string): string {
const cleaned = text.replace(/[^\u4e00-\u9fa5a-zA-Z0-9]/g, '')
if (cleaned.length <= 1) return cleaned
const bigrams: string[] = []
for (let i = 0; i < cleaned.length - 1; i++) {
bigrams.push(cleaned.slice(i, i + 2))
}
return bigrams.join(' ')
}
// 把查询转成 tsquery 表达式,用 | (OR) 连接,提高召回
export function queryToTsquery(query: string, operator: '&' | '|' = '|'): string {
const bigrams = toBigrams(query).split(' ').filter(Boolean)
if (bigrams.length === 0) return ''
return bigrams.join(` ${operator} `)
}
Schema 里加 tsvector 字段(Drizzle 无原生类型,用 customType)和 GIN 索引:
typescript
const tsVector = customType<{ data: string }>({
dataType() {
return 'tsvector'
},
})
// chunks 表里加字段
contentTsv: tsVector('content_tsv'),
// 索引部分
index('chunks_content_tsv_idx').using('gin', table.contentTsv),
灌库时写入 tsvector(bigram 在 JS 端处理):
css
await db.insert(chunks).values({
content: chunk.content,
embedding,
contentTsv: sql`to_tsvector('simple', ${toBigrams(chunk.content)})`,
})
两路检索
csharp
// 路径 1:向量检索
const vectorResults = await db
.select({ /* ... */ })
.from(chunks)
.orderBy(desc(vectorSim))
.limit(poolSize)
// 路径 2:全文搜索
const tsqueryStr = queryToTsquery(query, '|')
const ftsResults = await db
.select({ /* ... */ })
.from(chunks)
.where(sql`${chunks.contentTsv} @@ to_tsquery('simple', ${tsqueryStr})`)
.orderBy(desc(ftsRank))
.limit(poolSize)
注意:用 to_tsquery + 手动 OR 连接,不要用 plainto_tsquery,后者会把空格当 AND,过于严格导致召回为 0。
九、RRF 融合算法
问题
两路检索各自有一批结果,但分数尺度不同:向量相似度是 0-1,全文搜索的 ts_rank 可能是 0.001-100。直接把分数相加会偏向某一路。
RRF 解法
RRF(Reciprocal Rank Fusion,倒数排名融合)只看排名,不看分数大小:
ini
score = 1 / (k + rank)
k = 经验常数(论文推荐 60)
rank = 在该路结果中的排名(1, 2, 3, ...)
关键效果:如果一个 chunk 在两路里都出现,两份得分相加,排名会显著上升。这正是 Hybrid 比单路强的原因------两路都认可的结果最可信。
实现
typescript
const RRF_K = 60
const scoreMap = new Map<string, FusedItem>()
// 累计向量排名得分
vectorResults.forEach((chunk, idx) => {
const score = 1 / (RRF_K + idx + 1)
scoreMap.set(chunk.chunkId, { chunk, score })
})
// 累计全文排名得分
ftsResults.forEach((chunk, idx) => {
const score = 1 / (RRF_K + idx + 1)
const existing = scoreMap.get(chunk.chunkId)
if (existing) {
existing.score += score // 两路都命中 → 相加
} else {
scoreMap.set(chunk.chunkId, { chunk, score })
}
})
// 按融合得分排序
const sorted = Array.from(scoreMap.values())
.sort((a, b) => b.score - a.score)
举例:某 chunk 在向量路排第 2、全文路排第 1,融合得分 = 1/62 + 1/61 ≈ 0.0325,比任何单路命中(约 0.016)都高,排名飙升。
十、Reranker:粗排到精排
为什么需要 Reranker
Embedding 检索有个固有问题:一段几百字的 chunk 被压缩成一个定长向量,细微的相关性差异在压缩中丢失了。
Reranker 是专门评估相关性的模型,工作方式不同:把 (query, chunk) 作为一对完整输入到模型,直接输出 0-1 的相关性分数。不压缩,所以更精准。
代价是慢------每个候选都要单独算一次。所以采用两阶段:
makefile
粗排:Hybrid Search 召回 top 20(快)
↓
精排:Reranker 对 20 个重新打分(准)
↓
取真正的 top 5
相关性准确率通常能提升 10-30%。
实现
typescript
export async function rerank(
query: string,
documents: string[],
topN = 5
): Promise<RerankResult[]> {
if (documents.length === 0) return []
const response = await fetch(`${ZHIPU_BASE_URL}/rerank`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZHIPU_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'rerank',
query,
documents,
top_n: Math.min(topN, documents.length),
}),
})
if (!response.ok) {
throw new Error(`Rerank API error: ${response.status}`)
}
const data = await response.json()
return data.results.map((r: any) => ({
index: r.index,
score: r.relevance_score,
}))
}
容错降级
Reranker 是外部服务,可能失败(未开通、欠费、网络问题)。生产代码要做降级,失败时退回 RRF 排序,而不是让整个请求崩溃:
javascript
let finalItems
try {
const rerankResults = await rerank(query, poolResults.map((p) => p.chunk.content), topK)
finalItems = rerankResults.map((r) => ({ item: poolResults[r.index], rerankScore: r.score }))
} catch (err) {
console.warn('Reranker 失败,降级为 RRF 排序')
finalItems = poolResults.slice(0, topK).map((item) => ({ item }))
}
poolSize 怎么定
粗排召回数量(poolSize)太小,Reranker 没调整空间;太大则慢且贵。经验值:简单场景 20,复杂场景 50,极致场景 100。
十一、常见坑与排查
坑 1:第三方 API 静默失败
调用没报错,但返回的向量全是 0。排查方法:用 curl 绕过 SDK 直接打 API。如果 curl 正常而 SDK 不正常,就是 SDK 解析问题,改用 fetch。"OpenAI 兼容"不代表 100% 一致。
坑 2:死循环导致 OOM
Chunking 参数 overlap >= chunkSize 会让步长为 0,无限循环直到内存耗尽。看到 heap out of memory 优先怀疑死循环,加参数校验。
坑 3:ORM 的 SQL 片段不能复用
Drizzle 的 sql`...` 模板片段内部带参数占位符状态,当作变量复用多次会导致参数错乱。能用普通字符串/数字的地方就用普通值,SQL 片段尽量一次性使用。
坑 4:plainto_tsquery 过于严格
plainto_tsquery 把空格当 AND,要求所有词都命中,中文 bigram 场景下经常一条都匹配不上。改用 to_tsquery + 手动用 |(OR)连接。
坑 5:用户表达 ≠ 文档表达
这是 RAG 的经典难题,不是 bug。例如用户问"第 502 条"(阿拉伯数字),文档里是"第五百零二条"(中文数字),bigram 字面对不上,全文搜索召回 0。
解决方向:数字归一化、同义词扩展、用 LLM 做 query 改写。但注意:这种情况下向量检索通常仍能命中,因为向量理解语义------这恰好说明了 Hybrid 的价值:两路互补,各自的盲区由对方兜底。
排查方法论
"数据正常但功能不对"时,用二分法:先在数据库里手动执行 SQL。如果手动跑能出结果,说明数据和 SQL 语法没问题,问题在应用层代码;如果手动跑也不行,问题在数据或 SQL。
十二、技术选型与延伸
中文全文搜索的几个档次
| 方案 | 分词质量 | 部署成本 | 适用 |
|---|---|---|---|
| bigram | 中(够用) | 零依赖 | 学习 / 中小项目 / RAG 辅助层 |
| zhparser / pg_jieba | 高 | 装 PG 扩展 | 自建 PG 的中型项目 |
| Elasticsearch | 很高 | 独立服务 | 搜索是核心的大项目 |
配合 Reranker 时,关键词层不需要很精确,bigram 通常够用。
向量数据库选型
中小规模(千万级以下)用 PostgreSQL + pgvector 足够,不用引入新组件。亿级数据量或需要分布式时,再考虑 Pinecone / Milvus 等专用向量数据库。
进一步优化方向
- HyDE:让 LLM 先生成一个"假答案",用假答案的向量去检索,缓解短 query 语义模糊问题
- Multi-Query:让 LLM 生成多个查询变体,分别检索后合并
- Query 改写:用 LLM 把口语化的用户问题改写成更接近文档表达的形式
- Evals:建立评估体系,科学地调 chunkSize、topK、poolSize 等参数
RAG 参数没有标准答案
chunkSize、overlap、topK、poolSize、RRF 的 k 值,都要根据真实数据和领域调整。法律文档、代码、小说的最优策略各不相同。这正是 RAG 工程师"调参"工作的核心,也是为什么需要 Evals 评估体系。
小结
一套生产级 RAG 的完整链路:
ruby
灌库:文档 → 递归 Chunking → Embedding → 存入 PostgreSQL(向量 + tsvector)
+ HNSW 索引 + GIN 索引
检索:Query → 向量检索 ┐
全文检索 ┘→ RRF 融合 → Reranker 精排 → top-K
生成:top-K → 防幻觉 Prompt(强约束 + 引用来源) → LLM → 带依据的答案
RAG 不是"接两个 API"那么简单。Chunking 策略、Hybrid 检索、融合算法、Reranker、Prompt 设计,每一环都有工程权衡。理解每个环节的原理和取舍,才算真正掌握 RAG。
参考资源
- pgvector:github.com/pgvector/pg...
- Drizzle ORM pgvector 指南:orm.drizzle.team/docs/guides...
- Drizzle ORM 全文搜索指南:orm.drizzle.team/docs/guides...
- RRF 论文:Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods