向量数据库PGVector在这里插入代码片
PGVector 是 PostgreSQL 的向量扩展,让 PostgreSQL 直接支持向量存储和相似度搜索。
PGVector 给 PostgreSQL 加了一个新的数据类型 VECTOR,可以在普通的表里加一列向量,然后对这列向量做相似度搜索。
运算符 距离类型 适用场景
<-> 欧氏距离(L2) 坐标空间,对向量幅度敏感
<=> 余弦距离 文本语义搜索首选,只看方向不看幅度
<#> 内积(负数) 向量已归一化时等效于余弦距离
文本 RAG 场景几乎都用余弦距离。原因是:不同长度的文本,Embedding 向量的幅度可能不同,但语义方向是相似的。余弦距离只看方向,不受幅度影响,更适合语义搜索。
什么是向量索引
没有索引时,向量搜索要把查询向量和库里每一条记录都算一次距离(全量扫描)。100 万条数据,就要算 100 万次距离。这是 O(n) 的复杂度,数据量大了根本跑不动。
向量索引通过构建特殊的数据结构,让搜索时只需要计算一小部分候选向量的距离,大幅降低计算量。代价是:不保证找到"最精确"的结果,只保证找到"足够好"的近似结果------即 ANN(近似最近邻)搜索。
HNSW 构建了一个多层的图结构:
java
HNSW 索引结构(简化示意)
层 2(稀疏): A ←------------→ F ←------→ K
↑
层 1(中等): A ←------→ D ←------→ F ←------→ H ←------→ K
↑
层 0(密集): A-B-C-D-E-F-G-H-I-J-K(所有节点)
查询时从高层稀疏图开始找大方向,逐层往下精化,最终在底层找到精确候选。就像在地图上先定位到城市,再找街道,再找门牌号------高效且准确。
HNSW 的优点:
● 查询速度快,延迟低
● 召回率高(精度好)
● 支持增量插入(随时可以插入新数据,不需要重建索引)
HNSW 的缺点:
● 建索引较慢(但通常是一次性操作)
● 内存占用较大(索引结构要常驻内存)
大多数 RAG 场景推荐 HNSW。
IVFFlat(倒排文件 + 扁平量化)
IVFFlat 的思路是:先把向量空间划分成若干个"桶"(Cluster),每次查询只搜最近的几个桶,不搜全部。
IVFFlat 索引结构(简化示意)
java
向量空间
┌──────────────────────────────────┐
│ 桶1 桶2 桶3 桶4 桶5 │
│ ··· ··· ··· ··· ··· │
└──────────────────────────────────┘
↑
查询向量先判断属于哪个桶附近,只在附近桶里搜
IVFFlat 的优点:
● 建索引快
● 内存占用小
IVFFlat 的缺点:
● 查询速度相对较慢
● 召回率略低(向量被错分到错误桶的情况不可避免)
● 需要先有足够量的数据才能建索引(通常要求 > 1000 条,聚类才有意义)
适合 IVFFlat 的场景: 数据量百万级以上、批量入库(不是实时插入)、对内存比较敏感。
HNSW 的关键参数:m 和 ef_construction
HNSW 有两个建索引时的关键参数,影响精度和资源消耗的权衡。
参数 m:每个节点的最大连接数
m 决定了 HNSW 图的"密度"------每个节点最多连接几个邻居。
m 越大:图越密,搜索越准,但内存占用越大、建索引越慢
m 越小:内存省,但精度降低
通用推荐值:m = 16
什么时候调大 m?当你对召回率要求很高(比如法律文档检索,不能漏),可以考虑 m = 32 甚至 m = 64,代价是内存翻倍。
什么时候调小 m?内存极度紧张时,m = 8,精度会下降但速度更快。
参数 ef_construction:建索引时的搜索宽度
ef_construction 控制建索引时的精度------在往图里插入每个节点时,搜索多少候选邻居来决定最终连接。
ef_construction 越大:索引越精确,但建索引越慢
ef_construction 越小:建索引快,但索引质量略低
通用推荐值:ef_construction = 64
建议:这两个参数入门时不要纠结,16/64 就是很好的默认值。等到真正遇到性能瓶颈,再基于实际测量调整。
查询时的 ef_search:精度和速度的旋钮
建好索引后,查询时还有一个参数 ef_search,控制查询时搜索的候选数量。
ef_search 越大: 查询越准(召回率高),但耗时越长
ef_search 越小: 查询越快,但可能漏掉一些相关结果
默认值通常是 40。
这个参数的意义是:你可以在不重建索引的情况下,灵活地在"查询精度"和"查询速度"之间调整。
java
精度优先(比如法律条文检索): ef_search = 100-200
速度精度平衡(大多数场景): ef_search = 40-100 ← 默认值附近
速度优先(高并发低延迟): ef_search = 20-40
余弦距离 的范围是 0, 2,0 表示完全相同,2 表示完全相反。
但 RAG 框架里通常展示的是相似度分数,它是对余弦距离的转换:
java
相似度 = 1 - 余弦距离
完全相同 → 余弦距离 = 0 → 相似度 = 1.0
不相关 → 余弦距离 ≈ 1 → 相似度 ≈ 0.0
完全相反 → 余弦距离 = 2 → 相似度 = -1.0
PGVector 支持在向量搜索的同时按 Metadata 过滤。这个功能看起来简单,但设计得好可以大幅提升检索精度。
场景举例:
知识库里有:产品手册(category=manual)、FAQ(category=faq)、政策文档(category=policy)
用户提问:"退款要多久?"
普通向量搜索:可能找到来自产品手册的内容(里面也提到了退款)
加 Metadata 过滤:只在 category=faq 里搜,精准命中 FAQ 里的退款条款
Metadata 的设计直接影响过滤的粒度。鸡哥建议入库时至少标注:
{
"source": "文件名或 URL", // 来源
"category": "文档类别", // 分类(用于过滤)
"version": "版本号", // 如果有版本迭代
"upload_date": "入库时间" // 如果有时效要求
}
注意: Metadata 过滤和向量搜索是"先过滤后搜索"------先通过 Metadata 缩小候选集,再在候选集内搜向量。过滤条件越严格,候选集越小,TopK 能找到的结果就越少(甚至可能不足 K 个)。合理设计 Metadata 粒度很重要。
文档加载---多格式处理与 Metadata 设计
RAG 的第一步是把文档读进来。"读进来"听起来很简单,实际上有不少坑
不同格式的加载器选哪个
主流框架(Spring AI、LangChain4j、LlamaIndex)对常见格式都有现成的加载器。原生 PDF 是可以复制文字的,直接用文本提取器就行。扫描件是图片扫进去的,文字提取出来是空的或乱码------这是鸡哥见过的最高频问题之一,上来就说"向量搜不到",结果查半天发现文档本身就没内容。遇到这种情况需要先过一道 OCR。
pdf处理常见的几个陷阱
1.页眉页脚污染内容
处理方法:加载时设置顶部/底部边距(pageTopMargin / pageBottomMargin),跳过这些区域。70pt 是用得比较多的经验值,但具体要看文档的版式------有的文档页眉很高,有的很低,最好打开一两份实际文档量一下。
2.按页分还是按段分
按页加载:每页变成一个 Document,简单粗暴。优点是带页码 Metadata,方便溯源。缺点是一段话跨了两页,会被截断,语义完整性差。
按段落加载:尝试识别文档结构,把完整段落作为一个 Document。对结构清晰的 PDF 效果好;对格式混乱的 PDF(如合同 PDF、扫描转换的 PDF),段落识别不准,反而乱。
搜索的经验:产品手册、操作指南这类格式规整的文档用按段,合同、报告这类篇幅长、格式复杂的用按页,后面再靠分块策略来保证语义完整性。
Word / Excel 加载要知道的事
Word 提取文字内容相对稳定,但有几点要注意:
● 表格:Word 里的表格提取出来是纯文字,行列结构丢了。如果文档里有大量表格,提取后可读性很差,可能需要专门的表格解析处理
● 图片:图片直接忽略,图里的文字不会被提取(除非配 OCR)
● 页眉页脚:Word 的页眉页脚 Tika 一般会提取出来,需要清洗掉
Excel 最尴尬------它本质上是结构化数据,不是文档。Tika 提取出来是把所有单元格的文字拼在一起,表头和数据的对应关系全丢了。
鸡哥的建议:Excel 不要直接用通用文本加载器。如果 Excel 是知识表格(比如产品参数对比表),应该写专门的解析逻辑,把每行转成"字段名:值"的格式,这样向量化后语义更清晰。
如果 Excel 只是文字说明(纯文本填的 Excel),就随便了,Tika 直接读问题不大。
Metadata 设计------这里决定了你后面能不能过滤
java
Metadata 是 RAG 的"索引层",决定了:
1. 出了问题能不能追到来源
2. 检索时能不能按条件过滤(只搜这个部门的文档、只搜这个版本)
3. 多租户场景下能不能做数据隔离
java
推荐的 Metadata 字段设计
【必填字段】
filename 原始文件名 用于追溯来源,答案引用文件名
category 文档分类 后续检索时按分类过滤(如"产品手册"、"FAQ")
upload_time 入库时间 便于按时间过滤,排查过期内容
status 文档状态 active/inactive 软删除用,下线文档不从向量库删,只改状态
【多租户场景必填】
tenant_id 租户ID 隔离不同客户的数据,检索时加过滤条件
doc_id 文档唯一标识 同一文档的所有分块共用同一个 doc_id,
方便按文档粒度删除或更新
【可选字段】
department 归属部门
version 文档版本(v1、v2...)
author 文档作者
Metadata 设计的几个原则
原则一:提前想好过滤维度
原则二:doc_id 一定要加
原则三:status 字段做软删除
完整文档加载流程
java
文件上传
│
▼
【格式识别】根据文件后缀判断读取器类型
│
▼
【文档加载】调用对应读取器,提取文本内容
│
▼
【文档清洗】去除页眉页脚、多余空行、噪声字符
│
▼
【Metadata 注入】设置 filename/category/tenant_id/doc_id 等字段
│
▼
【分块】按语义或固定大小切分(下一节专讲)
│
▼
【向量化 + 入库】每个分块转成向量,写入向量库
│
▼
记录文档元数据到关系型数据库(方便管理和查询入库状态)
血泪大坑
坑一:上传了扫描件 PDF,向量库里什么都没有
坑二:没有 doc_id,更新文档只能清库重建
坑三:Metadata 字段名拼错,过滤永远不生效
坑四:Excel 产品参数表直接 Tika 读,检索出乱七八糟的结果
分块策略---RAG 效果差异最大的环节
同样的文档,换一种分块策略,检索命中率从 45% 提升到 78%。没有改模型,没有改检索算法,就改了怎么切。分块是 RAG 里性价比最高的优化点。
java
块太大(> 1000 Token):
语义上下文完整,模型理解更准确
向量语义混杂,检索精度低
占用更多上下文窗口,Token 成本高
块太小(< 100 Token):
向量语义聚焦,检索精度高
语义不完整,单块信息量不足
同一个问题需要拼多个块才能回答
通用建议:300-600 Token 是甜点区,在语义聚焦和上下文完整之间取得平衡。
这不是死规则。法律文档需要更小的块(精准);技术手册可以稍大(上下文完整)。后面针对不同文档类型会细说。
切块时需要设置相邻块的重叠区:
java
原文: AAAAAABBBBBBCCCCCC
不加重叠: [AAAAAA] [BBBBBB] [CCCCCC]
加重叠: [AAAAAABB] [BBBBBBCC] [CCCCCC]
↑ 相邻块有 BB 重叠
为什么需要重叠: 如果一个关键信息恰好在两块的边界处(比如"最高补偿金额为一万元"被切成了"最高补偿金额为"和"一万元,具体以合同为准"),有重叠就能保证至少一个块能完整包含这个信息。
重叠区大小建议:块大小的 10-15%。
不同文档类型的分块策略
技术文档 / 产品手册:按 Token 数量切,在段落边界切,保留上下文。
● 块大小:500 Token
● 重叠区:50-80 Token
● 分块器:TokenTextSplitter(先在段落边界切,超出再按 Token 截)
FAQ 文档(一问一答)
FAQ 是特殊场景------一个问题和对应的答案是一个完整的语义单元,绝对不能被切断。
法律合同 / 政策文档:
法律文件每句话都可能很关键,条款之间高度关联。
● 块大小:300 Token(更小,语义更聚焦)
● 重叠区:50 Token(偏大,防止条款被切断)
● 注意:按条款编号切,不要在条款中间切断
代码文档:代码的天然分割单位是函数和类,不是 Token 数量。
● 按语法边界切,不要在函数中间截断。
● 每个函数(含注释和函数签名)是一个块
● 实在太长的函数可以按逻辑段落切,但不要切在赋值语句中间
● 如果有代码解析能力(如 JavaParser),按 AST 精确分割效果最好
表格 / 结构化内容
表格的每一行通常是一条独立记录,按行切。
● 保留表头:每个块都带上列名,不然检索到的行没有语义
● 格式转换:表格转成自然语言描述后再入库,向量检索效果更好
○ "产品 XPro-3000,最大承载 500kg,工作温度 -20°C 至 80°C"
○ 这比直接存 XPro-3000 | 500kg | -20~80 向量语义更好
父子块检索(Parent-Child Chunking)
这是 Advanced RAG 里的重要技巧,解决块大小的根本矛盾:
小块:向量语义聚焦,检索精度高
大块:上下文完整,模型理解更好
父子块策略:用小块检索,用大块喂给模型。
java
索引时:
大块(父块,500 Token)→ 存到普通数据库(给模型用)
小块(子块,100 Token)→ 存到向量库(用于检索)
每个子块记录所属父块的 ID
检索时:
1. 用用户问题在向量库搜索小块(精准命中)
2. 通过父块 ID 取出对应的大块(完整上下文)
3. 把大块内容拼好,传给模型
常见坑:坑一:默认参数不改
很多同学用 TokenTextSplitter(500, 50, ...) 就完事了,从来不看实际切出来的块是什么样。养成习惯:入库之前,打印前 10 个块,看看有没有明显截断或语义破碎的情况。
坑二:忘记设相似度阈值
入库时块切得多,查询时设了 TopK=5 但没设相似度阈值,低质量的块也会被返回。分块和检索参数是一起调的。
坑三:文档更新时只增不删
文档更新了旧版本,但旧块还在向量库里。结果检索会同时返回新旧两个版本的内容,模型搞混。文档更新时要同步删除旧块,按文档 ID 做精确替换。