用 pgvector 做 RAG:别急着上 Pinecone,你的 PostgreSQL 就够了

Posted on Tue 10 March 2026 in AI • 8 min read

Abstract 用 pgvector 做 RAG:别急着上 Pinecone,你的 PostgreSQL 就够了
Authors Walter Fan
Category AI
Version v1.0
Updated 2026-03-10
License CC-BY-NC-ND 4.0

短大纲

展开看看 - Hook: 你真的需要一个专门的向量数据库吗? - 什么是 RAG,以及为什么它需要向量搜索 - pgvector:给 PostgreSQL 装上"语义理解"能力 - Docker Compose 一键部署 pgvector + pgweb - Python 完整示例:CLI 工具,支持 index / query / chat / stats / reset - 代码拆解:自动探测维度、语义分块、兼容任意 OpenAI 兼容 API - 性能调优:索引策略与踩坑笔记 - 什么时候该用 pgvector,什么时候该换别的 - 总结 + CheckList + 思维导图

你真的需要一个专门的向量数据库吗?

上周帮一个朋友 review 他的 RAG 架构,画出来大概长这样:用户提问 → 调 OpenAI embedding API → 发到 Pinecone 做相似度检索 → 拿回结果 → 再调 OpenAI 生成回答。五个步骤,三个外部服务,两张信用卡。

我问他:"你总共存了多少条向量?"

"大概……两万条。"

两万条。你知道这是什么概念吗?这连 PostgreSQL 热身都算不上。为了两万条向量专门搞一个 Pinecone,就好比为了烧一壶开水买了一台工业锅炉。

这篇文章想说服你的一件事:对于中小规模的 RAG 应用(百万级以下),pgvector + PostgreSQL 是性价比最高的方案。

你不用学新的查询语言,不用维护新的基础设施,不用担心数据一致性——因为你的向量和业务数据住在同一个数据库里,天然 ACID,天然 JOIN。


RAG 到底在干什么?一张图说清楚

RAG(Retrieval-Augmented Generation)的核心思路其实很朴素:LLM 不是什么都知道的,那就在它回答之前,先帮它翻翻资料。

@startuml
skinparam style strictuml
skinparam backgroundColor #FEFEFE
skinparam sequenceArrowColor #555555

actor User
participant "Application" as App
database "pgvector\n(PostgreSQL)" as PG
participant "Embedding\nModel" as Emb
participant "LLM" as LLM

User -> App: 提问
App -> Emb: 把问题转成向量
Emb --> App: question_embedding
App -> PG: SELECT * FROM docs\nORDER BY embedding <=> ?\nLIMIT 5
PG --> App: 最相关的 5 条文档
App -> LLM: 问题 + 相关文档\n(构建 prompt)
LLM --> App: 生成回答
App --> User: 回答

@enduml

RAG 流程图

整个流程分两个阶段:

  1. 离线阶段(Indexing):把你的文档切块、算 embedding、存进 pgvector
  2. 在线阶段(Retrieval + Generation):用户提问 → 算问题的 embedding → 在 pgvector 里找最相似的文档块 → 拼成 prompt 喂给 LLM

关键洞察:RAG 的质量上限,取决于检索的质量,而不是 LLM 的能力。 你的 LLM 再聪明,如果检索回来的都是不相关的内容,它也只能一本正经地胡说八道。


pgvector:30 秒看懂它干了什么

pgvector 是 PostgreSQL 的一个扩展,它做了三件事:

  1. 新增 vector 数据类型:你可以在表里加一列 embedding vector(1536),就像加一个 textint 字段一样自然
  2. 提供距离运算符<-> 欧氏距离、<=> 余弦距离、<#> 内积。写 SQL 就能做相似度搜索
  3. 支持 ANN 索引:IVFFlat 和 HNSW 两种近似最近邻索引,让百万级向量的搜索也能毫秒级返回

一句人话总结:pgvector 让 PostgreSQL 听懂了"语义"。

以前你只能 WHERE title LIKE '%机器学习%',现在你可以问"跟这个意思最像的文档有哪些"——哪怕文档里一个"机器学习"都没出现。


Docker Compose 一键部署

部署是最没技术含量但最容易翻车的环节。直接给你一份能跑的 docker-compose.yaml

