为什么你的 RAG 答不准

很多团队做完 RAG 的 demo 后上线,发现效果不稳定:问题稍微换个说法就检索不到。根因通常有三条:

  1. 切分策略粗糙:按固定字符数硬切,把一句话拆成两半;
  2. 只用向量检索:语义相似但关键词不匹配,尤其对型号、代码、专有名词效果差;
  3. 没有重排序:向量召回的 Top-K 里前几条往往不是最相关的。

下面用 PostgreSQL + pgvector 搭一套可直接上线的方案。

一、表结构与索引

CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pg_trgm; -- 模糊匹配

CREATE TABLE chunks (
  id          bigserial PRIMARY KEY,
  doc_id      text NOT NULL,
  title       text,
  content     text NOT NULL,
  tokens      int,
  -- 假设使用 1024 维嵌入模型
  embedding   vector(1024) NOT NULL,
  tsv         tsvector GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED,
  created_at  timestamptz DEFAULT now()
);

-- HNSW 索引:召回快、构建慢,适合读多写少
CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 64);

-- 全文索引:关键词兜底
CREATE INDEX ON chunks USING gin (tsv);

-- 元数据索引
CREATE INDEX ON chunks (doc_id);

调参提示:m 越大图越密、召回越高但内存越多;查询期用 SET hnsw.ef_search = 100 在召回与延迟间权衡。

二、混合检索:向量 + 全文 + RRF 融合

-- 使用 RRF(Reciprocal Rank Fusion)融合两路召回
WITH vector_search AS (
  SELECT id, ROW_NUMBER() OVER (ORDER BY embedding <=> :qvec) AS rank
  FROM chunks
  ORDER BY embedding <=> :qvec
  LIMIT 30
),
keyword_search AS (
  SELECT id, ROW_NUMBER() OVER (ORDER BY ts_rank_cd(tsv, plainto_tsquery('simple', :q)) DESC) AS rank
  FROM chunks
  WHERE tsv @@ plainto_tsquery('simple', :q)
  LIMIT 30
)
SELECT c.id, c.title, c.content,
       1.0 / (60 + vector_search.rank) * 1.0
     + 1.0 / (60 + keyword_search.rank) * 1.0 AS score
FROM chunks c
LEFT JOIN vector_search ON c.id = vector_search.id
LEFT JOIN keyword_search ON c.id = keyword_search.id
WHERE vector_search.id IS NOT NULL OR keyword_search.id IS NOT NULL
ORDER BY score DESC
LIMIT 12;

RRF 的好处是不需要归一化两路的分数,只用排名,鲁棒且实现简单。

三、重排序:把 Top-12 精炼成 Top-4

import httpx

RERANK_URL = "http://localhost:8000/rerank"

async def rerank(query: str, docs: list[str], top_k: int = 4) -> list[int]:
    """返回重排序后的原始下标"""
    async with httpx.AsyncClient(timeout=10) as client:
        r = await client.post(RERANK_URL, json={
            "model": "bge-reranker-v2-m3",
            "query": query,
            "documents": docs,
            "top_k": top_k,
        })
        r.raise_for_status()
        return [item["index"] for item in r.json()["results"]]

async def retrieve(query: str) -> list[dict]:
    qvec = await embed(query)
    rows = await db.fetch(HYBRID_SQL, {"qvec": qvec, "q": query})

    idx = await rerank(query, [r["content"] for r in rows], top_k=4)
    return [rows[i] for i in idx]

交叉编码器(cross-encoder)会把 query 与文档拼在一起过一遍模型,精度远高于双塔向量,但代价是慢——所以它只用于重排少量候选,而不是全库检索。

四、切分策略:按语义而非字数

import re

def split_markdown(text: str, max_len: int = 512, overlap: int = 64) -> list[str]:
    """优先按标题切,其次按段落,最后才按长度兜底"""
    blocks = re.split(r"\n(?=#{1,6} )", text)
    chunks: list[str] = []

    for b in blocks:
        if len(b) <= max_len:
            chunks.append(b.strip())
            continue

        buf = ""
        for para in b.split("\n\n"):
            if len(buf) + len(para) > max_len and buf:
                chunks.append(buf.strip())
                buf = buf[-overlap:]  # 保留尾部重叠
            buf += para + "\n\n"
        if buf.strip():
            chunks.append(buf.strip())

    return [c for c in chunks if c]

经验值:中文场景 300~500 字一块、10%~15% 重叠效果较好;每块额外拼上标题与上级标题,能显著提升召回。

五、评估:别靠感觉

# 用召回率@k 与答案正确率两个指标分别评估
async def evaluate(dataset: list[dict]) -> dict:
    hit = 0
    for case in dataset:
        got = await retrieve(case["question"])
        ids = {r["doc_id"] for r in got}
        if case["gold_doc_id"] in ids:
            hit += 1
    return {"recall@4": hit / len(dataset)}

准备 50~200 条真实问题 + 标注答案文档,每次改动切分/索引/重排策略后跑一遍,用数字驱动优化,而不是靠“问几句试试”。

六、工程细节

  • 嵌入模型版本化:换模型要重建向量列,建议把模型名写进表字段,支持双写灰度。
  • 批量写入:先 COPY 导入再建索引,比逐条 insert 快一个数量级。
  • 连接复用:检索是高频路径,务必使用连接池并预热。

小结

RAG 效果的差距,往往不在“换更大的模型”,而在切分是否合理、召回是否多路、是否重排。pgvector 让这套方案不需要引入额外基础设施,对已有 PostgreSQL 的团队来说是成本最低的选择。