尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

pgvector+PostgreSQL构建RAG知识库实战指南

pgvector+PostgreSQL构建RAG知识库实战指南 如果你手头已经跑着一套 PostgreSQL又想做语义搜索、搭一个 RAG 知识库但不想为了向量检索再引三套中间件那 pgvector 几乎是绕不开的选择。它是 PostgreSQL 官方的向量检索扩展直接把 embedding 存进数据库用 SQL 就能做相似度搜索还能和你的业务数据 JOIN、一起做事务、一起做备份。这篇指南我会从安装配置、建表索引、语义搜索查询到完整的 RAG 流水线一步步讲清楚也把我在实际项目中踩过的坑和调优经验一并分享出来。适合已经熟悉 PostgreSQL 但没接触过向量检索的开发者也适合正在做 RAG 知识库、但被向量数据库选型困扰的朋友。1. 为什么是 pgvector语义搜索到底在解决什么问题1.1 关键词搜索的边界在哪里传统搜索方案不管是 SQL 里的LIKE %关键词%还是 PostgreSQL 的全文检索tsvector本质都在做字面匹配。你能搜到“包含这些词”的文档但搜不到“意思相近但没有任何一个词相同”的内容。比如用户问“笔记本没电了怎么办”词面匹配要求文档里出现“笔记本”“没电”这些字样可如果文档标题是“电脑电池耗尽后的应急处理”传统搜索就彻底失联了。语义搜索的思路完全不同。它先把文本扔进一个 embedding 模型模型把这句话转换成一串几百维的浮点数向量这个向量可以理解为“这句话在语义空间里的坐标”。语义相近的两句话向量在空间里的距离就近语义无关的距离就远。搜索的时候只需要把用户的查询也转成向量然后在库里找“离它最近”的那些向量对应的文档就实现了真正意义上的“按意思搜”。这里我用一个生活化类比帮新手理解关键词搜索像你按门牌号找地址必须知道确切的门牌语义搜索像拿着一张照片去问路人“这个地方在哪”对方看的是整体环境和特征不需要你说的每个字都和路牌对上。1.2 三种常见向量数据库方案的取舍做语义搜索第一反应可能是上独立向量数据库比如 FAISS、Milvus、Qdrant 这些。它们确实性能强悍但代价也很实在多了一套分布式系统要部署、要监控、要保证高可用数据和 PostgreSQL 之间要做同步经常面临双写一致性问题——业务库更新了向量库里还是旧数据事务也没法跨系统保证。对于中小团队、内部工具、个人项目来说这个运维负担往往比搜索引擎本身还重。pgvector 走的是另一条路线不额外引入任何组件就在 PostgreSQL 内部新增一种vector数据类型和对应的索引方法。你可以在同一个事务里同时更新业务表和向量表可以用一条 SQL 完成“筛选条件 语义排序”可以继续用 PostgreSQL 的权限体系、备份恢复、扩展生态。方案部署成本数据一致性适合场景FAISS / Milvus / Qdrant独立服务需部署维护需自行同步易不一致千万级以上向量、延迟极敏感的大型系统pgvector安装扩展即可与业务数据同库同事务百万级以下向量、中小项目、需要和业务数据 JOIN我这里说的百万级是个经验边界不是硬性上限。pgvector 在海量数据下也能用但独立向量库在超大规模、超高 QPS 场景下确实有更成熟的分布式方案。选型时关键是别一开始就追求“极致性能”多数项目的数据量连 100 万向量都不到pgvector 在百万级上跑 HNSW 索引的查询时延通常在几十毫秒级别完全够用。1.3 pgvector 的工作原理距离函数与向量索引使用 pgvector 之前至少要知道三个距离函数-表示欧氏距离L2表示余弦距离#表示负内积。文本语义搜索最常用的是余弦距离因为它的计算只关心向量的方向不关心向量长度这正好符合 embedding 模型输出的特性——模型更在意语义方向而不是绝对数值。原始数据量一大全表扫描逐个算距离是灾难。pgvector 提供了两种索引IVFFlat 和 HNSW。IVFFlat 的思路是先对向量空间做聚类分桶查询时只挑最近的几个桶搜索有点像图书馆先按大类分区再找书HNSW 的思路是构建一个多层近邻图查询时从最上层往下逐层逼近有点像社交网络里“找朋友的朋友”的六度分隔。HNSW 的查询精度和速度普遍优于 IVFFlat构建速度稍慢但可接受新项目基本可以直接选 HNSW。2. 环境准备PostgreSQL 版本选择与 pgvector 安装2.1 版本怎么选14 是底线16/17 更省心pgvector 对 PostgreSQL 的版本有下限要求。HNSW 索引需要 PostgreSQL 14 及以上版本所以如果你还在用 12、13建议先升级数据库别急着装扩展。当前主流发行版默认提供 PostgreSQL 16 或 17功能稳定社区资料也多新项目直接选这两个版本就好。还有一点容易被忽略如果你打算用源码编译安装 pgvector需要先装好 PostgreSQL 的开发头文件在 Debian/Ubuntu 上是postgresql-server-dev-16这个包Red Hat/CentOS 系是postgresql16-devel。缺了头文件make会直接报错找不到pg_config。实际工作中不少人的安装失败就是卡在这一步。2.2 三种安装方式按你的环境挑一种我按从省事到折腾的顺序排一下包管理器安装Debian/Ubuntu 上apt install postgresql-16-pgvectorFedora 上dnf install postgresql16-pgvector。装完就完了不需要编译最省心。源码编译安装从 GitHub 拉取 pgvector 源码执行make make install。这个方式适合没有现成软件包的环境或者你想用最新特性。注意编译前确认pg_config在你的PATH里而且它指向的目标 PostgreSQL 版本和你当前运行的服务一致。如果你机器上有多个 PostgreSQL 版本这一步最容易掉坑。Docker 安装官方镜像pgvector/pgvector:pg16或者pgvector/pgvector:pg17已经把扩展编译好了直接拉起来用。如果你用的是标准postgres镜像也可以自己写个 Dockerfile 额外安装本质是复制编译产物。我个人的建议是能用官方现成镜像就用现成的少踩编译的坑。2.3 验证安装别急着写代码先花一分钟确认扩展可用装好之后连到数据库里执行CREATE EXTENSION IF NOT EXISTS vector;看到CREATE EXTENSION的返回就说明成功了。再跑一句SELECT * FROM pg_available_extensions WHERE name vector;可以确认扩展版本。如果报错extension vector is not available大概率是扩展文件装到了别的 PostgreSQL 实例目录下或者安装路径的版本和你当前连接的数据库版本不一致。注意装 pgvector 不需要设置shared_preload_libraries它不像某些扩展需要预加载到共享内存里。我踩过的一次坑是用源码编译时没注意pg_config指向的是 PostgreSQL 15而实际跑了两个实例一个 15 一个 16结果扩展装错地方了。排查方法很简单psql里执行select version();确认当前实例版本再执行select current_setting(server_version);对比一下如果环境允许直接在对应版本的bin目录下重新编译一次最省事。3. 建表、向量化与索引让数据变成可检索的向量3.1 表结构设计别把所有东西塞进一个大字段我建议用两张表来组织 RAG 知识库的数据。第一张是文档表记录原始文档的元信息第二张是分块表记录切分后的文本块和对应的向量。这样设计的好处是后续做文档更新、权限过滤、来源追踪都方便也符合 RAG 检索的粒度——搜索是对“文本块”做的不是对整个文档做的。CREATE TABLE documents ( id BIGSERIAL PRIMARY KEY, title TEXT NOT NULL, source TEXT, created_at TIMESTAMPTZ DEFAULT now() ); CREATE TABLE document_chunks ( id BIGSERIAL PRIMARY KEY, document_id BIGINT REFERENCES documents(id) ON DELETE CASCADE, chunk_index INT NOT NULL, content TEXT NOT NULL, embedding vector(1024) );embedding字段的维度必须和你的 embedding 模型输出维度严格一致。如果你用 OpenAI 的text-embedding-3-small维度是 1536用text-embedding-3-large维度是 3072用本地模型 BGE-M3维度是 1024。这块一旦建表时定错了后续插入向量会直接报错所以开工前一定要先确认模型输出维度。另外我把metadata比如文档分类、权限标签、URL 链接放在了文档表里检索时可以关联回来做过滤比塞进 JSON 字段里管理起来更清晰。3.2 生成 embedding 的两种主流路径生成 embedding 是整个链路里最外部的环节但也是最影响检索效果的一环。这里我给两条路径。路径一本地模型数据不出内网。用 Ollama 跑一个 BGE-M3 或者其他 embedding 模型然后通过 HTTP 接口调用。这是我自己现在最推荐的做法尤其适合企业内部知识库文档往往涉及敏感信息调外部 API 心里没底。启动一个本地模型后调用方式很简单curl http://localhost:11434/api/embed \ -H Content-Type: application/json \ -d {model: bge-m3, input: 笔记本没电了怎么办}模型会返回一个 1024 维的 float 数组这个数组就是你插入数据库的embedding值。路径二调用 OpenAI 或其他云端 embedding API。如果你的场景允许数据出网并且团队已经有现成的 API Key用text-embedding-3-small也能获得不错的效果。调用方式用官方 SDK 或者requests都行。要注意的是云端 API 有速率限制和费用批量入库时最好做一下重试与限速。无论哪条路径嵌入的质量直接决定语义搜索的上限。我个人建议大家优先选中文效果好的模型BGE 系列的向量化模型在这块做得比较稳。模型选完之后把一整批数据向量化插入库里的脚本大致长这样import psycopg2 import requests conn psycopg2.connect(hostlocalhost dbnamemydb userpostgres) cur conn.cursor() texts [文档一的文本内容, 文档二的文本内容] for doc_id, text in enumerate(texts, start1): resp requests.post(http://localhost:11434/api/embed, json{model: bge-m3, input: text}).json() embedding resp[embeddings][0] cur.execute( INSERT INTO document_chunks (document_id, chunk_index, content, embedding) VALUES (%s, %s, %s, %s), (doc_id, 0, text, embedding) ) conn.commit()有一点必须强调插入的 Python 列表会被 psycopg2 自动转换成 PostgreSQL 的数组但 pgvector 存的是vector类型严格来说需要显式类型转换。实际使用中你可以直接传字符串形式的[0.1,0.2,...]让 PostgreSQL 隐式转换或者用psycopg2.extras注册适配器否则会报column embedding is of type vector but expression is of type double precision[]之类的类型错误。这个坑我见过至少三次新手最容易在这卡住。3.3 HNSW 与 IVFFlat 的关键参数谁适合你的数据量索引创建语法很简单但参数选择直接影响查询速度和精度。先说结论新项目、数据量在几万到几百万之间直接上 HNSW。CREATE INDEX ON document_chunks USING hnsw (embedding vector_cosine_ops);HNSW 有两个构建参数m控制每个节点的最大连接数默认 16调大到 32 可以提高召回率但会使索引更大、构建更慢ef_construction控制构建时的动态列表大小默认 64调大到 128 能提升索引质量同样牺牲构建速度。查询时还有一个会话级参数hnsw.ef_search控制搜索时考察的候选节点数量默认 40调到 100 左右能明显提升召回但查询延迟也会上升。IVFFlat 的话参数是lists和probes。lists表示分成多少个聚类桶一般建议行数除以 1000比如 50 万行就设 500probes是查询时搜索几个桶默认 1调到 10 左右能提升召回。注意IVFFlat 有个先决条件索引建好后必须先有数据否则聚类结果是空的而且数据如果分布变化太大索引质量会下降。说实话2024 年之后的新项目我是无脑推荐 HNSW 的它的查询稳定性比 IVFFlat 好太多参数也不用调来调去。4. 从向量到语义搜索查询写法与混合检索4.1 最基础的语义搜索 SQL一行代码实现“按意思找”假设用户输了一个问题你先把它转成向量然后执行查询SELECT id, content, 1 - (embedding [0.1, 0.2, ...]) AS similarity FROM document_chunks ORDER BY embedding [0.1, 0.2, ...] LIMIT 5;这里是余弦距离返回 0 表示完全一致1 表示完全无关2 表示方向完全相反。为了直观展示相关度我用1 - 距离把它转换成相似度分数。实际项目里我们经常在应用层生成查询向量然后通过参数化查询传入避免每次拼 SQL。一个常见疑问是为什么排序用而不是-。取决于你 embedding 模型的训练方式。模型如果做了归一化——所有向量长度都是 1——那么余弦距离、欧氏距离、内积在排序意义上是一致的但有些模型没做归一化这时余弦距离更稳定因为它只看方向不管长度。我常用的 BGE-M3 输出已经是归一化的但为了通用性文本场景我仍然建议用。4.2 带条件过滤的向量检索WHERE 子句与索引的相爱相杀真实业务不会只做全局语义搜索总要加一些过滤条件比如“只看技术类文档”“只看某个月的数据”。SQL 写起来很直接SELECT id, content, 1 - (embedding $1) AS similarity FROM document_chunks WHERE document_id IN (SELECT id FROM documents WHERE category 技术文档) ORDER BY embedding $1 LIMIT 5;但这里藏着一个性能问题HNSW 索引本质上是先找“全局最近邻”再在结果集里过滤。如果过滤条件很强——比如只保留了 1% 的数据——索引可能选出来的邻居大部分都被过滤掉了导致召回不满 5 条或者结果不准确。面对这个场景我的经验是如果过滤后数据量仍然比较大几万条以上可以直接在查询时加一个显式的WHERE条件让 PostgreSQL 先做一个粗筛再排向量如果过滤后数据量很小这种写法完全没问题。极端情况下可以考虑建部分索引例如只对某个分类的文档建 HNSW 索引。pgvector在 0.7.0 之后对WHERE过滤和 HNSW 的配合有所优化但如果你的过滤维度很多还是建议在应用层做两层结构先根据条件缩小候选 ID 集合再对候选集合做向量搜索。4.3 混合检索全文检索 语义向量两个都要只做语义搜索会有一个尴尬场景用户问“请帮我找编号 DOC-2024-001 的文档”这个编号在语义空间里没有任何意义纯向量搜索大概率翻车。这时候必须靠全文检索兜底。PostgreSQL 自带的tsvector全文搜索对精确词、编号、专业名词非常擅长两者配合才是一个完整的检索系统。实践中我一般这样合并SELECT id, content, ts_rank(to_tsvector(simple, content), plainto_tsquery(simple, DOC-2024-001)) AS text_score, 1 - (embedding $1) AS semantic_score FROM document_chunks WHERE to_tsvector(simple, content) plainto_tsquery(simple, DOC-2024-001) OR 1 - (embedding $1) 0.7 ORDER BY (text_score semantic_score) DESC LIMIT 5;注意我用了simple配置而不是默认的english或chinese因为默认分词对中文不友好对编号这种“词”反而会拆得乱七八糟。至于text_score和semantic_score的权重怎么配没有标准答案我的做法是先全跑一遍看哪些查询靠语义命中、哪些靠全文命中然后给全文检索一个小的降权系数。这个比例值得你在自己的数据集上花时间做实验它是区分“能用”和“好用”的关键一步。5. 把搜索变成问答构建一条可用的 RAG 流水线5.1 RAG 的基本形态与适用场景RAGRetrieval-Augmented Generation检索增强生成说白了就是先从知识库里检索出相关文档片段再把片段拼进 Prompt最后让大模型基于这些片段回答。它解决的痛点是 LLM 没有你的私有数据、会一本正经地胡编、知识版本停留在训练时刻。用 RAG 把内部文档、FAQ、售后记录喂给模型它就能像“带着资料开卷考试”一样回答你的业务问题。适合 RAG 的场景包括企业内部知识库问答、电商智能客服、文档搜索引擎、医疗/法律等行业的专业问答助手。判断你的场景适不适合 RAG一个简单的标准是答案是否存在于你们已有的文档里。如果是RAG 大概率有效如果答案需要基于多份文档推理、综合判断RAG 也能帮上忙但你需要调优策略。5.2 文档切分的实操细节chunk size 和 overlap 不是玄学很多人在 RAG 里效果差问题不在向量数据库而在数据进入数据库之前的切分环节。切分太粗每个 chunk 包含太多主题向量平均化之后语义模糊切分太细一个完整知识被拆散检索时上下文不足。我自己的经验法则按语义单元切分优先按 Markdown 标题、段落、列表等结构边界切而不是无脑按字符数硬切。结构边界天然是语义边界。没有结构时再按长度切。chunk size 控制在 300-500 个汉字左右。OpenAI 的 tokenizer 大概是 1 个汉字 ≈ 1.5-2 token500 汉字 ≈ 750-1000 token。太大吞细节太小丢上下文。overlap 设置在 50-100 字。overlap 的作用是避免一句话恰好被一刀切开导致语义断掉。它不是越多越好越多冗余越大、索引越大、检索时噪声越高。如果你用的是 LangChain可以这么配from langchain_text_splitters import RecursiveCharacterTextSplitter splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap80, separators[\n\n, \n, 。, , , ., ], )上面这个separators顺序是我调出来的中文用句号、感叹号、问号作为兜底边界比默认的纯英文标点靠谱很多。Java 生态的话LangChain4j 里有一个Easy RAG模块封装了文档解析、切分、向量化入库和检索问答连 pgvector 的接口都直接接好了用起来省不少事。切分完的 chunk 顺序要保留chunk_index字段就是干这个用的后续如果要做多 chunk 上下文拼接或者引用溯源都靠它。5.3 从检索到生成完整的最小 Python 实现整个 RAG 链路不需要引入重框架用psycopg2做检索、用requests调模型三十行代码就能跑通。我把一个最小实现贴出来这个版本已经是我在实际项目中跑过、可以用于小规模内部工具的骨架import psycopg2 import requests def get_embedding(text): resp requests.post(http://localhost:11434/api/embed, json{model: bge-m3, input: text}) return resp.json()[embeddings][0] def search(query, top_k5): vec get_embedding(query) conn psycopg2.connect(hostlocalhost dbnamemydb userpostgres) cur conn.cursor() cur.execute( SELECT content, 1 - (embedding %s::vector) AS similarity FROM document_chunks ORDER BY embedding %s::vector LIMIT %s , (vec, vec, top_k) ) results cur.fetchall() cur.close() conn.close() return results def ask(query): results search(query) context \n\n.join([r[0] for r in results]) prompt f你是一个企业内部助手。请严格根据下面的资料回答问题。 如果资料中没有相关信息请直接说“资料中未找到相关信息”不要编造。 资料 {context} 问题{query} resp requests.post(http://localhost:11434/api/chat, json{model: qwen2.5, messages: [{role: user, content: prompt}]}) return resp.json()[message][content] print(ask(笔记本没电了怎么办))这里面有个细节%s::vector是必要的显式类型转换否则参数推断有时会出问题。top_k我一般设 5太多会带进噪声太少上下文不够。要注意的是ask的模型qwen2.5和get_embedding用的模型bge-m3是两个不同的模型前者管生成后者管向量化这条链路你一定要分开看不然很容易混淆。5.4 RAG 的瓶颈与优化方向为什么说七成功夫在检索很多人做 RAG 时把精力全花在调 Prompt、换大模型上结果效果依然不稳定。我做了几轮之后才意识到RAG 的上限由检索决定生成只是把检索到的知识组织成通顺的语言。检索如果召回不到正确答案后面换再强的模型也是白搭。RAG 的瓶颈有几层。第一层是召回率相关文档根本没被搜出来再强的生成也没用。优化方向包括调 HNSW 参数、混合检索、rerank。第二层是噪声率检索结果里混入太多不相关内容干扰模型判断。优化方向是把 chunk 切得更干净加强元数据过滤。第三层是评估没有量化指标你就不知道怎么优化。我建议至少记录两个数字命中率正确答案是否出现在检索返回的 top-5 里和生成准确率最终回答是否让用户满意。先跑一个 50 条问题的测试集每次改动都跑一遍用数字说话。Rerank 这个词值得展开。第一轮向量检索返回 20 条候选然后用一个专门的重排模型比如 BGE-Reranker对这些候选逐条和查询算相关度分数重新排序后只取前 5 条。这个动作能在不太增加延迟的情况下显著提升效果是目前很多产品级 RAG 的标准做法。不过它属于锦上添花新手先把基础链路跑通、把数据切好、索引建对效果已经能覆盖大部分需求了。6. 常见问题与避坑实录6.1 向量查询慢、没走索引怎么办先用EXPLAIN ANALYZE看执行计划里有没有Index Scan using document_chunks_embedding_idx如果看到Seq Scan说明全表扫描了。常见原因有三个没建索引最蠢但也最常见查询里LIMIT太大走了索引还要回表太多条规划器认为全表扫更快过滤条件导致索引不能有效利用。HNSW 索引的ef_search也可以适当调大默认 40 对精度要求高的场景偏低我可以承受查询慢 10 毫秒换召回提升通常就会先调它试试。6.2 RAG 知识库能存图片吗很多人问“RAG 知识库能存储图片吗”答案是可以但你要搞清楚存的是什么。纯文本向量库存不了图片内容但你可以用视觉语言模型比如 CLIP 或者 Qwen-VL把图片转成视觉向量存进 pgvector 里然后支持“用文字描述找图片”的语义检索。更常见的做法是图片本身存到对象存储或者bytea字段里图片生成的文字描述向量化后进向量库检索到描述再带出图片 URL。所以严格说pgvector 存的是图片的可搜索特征不是图片本身。视觉向量和文本向量是两个空间不要混在一起插进同一个向量字段。6.3 中文语义搜索效果差问题通常不在数据库中文检索效果差先别怀疑 pgvector99% 的情况是 embedding 模型和切分策略的问题。通用英文模型对中文的支持参差不齐我强烈建议中文场景直接用 BGE-M3 这样的国产中文向量模型。另外中文没有天然空格分词切分时如果不按句子边界切很容易把一个完整语义切成碎片。我之前遇到一个案例某字段内容都是“技术支持电话400-xxx-xxxx”切分时正好从中间切断导致检索命中率下降后来加了句号兜底和 overlap 才解决。6.4 向量索引膨胀与数据更新维护业务数据频繁更新删除会导致 HNSW 索引膨胀查询性能下降。PostgreSQL 的索引膨胀在向量索引上同样存在我的维护习惯是每天增量导入后如果删除量超过了总量的 10%就执行一次REINDEX INDEX document_chunks_embedding_idx。这操作会锁表建议放在低峰期执行。另外批量导入时先删除索引、导入完再重建比边导边维护快得多这个思路和普通 PostgreSQL 索引优化完全一样。6.5 常见问题速查表现象可能原因排查/解决CREATE EXTENSION报错扩展装到了其他版本的实例目录确认pg_config指向的版本重新编译安装插入向量时类型错误没有做类型转换SQL 里显式%s::vector查询不走向量索引没建索引 / 过滤条件过强EXPLAIN ANALYZE查看计划调ef_search中文效果差embedding 模型选型或切块问题换 BGE 系列模型调整separators和 overlap检索结果相关但噪声多chunk 太大或 top_k 太大缩小 chunk_size降低 top_k加 rerank索引膨胀导致变慢频繁增删低峰期REINDEX说起来我最早做语义搜索的时候第一反应是上 FAISS后来发现数据要从业务库导出、转换、同步维护成本极高迁移到 pgvector 之后只用了一张表就搞定了整个内部知识库的检索。如果你的数据量在百万级以下pgvector 的体验真的非常顺滑尤其是和既有 PostgreSQL 业务数据做 JOIN、做权限过滤的时候那种“自带数据库原生气质”的优势是独立向量库给不了的。如果你正在纠结要不要为了 RAG 上一个新数据库我个人的建议是先在自己的 PostgreSQL 实例上把 pgvector 跑起来用最小闭环验证整个效果链路数据规模真到了千万级、性能指标确实追不上的时候再考虑迁移到独立向量数据库也不迟。
返回列表