services:
  pgvector:
    image: ankane/pgvector
    container_name: pgvector
    environment:
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASS}
      POSTGRES_DB: ${DB_NAME}
    ports:
      - "5432:5432"
    volumes:
      - pgvector-data:/var/lib/postgresql/data
    networks:
      - pgvector-net
    restart: unless-stopped

  pgweb:
    image: sosedoff/pgweb
    container_name: pgweb
    ports:
      - "8081:8081"
    environment:
      - PGWEB_DATABASE_URL=${DB_URL}
    depends_on:
      - pgvector
    networks:
      - pgvector-net
    restart: unless-stopped

volumes:
  pgvector-data:

networks:
  pgvector-net:
    driver: bridge

对应的 .env 文件:

DB_USER=rag_user
DB_PASS=rag_pass_2026
DB_NAME=rag_db
DB_URL=postgres://rag_user:rag_pass_2026@pgvector:5432/rag_db?sslmode=disable

启动:

docker compose up -d

几个注意点:

  • ankane/pgvector 是 pgvector 官方维护的 Docker 镜像,自带 PostgreSQL + pgvector 扩展,开箱即用
  • pgweb 是一个轻量级的 PostgreSQL Web 管理工具,方便你直观地看数据。访问 http://localhost:8081 就行
  • 生产环境请务必改密码、限制端口暴露、配置 SSL

Python 完整示例:从 0 到 1 的 RAG

下面这个例子是完全可执行、可测试的。它不依赖 LangChain,只用最基础的库,让你看清每一步在干什么。而且它是一个带 CLI 的工具——支持索引 Markdown 文件、单次查询、交互式聊天和数据管理,不是那种跑一次就扔的 demo。

依赖安装

pip install psycopg2-binary openai httpx python-dotenv

用法速览

# 索引一个目录下的所有 Markdown 文件
python pgvector_rag_demo.py index ./docs/

# 单次 RAG 查询
python pgvector_rag_demo.py query "pgvector 怎么建索引?"

# 交互式聊天模式
python pgvector_rag_demo.py chat

# 查看知识库统计
python pgvector_rag_demo.py stats

# 清空所有索引数据(换模型后需要重建)
python pgvector_rag_demo.py reset

配置(.env 文件)

脚本从 .env 读取所有配置,embedding 和 LLM 可以用不同的服务——比如 embedding 用本地部署的模型,LLM 用云端 API:

# 数据库(二选一)
DATABASE_URL=postgresql://rag_user:rag_pass_2026@localhost:5432/rag_db
# 或者分开写:
# DB_HOST=localhost
# DB_PORT=5432
# DB_NAME=rag_db
# DB_USER=rag_user
# DB_PASS=rag_pass_2026

# Embedding 模型(兼容 OpenAI 接口的都行)
EMBEDDING_BASE_URL=https://api.openai.com/v1
EMBEDDING_API_KEY=sk-xxx
EMBEDDING_MODEL=text-embedding-3-small

# LLM(同样兼容 OpenAI 接口)
LLM_BASE_URL=https://api.openai.com/v1
LLM_API_KEY=sk-xxx
LLM_MODEL=gpt-4o-mini

一个值得注意的设计:embedding 和 LLM 的 base URL 是分开配置的。实际项目中你很可能 embedding 用便宜的本地模型,LLM 用云端大模型,这样成本最优。

完整代码

#!/usr/bin/env python3
"""
pgvector RAG Demo — Read markdown files, embed, store in PostgreSQL, then query.

Usage:
  python pgvector_rag_demo.py index ./path/to/markdown/dir
  python pgvector_rag_demo.py query "How does the RAG engine work?"
  python pgvector_rag_demo.py chat
  python pgvector_rag_demo.py stats
  python pgvector_rag_demo.py reset

Prerequisites:
  pip install psycopg2-binary openai httpx python-dotenv
"""

import os, sys, glob, textwrap
from pathlib import Path
from dotenv import load_dotenv

PROJECT_ROOT = Path(__file__).resolve().parent.parent
load_dotenv(PROJECT_ROOT / ".env")
load_dotenv(override=True)


# ============================================================
# Configuration
# ============================================================

def get_db_config() -> dict:
    db_url = os.getenv("DATABASE_URL", "")
    if db_url.startswith("postgresql"):
        from urllib.parse import urlparse
        p = urlparse(db_url)
        return {
            "host": p.hostname or "localhost",
            "port": p.port or 5432,
            "dbname": p.path.lstrip("/") or "rag_db",
            "user": p.username or "postgres",
            "password": p.password or "postgres",
        }
    return {
        "host": os.getenv("DB_HOST", "localhost"),
        "port": int(os.getenv("DB_PORT", "5432")),
        "dbname": os.getenv("DB_NAME", "rag_db"),
        "user": os.getenv("DB_USER", "postgres"),
        "password": os.getenv("DB_PASS", "postgres"),
    }

EMBEDDING_BASE_URL = (os.getenv("EMBEDDING_BASE_URL")
                      or os.getenv("LLM_BASE_URL", "https://api.openai.com/v1"))
EMBEDDING_API_KEY  = (os.getenv("EMBEDDING_API_KEY")
                      or os.getenv("LLM_API_KEY", ""))
EMBEDDING_MODEL    = (os.getenv("EMBEDDING_MODEL")
                      or os.getenv("LLM_EMBEDDING_MODEL", "text-embedding-3-small"))
LLM_BASE_URL   = os.getenv("LLM_BASE_URL", "https://api.openai.com/v1")
LLM_API_KEY    = os.getenv("LLM_API_KEY", "")
LLM_MODEL      = os.getenv("LLM_MODEL", "gpt-4o-mini")
LLM_VERIFY_SSL = os.getenv("LLM_VERIFY_SSL", "true").lower() not in ("false", "0", "no")

TOP_K = 5
CHUNK_SIZE = 512
CHUNK_OVERLAP = 50


# ============================================================
# Embedding (OpenAI-compatible API)
# ============================================================

def get_embedding_client():
    import httpx
    from openai import OpenAI
    http_client = httpx.Client(
        headers={"X-Api-Key": EMBEDDING_API_KEY},
        verify=LLM_VERIFY_SSL,
    )
    return OpenAI(
        api_key=EMBEDDING_API_KEY,
        base_url=EMBEDDING_BASE_URL,
        http_client=http_client,
    )

def get_embeddings(texts: list[str]) -> list[list[float]]:
    client = get_embedding_client()
    response = client.embeddings.create(input=texts, model=EMBEDDING_MODEL)
    return [item.embedding for item in response.data]

def get_embedding(text: str) -> list[float]:
    return get_embeddings([text])[0]


# ============================================================
# Text chunking (sentence-aware)
# ============================================================

def chunk_text(text: str, chunk_size: int = CHUNK_SIZE,
               overlap: int = CHUNK_OVERLAP) -> list[str]:
    """按语义边界切块,带重叠窗口。"""
    text = text.strip()
    if not text:
        return []
    if len(text) <= chunk_size:
        return [text]

    chunks, start = [], 0
    while start < len(text):
        end = start + chunk_size
        if end < len(text):
            for sep in ["\n\n", "\n", ". ", "。", "! ", "? "]:
                last_sep = text.rfind(sep, start + chunk_size // 2, end)
                if last_sep != -1:
                    end = last_sep + len(sep)
                    break
        chunk = text[start:end].strip()
        if chunk:
            chunks.append(chunk)
        new_start = end - overlap
        if new_start <= start:
            new_start = end
        start = new_start
    return chunks


# ============================================================
# Markdown file reading
# ============================================================

def read_markdown_files(directory: str) -> list[dict]:
    docs, seen = [], set()
    for pattern in [os.path.join(directory, "*.md"),
                    os.path.join(directory, "**/*.md")]:
        for filepath in glob.glob(pattern, recursive=True):
            filepath = os.path.abspath(filepath)
            if filepath in seen:
                continue
            seen.add(filepath)
            try:
                with open(filepath, "r", encoding="utf-8") as f:
                    content = f.read()
            except Exception as e:
                print(f"  Skipping {filepath}: {e}")
                continue
            if not content.strip():
                continue
            title = Path(filepath).stem
            for line in content.split("\n"):
                line = line.strip()
                if line.startswith("# "):
                    title = line.lstrip("# ").strip()
                    break
                if line.startswith("Title:"):
                    title = line.split(":", 1)[1].strip()
                    break
            docs.append({"title": title, "content": content, "source": filepath})
    return docs


# ============================================================
# Database
# ============================================================

def get_connection():
    import psycopg2
    cfg = get_db_config()
    conn = psycopg2.connect(**cfg)
    print(f"Connected to PostgreSQL: {cfg['host']}:{cfg['port']}/{cfg['dbname']}")
    return conn

def _detect_embedding_dim() -> int:
    """发一条短文本给 embedding 模型,自动探测输出维度。"""
    try:
        emb = get_embedding("dimension probe")
        dim = len(emb)
        print(f"Detected embedding dimension: {dim} (model: {EMBEDDING_MODEL})")
        return dim
    except Exception as e:
        print(f"Could not probe dimension ({e}), defaulting to 1536")
        return 1536

def init_db(conn):
    dim = _detect_embedding_dim()
    with conn.cursor() as cur:
        cur.execute("CREATE EXTENSION IF NOT EXISTS vector;")
        cur.execute("""
            SELECT EXISTS (
                SELECT 1 FROM information_schema.tables
                WHERE table_name = 'rag_documents'
            );
        """)
        if not cur.fetchone()[0]:
            cur.execute(f"""
                CREATE TABLE rag_documents (
                    id SERIAL PRIMARY KEY,
                    title TEXT NOT NULL,
                    content TEXT NOT NULL,
                    source TEXT DEFAULT '',
                    chunk_index INTEGER DEFAULT 0,
                    embedding vector({dim}),
                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
                );
            """)
            cur.execute(f"""
                CREATE INDEX idx_rag_docs_embedding
                ON rag_documents
                USING hnsw (embedding vector_cosine_ops)
                WITH (m = 16, ef_construction = 64);
            """)
            print(f"Created table rag_documents with vector({dim}) + HNSW index")
        else:
            cur.execute("""
                SELECT atttypmod FROM pg_attribute
                WHERE attrelid = 'rag_documents'::regclass
                  AND attname = 'embedding';
            """)
            row = cur.fetchone()
            existing_dim = row[0] if row and row[0] > 0 else None
            if existing_dim and existing_dim != dim:
                print(f"Warning: existing column is vector({existing_dim}), "
                      f"but model produces {dim}-dim. Run 'reset' and re-index.")
            print(f"Table rag_documents already exists (dim: {existing_dim or 'unset'})")
        conn.commit()

def index_documents(conn, docs: list[dict]):
    from psycopg2.extras import execute_values
    total_chunks = 0
    for doc in docs:
        chunks = chunk_text(doc["content"])
        if not chunks:
            continue
        print(f"  {doc['title']}: {len(chunks)} chunks", end="")
        batch_size = 64
        all_embeddings = []
        for i in range(0, len(chunks), batch_size):
            all_embeddings.extend(get_embeddings(chunks[i:i + batch_size]))
            print(".", end="", flush=True)
        print()
        rows = [(doc["title"], c, doc.get("source", ""), i, e)
                for i, (c, e) in enumerate(zip(chunks, all_embeddings))]
        with conn.cursor() as cur:
            execute_values(
                cur,
                """INSERT INTO rag_documents
                   (title, content, source, chunk_index, embedding)
                   VALUES %s""",
                rows,
                template="(%s, %s, %s, %s, %s::vector)",
            )
        conn.commit()
        total_chunks += len(rows)
    print(f"Indexed {total_chunks} chunks from {len(docs)} documents")

def search_similar(conn, query: str, top_k: int = TOP_K) -> list[dict]:
    query_emb = get_embedding(query)
    with conn.cursor() as cur:
        cur.execute("""
            SELECT id, title, content, source, chunk_index,
                   1 - (embedding <=> %s::vector) AS similarity
            FROM rag_documents
            ORDER BY embedding <=> %s::vector
            LIMIT %s
        """, (query_emb, query_emb, top_k))
        columns = [desc[0] for desc in cur.description]
        return [dict(zip(columns, row)) for row in cur.fetchall()]

def get_stats(conn) -> dict:
    with conn.cursor() as cur:
        cur.execute("SELECT COUNT(*) FROM rag_documents;")
        total = cur.fetchone()[0]
        cur.execute("SELECT COUNT(DISTINCT title) FROM rag_documents;")
        docs = cur.fetchone()[0]
        cur.execute("SELECT COUNT(DISTINCT source) FROM rag_documents;")
        sources = cur.fetchone()[0]
    return {"total_chunks": total, "documents": docs, "sources": sources}


# ============================================================
# RAG: Retrieve + Generate
# ============================================================

def rag_query(conn, question: str) -> str:
    import httpx
    from openai import OpenAI

    results = search_similar(conn, question)
    print(f"\nQuestion: {question}")
    print(f"Retrieved {len(results)} chunks:")
    for i, r in enumerate(results, 1):
        print(f"  [{i}] {r['title']} (chunk #{r['chunk_index']}, "
              f"similarity: {r['similarity']:.4f})")

    context_parts = []
    for r in results:
        snippet = r["content"][:500]
        context_parts.append(f"### {r['title']}\n{snippet}\n(source: {r['source']})")
    context = "\n\n".join(context_parts)

    http_client = httpx.Client(
        headers={"X-Api-Key": LLM_API_KEY}, verify=LLM_VERIFY_SSL,
    )
    client = OpenAI(
        api_key=LLM_API_KEY, base_url=LLM_BASE_URL, http_client=http_client,
    )
    messages = [
        {"role": "system", "content": (
            "You are a knowledgeable assistant. Answer based on "
            "the provided reference materials. If insufficient, say so. "
            "Cite sources. Answer in the same language as the question."
        )},
        {"role": "user",
         "content": f"Reference materials:\n{context}\n\nQuestion: {question}"},
    ]
    response = client.chat.completions.create(
        model=LLM_MODEL, messages=messages, temperature=0.3, max_tokens=1024,
    )
    answer = response.choices[0].message.content
    print(f"\nAnswer:\n{textwrap.fill(answer, width=80)}")
    return answer


# ============================================================
# CLI
# ============================================================

def main():
    if len(sys.argv) < 2:
        print("Usage: python pgvector_rag_demo.py <index|query|chat|stats|reset>")
        sys.exit(1)

    command = sys.argv[1]
    conn = get_connection()
    try:
        init_db(conn)

        if command == "index":
            directory = sys.argv[2] if len(sys.argv) > 2 else "."
            docs = read_markdown_files(directory)
            if docs:
                index_documents(conn, docs)
            else:
                print("No markdown files found.")

        elif command == "query":
            question = " ".join(sys.argv[2:])
            rag_query(conn, question)

        elif command == "chat":
            print("Interactive RAG chat (type 'quit' to exit)\n")
            while True:
                try:
                    q = input("You: ").strip()
                except (EOFError, KeyboardInterrupt):
                    break
                if not q or q.lower() in ("quit", "exit", "q"):
                    break
                rag_query(conn, q)
                print()

        elif command == "stats":
            s = get_stats(conn)
            print(f"Chunks: {s['total_chunks']}, "
                  f"Documents: {s['documents']}, Sources: {s['sources']}")

        elif command == "reset":
            with conn.cursor() as cur:
                cur.execute("DROP TABLE IF EXISTS rag_documents;")
                conn.commit()
            print("All indexed data cleared. Run 'index' to re-index.")

    finally:
        conn.close()

if __name__ == "__main__":
    main()

完整源码也在 lazy-rabbit-agent/example/pgvector_rag_demo.py


代码拆解:几个值得细看的地方

自动探测 embedding 维度

以前的做法是硬编码 vector(1536),换个模型就得改代码。现在 _detect_embedding_dim() 会在初始化时发一条短文本给 embedding 模型,自动拿到输出维度。如果表已经存在且维度不匹配,还会给你一个明确的 warning:

def _detect_embedding_dim() -> int:
    try:
        emb = get_embedding("dimension probe")
        return len(emb)
    except Exception:
        return 1536  # fallback

这个小细节在实际开发中省了不少麻烦——团队里总有人用不同的 embedding 模型,然后困惑地问"为什么插入报错"。

语义感知的文本分块

分块是 RAG 质量的生命线。机械地按固定字符数切,经常把一句话从中间劈开。这个版本的 chunk_text 会在指定范围内优先找语义边界(段落分隔符、句号、换行符)来断句:

def chunk_text(text, chunk_size=512, overlap=50):
    # ...
    for sep in ["\n\n", "\n", ". ", "。", "! ", "? "]:
        last_sep = text.rfind(sep, start + chunk_size // 2, end)
        if last_sep != -1:
            end = last_sep + len(sep)
            break
    # ...

优先级:段落 > 换行 > 英文句号 > 中文句号。这样切出来的 chunk 语义更完整,检索质量自然更好。

兼容任何 OpenAI 兼容 API

不锁死 OpenAI。embedding 和 LLM 的 base URL、API key 都独立配置,任何兼容 OpenAI 接口的服务都能接:vLLM、Ollama、Azure OpenAI、各种国产大模型 API。这在企业环境里几乎是刚需——你不一定能直连 OpenAI。

HNSW 索引——为什么不用 IVFFlat?

CREATE INDEX idx_rag_docs_embedding
ON rag_documents
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);

pgvector 提供两种 ANN 索引:

IVFFlat HNSW
原理 把向量分成若干簇,查询时只搜最近的几个簇 分层图结构,从顶层逐层逼近最近邻
建索引速度 快(需要先有数据) 慢(但可以边插入边建)
查询精度 取决于 probes 参数 取决于 ef_search 参数
适合场景 数据量大、批量导入后查询 持续有新数据写入、对精度要求高

我的建议:除非你有明确的理由用 IVFFlat(比如数据一次性导入且量级千万以上),否则默认用 HNSW。它更简单,增量写入友好,精度也更好。

HNSW 的两个关键参数: - m(默认 16):每个节点的最大连接数。越大精度越高,但索引越大、建索引越慢 - ef_construction(默认 64):建索引时的搜索宽度。越大精度越高,建索引越慢

查询时还有一个运行时参数:

SET hnsw.ef_search = 100;  -- 默认 40,越大越精确但越慢

距离运算符——选哪个?

  • <=>(余弦距离):最常用,适合文本 embedding。不受向量长度影响,只看方向
  • <->(欧氏距离):适合已经归一化的向量,或者空间位置类的数据
  • <#>(负内积):适合推荐系统中需要考虑向量长度("热度")的场景

99% 的 RAG 场景用 <=> 就对了。

分块策略——已经内置了

代码里的 chunk_text 已经实现了语义感知的切块:按段落和句子边界断句,带 50 字符的重叠窗口。这和 LangChain 的 RecursiveCharacterTextSplitter 思路一致,但不需要额外依赖。

如果你的文档格式更复杂(比如有代码块、表格),可能需要进一步定制分块逻辑——比如整个代码块不拆分,表格按行切而不是按字符切。


性能调优备忘录

跑通了是第一步,跑得好是另一回事。几个容易忽略的点:

1. 向量维度选择

模型 维度 适合场景
text-embedding-3-small 1536 通用,精度好
text-embedding-3-large 3072 精度更好,但存储和计算翻倍
all-MiniLM-L6-v2 384 本地部署,速度快
bge-small-zh-v1.5 512 中文专用,效果好

维度越高,精度通常越好,但存储和检索成本也线性增长。先用小维度验证效果,不够再换大的。

2. 批量操作

插入时一定要用批量 API,不要一条一条插。OpenAI 的 embedding API 支持一次传多条文本(上限 2048 条),网络开销差了几十倍。

3. 预热查询

HNSW 索引第一次查询会比较慢(需要加载到内存)。可以在服务启动后发一个 dummy 查询预热。

4. 监控索引大小

SELECT pg_size_pretty(pg_relation_size('idx_rag_docs_embedding'));

如果索引已经比你的内存大了,查询性能会断崖式下跌。这时候要么加内存,要么考虑分表。


pgvector vs. Elasticsearch vs. 专用向量数据库:怎么选?

我之前写过一篇 用 Elasticsearch 做 RAG 的文章,核心观点是"别只盯着向量数据库,ES 的混合检索才是扫地僧"。现在又写了 pgvector 这篇,难免有读者问:那我到底该用哪个?

先上对比表,再说判断逻辑:

考虑因素 pgvector (PostgreSQL) Elasticsearch Pinecone / Milvus 等
检索能力 纯向量检索(余弦/欧氏/内积) 混合检索(BM25 + Vector + RRF 融合) 向量检索为主,部分支持混合
数据规模 < 500 万条够用 千万级别没问题 千万到十亿级别
运维成本 复用现有 PG,几乎零成本 需要 JVM 调优、集群管理 SaaS 免运维,但价格不透明
数据一致性 ACID,天然和业务表 JOIN 近实时(~1s 延迟) 最终一致性
关键词精确匹配 需要自己加 WHERE / tsvector 原生 BM25,开箱即用 弱,或不支持
字段加权 / 时间衰减 需要自己实现 Function Score 原生支持 部分支持
结构化过滤 SQL WHERE 子句,灵活 bool query(must/should/filter) 元数据过滤,能力有限
可视化监控 pgweb 或 DBeaver Kibana,功能强大 各家仪表盘
学习成本 会 SQL 就行 ES DSL 有一定门槛 各家 SDK
费用 几乎为零(自托管) 自托管免费,Cloud 按量计费 SaaS 按量,容易超支

我的判断逻辑

这三个方案不是互相替代的关系,而是不同阶段、不同场景的最优解

选 pgvector 的场景: - 你已经有 PostgreSQL,不想引入新组件 - 数据量在百万级以下 - 检索场景以语义搜索为主,不需要复杂的关键词匹配 - 团队小,运维资源有限 - 需要向量数据和业务数据做 JOIN

选 Elasticsearch 的场景: - 用户的查询里经常包含精确关键词(错误码、订单号、专有名词) - 需要混合检索(BM25 + Vector),而且需要调权重、加时间衰减 - 数据需要按部门、角色、时间范围做结构化过滤 - 你需要 Kibana 来监控数据质量和检索效果 - 已经有 ES 集群在运行

选专用向量数据库的场景: - 数据量到了千万甚至亿级 - 需要多租户隔离、自动 sharding - 团队规模足够大,能承担额外运维成本

一句话总结:pgvector 是"穷人的向量数据库"(褒义),Elasticsearch 是"全能选手",Pinecone/Milvus 是"重型武器"。 先从 pgvector 开始跑通,检索质量不够再上 ES 的混合检索,数据量爆炸了再考虑专用方案。

相关阅读:RAG 进阶指南:别只盯着向量数据库,Elasticsearch 才是扫地僧


踩坑笔记

几个我和同事实际踩过的坑,省得你再来一遍:

  1. 忘了 CREATE EXTENSION vector:连接数据库后第一件事就是执行这个,否则所有 vector 类型的操作都会报错

  2. embedding 维度对不上:表定义的是 vector(1536),但你换了一个输出 384 维的模型。pg 会直接报错。示例代码里的 _detect_embedding_dim() 能自动探测并在维度不匹配时告警,但已有数据需要 reset 后重建

  3. IVFFlat 索引要求先有数据:建 IVFFlat 索引前表不能为空,因为它需要先做聚类。HNSW 没这个限制

  4. psycopg2 不认识 vector 类型:查询结果里的 vector 列会返回字符串 "[0.1, 0.2, ...]"。用 pgvector 这个 Python 包可以自动注册类型适配器

  5. Docker 网络里的 hostname:在 docker-compose 里,服务间通信用服务名(如 pgvector)作为 hostname,但从宿主机连要用 localhost


总结

@startmindmap
* pgvector 做 RAG
** 为什么用 pgvector
*** 复用 PostgreSQL
*** ACID + JOIN
*** 零额外成本
*** 会 SQL 就行
** 核心步骤
*** 部署 pgvector (Docker)
*** 创建 vector 列(自动探测维度)
*** 建 HNSW 索引
*** 语义分块 + 算 embedding
*** 余弦相似度检索
*** 拼 prompt → LLM
** CLI 工具
*** index: 索引 Markdown 目录
*** query: 单次 RAG 查询
*** chat: 交互式聊天
*** stats: 知识库统计
*** reset: 清空重建
** 性能优化
*** 选对 embedding 维度
*** 批量插入(batch 64)
*** HNSW 参数调优
*** 预热索引
** 适用边界
*** < 500 万条:pgvector
*** > 千万条:专用向量库
*** 需要 JOIN:pgvector
*** 多租户/sharding:专用
** 踩坑清单
*** CREATE EXTENSION 别忘
*** 维度不匹配要 reset
*** IVFFlat 需要先有数据
*** Docker 网络 hostname
@endmindmap

pgvector RAG 思维导图

明天就能做的 CheckList

  • [ ] docker compose up -d 启动 pgvector 和 pgweb
  • [ ] 配好 .env(数据库连接 + embedding/LLM API)
  • [ ] python pgvector_rag_demo.py index ./your-docs/ 索引你的 Markdown 文档
  • [ ] python pgvector_rag_demo.py chat 交互式验证检索效果
  • [ ] python pgvector_rag_demo.py stats 确认索引数据量
  • [ ] 用 EXPLAIN ANALYZE 检查查询是否走了 HNSW 索引
  • [ ] 如果要省钱,把 EMBEDDING_BASE_URL 指向本地部署的模型服务

一个开放问题

当你的 RAG 系统检索到了相关文档,但 LLM 生成的回答仍然不对——你怎么诊断是检索的问题还是生成的问题?这个问题没有标准答案,但值得你认真想想。也许答案在于:先把检索和生成拆开来分别评估。


扩展阅读



本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。