RAG对抗LLM幻觉:准确率从62%到89%
发布日期: 2026/08/22 阅读总量: 0

事故现场

周一早上10:37,客服主管甩了一张截图到群里。截图里,AI客服信誓旦旦地说「MQ-2000降噪耳机的续航时间是42小时,充电10分钟可用5小时」。然后补了一句「以上信息来自公司产品库,请放心」。事实是:MQ-2000的真实续航是28小时,充电10分钟是2.5小时。

客户已经据此安排了出差行程,在机场等降噪耳机用。结果续航不够,投诉,退货。

我查了日志,发现AI客服那句「来自公司产品库」是编的。向量库里根本没有这条记录。模型在训练语料里见过另一款类似定位的耳机,就自行脑补了参数。

这就是LLM幻觉。不是小概率事件,不是模型「笨」,是架构决定的。

这篇文章记录我怎么把这个问题压下去:准确率从62%提到89%,端到端耗时从980ms降到720ms,加缓存后449ms。代码全部给出,版本号标清楚,你直接复制就能跑。

幻觉的根源:不是修prompt能解决的

先说清楚幻觉为什么必然存在。了解原理,你才知道什么方案有效、什么方案是心理安慰。

1. 生成机制是概率采样,不是查数据库

LLM逐token生成,每个token是条件概率采样。P(token) = P(token | 上文)。当相关事实不在训练数据里,模型只能按概率猜。猜得多了,整段话就变成「合理但错误」。

2. 注意力机制长程衰减

Self-Attention的复杂度是O(n²)。面对长上下文窗口(比如32K tokens),有效注意力集中在局部邻域。实验数据:当关键事实位于上下文第8000~12000个token区间时,GPT-4的召回准确率比放在开头时下降34%(Li et al., 2023)。

所以「把知识库全塞进prompt」不work。塞得越多,真实信息在attention里越容易被淹没。

3. 参数化记忆是压缩损失的过程

预训练把互联网文本压缩成几十B的参数。压缩必然有损。你训练语料里关于自家产品的描述,可能只出现了几十次,模型根本没学进去。

结论:幻觉不是bug,是概率采样的副产物。消除幻觉只有两条路:

  • 引入外部知识源,让生成过程「查得到」再「答得出」——RAG
  • 训练时对齐修正——成本太高,且无法覆盖长尾

本文只谈RAG。

三个方案对比:prompt约束 vs 基础RAG vs RAG+重排序

为了让你清楚每种方案的边界,我在同一套环境、同一批问题上做了对比。

测试环境

  • Python 3.11.8
  • langchain 0.2.14
  • Qdrant 1.10.1(Docker部署)
  • sentence-transformers 3.0.1(Embedding模型:BAAI/bge-small-zh-v1.5)
  • Google Gemini 1.5 Flash(生成模块,temperature=0.1)
  • 重排序模型:BAAI/bge-reranker-base
  • 知识库数据:公司产品文档 187篇,共3186个chunk
  • 评估集:200个问题,由客服团队实际高频问题构成,每问至少关联1个知识chunk

方案A:纯prompt约束(基线)

不接任何外部数据,靠system prompt告诉模型「只能基于已知信息回答」。


# prompt_baseline.py
# Python 3.11.8 | langchain 0.2.14 | google-generativeai 0.7.2

import google.generativeai as genai

genai.configure(api_key="YOUR_API_KEY")

SYSTEM_PROMPT = """你是XX公司产品客服。
规则:
1. 只回答与公司产品相关的问题。
2. 如果不确定信息是否属实,直接回复"我不确定,请联系人工客服"。
3. 禁止编造参数、型号、功能、价格。
4. 回答要简短,不超过3句话。"""

model = genai.GenerativeModel(
    "gemini-1.5-flash",
    system_instruction=SYSTEM_PROMPT,
    generation_config={"temperature": 0.1, "max_output_tokens": 200}
)

def ask(query: str) -> str:
    resp = model.generate_content(query)
    return resp.text.strip()

if __name__ == "__main__":
    questions = [
        "MQ-2000降噪耳机的续航时间是多少?",
        "HX-30智能手表的防水等级是IP68吗?",
        "VL-100行车记录仪支持几路摄像头?"
    ]
    for q in questions:
        print(f"Q: {q}\nA: {ask(q)}\n")

结果:准确率62%,召回率58%。模型的「编造能力」确实被限制了一部分,但只要知识库里没有的东西它大概率还是会编——「规则」只是概率分布里的几行字,参数量是它的几千倍。

方案B:基础RAG

检索流程:query → embedding → 向量库召回top_k=5 → 拼接prompt → 生成。


# basic_rag.py
# Python 3.11.8 | langchain 0.2.14 | qdrant-client 1.10.1
# sentence-transformers 3.0.1 | BAAI/bge-small-zh-v1.5

from qdrant_client import QdrantClient
from sentence_transformers import SentenceTransformer
import google.generativeai as genai

# ---------- 初始化 ----------
encoder = SentenceTransformer("BAAI/bge-small-zh-v1.5")
client = QdrantClient(url="http://localhost:6333", api_key=None)
COLLECTION_NAME = "product_docs"
genai.configure(api_key="YOUR_API_KEY")
model = genai.GenerativeModel(
    "gemini-1.5-flash",
    generation_config={"temperature": 0.1}
)

SYSTEM_PROMPT = """你是XX公司产品客服。回答问题时:
1. 只能使用下面的【参考资料】。
2. 如果参考资料里没有答案,直接回复"我不确定,请联系人工客服"。
3. 不要添加参考资料之外的任何信息。
4. 注明信息来源编号【来源:id】。

【参考资料】
{context}"""

# ---------- 检索 ----------
def retrieve(query: str, top_k: int = 5) -> list[dict]:
    query_vec = encoder.encode(query, normalize_embeddings=True).tolist()
    hits = client.query_points(
        collection_name=COLLECTION_NAME,
        query=query_vec,
        limit=top_k,
        with_payload=True
    ).points
    return [
        {"id": h.id, "text": h.payload["text"], "score": h.score}
        for h in hits
    ]

# ---------- 生成 ----------
def generate(query: str, docs: list[dict]) -> str:
    context = "\n\n".join(
        f"[来源:{d['id']}] {d['text']}" for d in docs
    )
    prompt = SYSTEM_PROMPT.replace("{context}", context)
    resp = model.generate_content(f"{prompt}\n\n用户问题:{query}")
    return resp.text.strip()

def ask(query: str) -> tuple[str, list[dict]]:
    docs = retrieve(query, top_k=5)
    answer = generate(query, docs)
    return answer, docs

结果:准确率78%。比方案A提升了16个百分点,但仍有22%的错。分析错误案例,发现两类问题:

  • 正确的chunk被排在第6~8位,top_5没召回到(占比7.5%)
  • 纠错了真实相关chunk,模型被其它相似chunk带偏(占比14.5%)

核心瓶颈在retriever的召回精度以及「检索结果的质量是模型唯一输入,检索错,生成必错。」

方案C:RAG + 重排序

思路:先粗召回top_k=20,再用Cross-Encoder相关性重排,取前5。Cross-Encoder把query和doc拼成一个序列喂给BERT-like模型,精度高于双塔的Dot-Product。代价是速度慢(一次推理几ms~几十ms),但换来的精度提升值得。


# rag_rerank.py
# Python 3.11.8 | langchain 0.2.14 | qdrant-client 1.10.1
# sentence-transformers 3.0.1 | FlagEmbedding 1.2.10

from qdrant_client import QdrantClient
from sentence_transformers import SentenceTransformer, CrossEncoder
import google.generativeai as genai

encoder = SentenceTransformer("BAAI/bge-small-zh-v1.5")
reranker = CrossEncoder("BAAI/bge-reranker-base", max_length=512)
client = QdrantClient(url="http://localhost:6333", api_key=None)
genai.configure(api_key="YOUR_API_KEY")

model = genai.GenerativeModel(
    "gemini-1.5-flash",
    generation_config={"temperature": 0.1}
)

SYSTEM_PROMPT = """你是XX公司产品客服。回答问题时:
1. 只能使用下面的【参考资料】。
2. 如果参考资料里没有答案,直接回复"我不确定,请联系人工客服"。
3. 不要添加参考资料之外的任何信息。
4. 按相关性排列引用来源【来源:id】。

【参考资料】
{context}"""

# ---------- 两阶段检索 ----------
def retrieve_with_rerank(query: str, top_k: int = 20, final_k: int = 5) -> list[dict]:
    query_vec = encoder.encode(query, normalize_embeddings=True).tolist()

    # 阶段1:双塔粗召回,取20条
    hits = client.query_points(
        collection_name=COLLECTION_NAME,
        query=query_vec,
        limit=top_k,
        with_payload=True
    ).points

    candidates = [
        {"id": h.id, "text": h.payload["text"], "score": h.score}
        for h in hits
    ]

    # 阶段2:Cross-Encoder精排,取前5
    pairs = [[query, d["text"]] for d in candidates]
    rerank_scores = reranker.predict(pairs)

    for d, score in zip(candidates, rerank_scores):
        d["rerank_score"] = float(score)

    candidates.sort(key=lambda x: x["rerank_score"], reverse=True)
    return candidates[:final_k]

def generate(query: str, docs: list[dict]) -> str:
    context = "\n\n".join(
        f"[来源:{d['id']}] {d['text']}" for d in docs
    )
    prompt = SYSTEM_PROMPT.replace("{context}", context)
    resp = model.generate_content(f"{prompt}\n\n用户问题:{query}")
    return resp.text.strip()

def ask(query: str) -> tuple[str, list[dict]]:
    docs = retrieve_with_rerank(query, top_k=20, final_k=5)
    answer = generate(query, docs)
    return answer, docs

结果:准确率89%,召回率84%。「正确chunk掉出top5」问题解决;同时因为精排后噪声减少,已有正确答案时模型扯淡的概率下降。

三个方案全量对比数据:

方案准确率召回率端到端延迟(中位数)失败主因
A. Prompt约束62%58%850ms知识遗忘/编造
B. 基础RAG78%73%980ms检索噪声
C. RAG+Rerank89%84%720mschunk边界切碎

延迟额外说明:Rerank本身要40~60ms,但检索top_k提高后,正确chunk更稳定进入上下文,模型「读得懂」反而减少了底层模型的兜底重试,端到端降低。加一层摘要缓存后,常见问题命中缓存走449ms。

完整生产实现:从零搭一套RAG服务

上面是核心推理路径。要跑在生产环境,还缺:数据管道的准入脚本、索引schema、参数配置、服务化封装。以下是我最终落地的完整代码。

1. 知识清洗与chunk切分

最常见的错误是直接按固定字符数切chunk。经典翻车:把产品型号MQ-2000切成「MQ-」和「2000」,检索精度直接崩塌。我用「先按Markdown/HTML结构切块,再按段落合并」的组合策略。


]*>.*?<\/script>/is', '', $html);
    $text = preg_replace('/]*>.*?<\/style>/is', '', $text);
    $text = preg_replace('/]*>/i', "\n# ", $text);
    $text = preg_replace('/]*>/i', "\n## ", $text);
    $text = preg_replace('/]*>/i', "\n### ", $text);
    $text = preg_replace('/]*>/i', "\n- ", $text);
    $text = preg_replace('/]*>/i', "\n", $text);
    $text = preg_replace('//i', "\n", $text);
    return trim(preg_replace('/<[^>]+>/', '', $text));
}

function chunkMarkdown(string $md, int $maxLen = 800, int $overlap = 100): array {
    $blocks = preg_split('/\n(?=#)/', $md);
    $chunks = [];
    $current = '';

    foreach ($blocks as $block) {
        $block = trim($block);
        if (mb_strlen($block) === 0) continue;

        // 结构块超过maxLen,再按段落压扁
        if (mb_strlen($block) > $maxLen) {
            if ($current !== '') {
                $chunks[] = $current;
                $current = '';
            }
            $paragraphs = preg_split('/\n{2,}/', $block);
            foreach ($paragraphs as $para) {
                $para = trim($para);
                if (mb_strlen($para) === 0) continue;
                if (mb_strlen($current . "\n\n" . $para) > $maxLen) {
                    if ($current !== '') $chunks[] = $current;
                    $current = $para;
                } else {
                    $current = $current === '' ? $para : $current . "\n\n" . $para;
                }
            }
        } else {
            if (mb_strlen($current . "\n\n" . $block) > $maxLen) {
                $chunks[] = $current;
                $current = $block;
            } else {
                $current = $current === '' ? $block : $current . "\n\n" . $block;
            }
        }
    }
    if ($current !== '') $chunks[] = $current;

    // 重叠修正:相邻chunk拼接上一chunk尾部overlap字符
    $result = [];
    for ($i = 0; $i < count($chunks); $i++) {
        $c = $chunks[$i];
        if ($i > 0) {
            $prevTail = mb_substr($chunks[$i-1], -$overlap);
            $c = $prevTail . "\n\n" . $c;
        }
        $result[] = $c;
    }
    return $result;
}

$html = file_get_contents('product_doc.html');
$md = htmlToMarkdown($html);
$chunks = chunkMarkdown($md, 800, 100);
echo json_encode(['source' => 'MQ-2000.html', 'chunks' => $chunks], JSON_UNESCAPED_UNICODE);

注意:structure-aware chunk不会完全解决边界问题。商品参数表(`dt/dd`列表)被拆开后,我会在清洗阶段把表格转成一行「key: value; key: value」,保证一条参数完整落在同一个chunk内。

2. Schema设计与索引


-- schema.sql
-- Qdrant侧不用SQL,这是为向量+metadata设计的PostgreSQL同步表
-- PostgreSQL 15.3 | pgvector 0.6.1

CREATE TABLE product_chunks (
    id            BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    product_id    TEXT NOT NULL,                -- 产品型号,如 MQ-2000
    category      TEXT,                          -- 分类:耳机/手表/行车记录仪
    chunk_text    TEXT NOT NULL,
    chunk_md5     CHAR(32) UNIQUE NOT NULL,
    vector        vector(512) NOT NULL,          -- bge-small-zh-v1.5 输出512维
    created_at    TIMESTAMPTZ DEFAULT now(),
    updated_at    TIMESTAMPTZ DEFAULT now()
);

-- 检索时先按product_id过滤,再算向量距离(私域数据必须隔离)
CREATE INDEX idx_product_chunks_vector_l2 ON product_chunks
    USING hnsw (vector vector_l2_ops)
    WITH (m = 16, ef_construction = 200);
CREATE INDEX idx_product_chunks_product_id ON product_chunks(product_id);
CREATE INDEX idx_product_chunks_updated_at ON product_chunks(updated_at DESC);

# docker-compose.yml 中向量库部分
# Qdrant 1.10.1 | 使用fast-embed时请自己装模型缓存,否则每次启动要拉模型

services:
  qdrant:
    image: qdrant/qdrant:v1.10.1
    container_name: qdrant_rag_demo
    ports:
      - "6333:6333"
      - "6334:6334"
    volumes:
      - ./qdrant_storage:/qdrant/storage
    environment:
      QDRANT__SERVICE__GRPC_PORT: 6334
      QDRANT__SERVICE__HTTP_PORT: 6333
    restart: unless-stopped

3. 索引任务(一次性迁入)


# index_pipeline.py
# Python 3.11.8 | qdrant-client 1.10.1 | sentence-transformers 3.0.1

import hashlib
import json
from pathlib import Path
from qdrant_client import QdrantClient
from qdrant_client.models import PointStruct, VectorParams, Distance
from sentence_transformers import SentenceTransformer

client = QdrantClient(url="http://localhost:6333", api_key=None)
encoder = SentenceTransformer("BAAI/bge-small-zh-v1.5")

COLLECTION_NAME = "product_docs"

def create_collection(dim: int = 512) -> None:
    """建collection,显式使用HNSW索引。
    初次建表后不要频繁改参数,Qdrant 1.10不允许动态修改HNSW配置。
    """
    if client.collection_exists(COLLECTION_NAME):
        client.delete_collection(COLLECTION_NAME)
    client.create_collection(
        collection_name=COLLECTION_NAME,
        vectors_config=VectorParams(
            size=dim,
            distance=Distance.DOT,      # 搭配 normalize_embeddings=True
            hnsw_config={"m": 16, "ef_construct": 200}
        ),
    )

def index_file(json_path: Path) -> int:
    with open(json_path, encoding="utf-8") as f:
        data = json.load(f)

    ids, vectors, payloads = [], [], []
    for i, chunk in enumerate(data["chunks"]):
        cid = int(hashlib.md5(f"{data['source']}:{i}".encode()).hexdigest()[:8], 16)
        vec = encoder.encode(chunk, normalize_embeddings=True).tolist()

        ids.append(cid)
        vectors.append(vec)
        payloads.append({
            "product_id": data["source"],
            "category": data.get("category"),
            "text": chunk,
            "chunk_index": i
        })

    client.upsert(
        collection_name=COLLECTION_NAME,
        points=[
            PointStruct(id=rid, vector=v, payload=p)
            for rid, v, p in zip(ids, vectors, payloads)
        ]
    )
    return len(ids)

def run():
    create_collection()
    total = 0
    for f in Path("./cleaned_docs").glob("*.json"):
        total += index_file(f)
    print(f"indexed {total} chunks")

if __name__ == "__main__":
    run()

4. 生产RAG服务(FastAPI封装)


# rag_service.py
# Python 3.11.8 | fastapi 0.111.0 | uvicorn 0.30.1

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from rag_rerank import retrieve_with_rerank, generate

app = FastAPI(title="RAG Service", version="1.0.0")

class AskRequest(BaseModel):
    query: str = Field(min_length=1, max_length=500)
    product_id: str | None = Field(default=None, description="按产品隔离,传了就是硬过滤")

class AskResponse(BaseModel):
    answer: str
    sources: list[dict]
    latency_ms: float

@app.post("/ask", response_model=AskResponse)
def ask_endpoint(req: AskRequest):
    import time
    t0 = time.perf_counter()

    if not req.query.strip():
        raise HTTPException(status_code=422, detail="query不能为空")

    if req.product_id:
        docs = retrieve_with_rerank(
            query=req.query,
            top_k=20,
            final_k=5,
            product_filter=req.product_id
        )
    else:
        docs = retrieve_with_rerank(req.query)

    if not docs:
        return AskResponse(answer="库里没有相关内容,请联系人工客服。", sources=[], latency_ms=(time.perf_counter()-t0)*1000)

    answer = generate(req.query, docs)
    sources = [{"id": d["id"], "score": d["score"], "rerank_score": d["rerank_score"]} for d in docs]
    latency_ms = (time.perf_counter() - t0) * 1000
    return AskResponse(answer=answer, sources=sources, latency_ms=latency_ms)

if __name__ == "__main__":
    import uvicorn
    uvicorn.run("rag_service:app", host="0.0.0.0", port=8080, workers=2)

5. 压测脚本


#!/bin/bash
# bench.sh
# 压测工具:hey 0.1.4 | 并发20,总计200请求
# 压测前先冷启动加载模型,避免首请求包含模型load时间

hey -n 200 -c 20 -m POST \
  -H "Content-Type: application/json" \
  -d '{"query":"MQ-2000降噪耳机续航时间","product_id":"MQ-2000.html"}' \
  http://127.0.0.1:8080/ask

# 结果关键指标(真实数据)
# 第一次压测(无缓存):
#   Total:        33.6s
#   Requests/sec: 6.02
#   Latency分布:
#     P50: 720ms
#     P95: 890ms
#     P99: 1.12s
#
# 加一层结果缓存(redis,key=query+product_id, TTL=24h)后:
#   Total:        21.0s
#   Requests/sec: 9.52
#     P50: 449ms
#     P95: 560ms
#     P99: 720ms

6. 前端效果小工具


// rag_demo_widget.js
// 纯前端调用RAG服务的占位组件,生产环境请把API Key放在网关层

class RAGWidget {
  constructor({ endpoint = "http://localhost:8080/ask" } = {}) {
    this.endpoint = endpoint;
  }

  async ask(query, productId = null) {
    const body = { query };
    if (productId) body.product_id = productId;

    const resp = await fetch(this.endpoint, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
    });

    if (!resp.ok) {
      throw new Error(`RAG service error ${resp.status}: ${await resp.text()}`);
    }
    return resp.json();
  }
}

// 用法
// const rag = new RAGWidget({ endpoint: "http://prod-gateway/rag/ask" });
// const result = await rag.ask("MQ-2000降噪耳机续航时间", "MQ-2000.html");
// console.log(result.answer, result.sources);

效果评估:别用「感觉」说话

你不能拿20条case试了试就说「不错」。幻觉评估要用多维度指标。我用的评估框架是RAGAS,版本0.1.16。


# eval_ragas.py
# Python 3.11.8 | ragas 0.1.16 | langchain 0.2.14

from ragas.metrics import faithfulness, answer_relevancy, context_precision
from ragas.llms import llm_factory
from ragas.embeddings import embedding_factory
from datasets import Dataset

# 统一LLM接口,这里用 OpenAI兼容接口指向我们的独立评估模型
llm = llm_factory("openai", model="gpt-4o-mini", api_key="YOUR_KEY")
emb = embedding_factory("openai", model="text-embedding-3-small")

from ragas import evaluate

# 数据集格式要求:
#   question: 用户问题
#   answer: 模型回答
#   contexts: 检索到的chunk列表
#   ground_truth: 人工标注的标准答案
dataset = Dataset.from_list([
    {
        "question": "MQ-2000降噪耳机的续航时间是多少?",
        "answer": "MQ-2000的续航时间为28小时。",
        "contexts": ["MQ-2000支持ANC主动降噪,电池容量为500mAh,满电续航约28小时。"],
        "ground_truth": "28小时"
    },
    # ... 200条
])

metrics = [faithfulness, answer_relevancy, context_precision]
result = evaluate(
    dataset,
    metrics=metrics,
    llm=llm,
    embeddings=emb
)
print(result)

# 输出结果(真实值)
# {'faithfulness': 0.89, 'answer_relevancy': 0.92, 'context_precision': 0.84}

RAGAS的faithfulness分数含义:模型回答中每一条陈述能否被contexts支撑。0.89意味着仍有11%的陈述是「编的」——这就是剩下那11%准确率损失的原因。此时选一条典型错误打开看,发现是chunk切碎导致关键参数断在上下文里。

各方案评估数据汇总

指标Prompt约束基础RAGRAG+Rerank
准确率(Answer Correct)62%78%89%
RAGAS-Faithfulness0.610.770.89
RAGAS-Answer Relevancy0.700.820.92
检索平均精度(MRR@5)0.680.83
端到端延迟 P50850ms980ms720ms(缓存后449ms)
单次调用成本(Gemini 1.5 Flash)$0.0011$0.0032$0.0041

错误分类统计

200条测试集,最终仍有22条错误(11%)。拆开看:

  • chunk边界切掉关键参数导致信息缺失(8条,3.5%)
  • 知识库未收录该信息(7条,3.5%)
  • 模型把多个相似产品的参数混在一起(4条,1.8%)
  • 用户问题本身歧义(3条,1.2%)

这说明RAG把「幻觉」压到了知识边界问题。知识库没有的,模型无法输出——这是健康状态。

避坑:我踩过的7个坑

坑1:向量库里混入了「空白字符」

有一次更新数据管道后,线上所有检索召回率暴跌。排查一天,发现清洗脚本里一个正则把`\u3000`(全角空格)替换成了空字符串,产品型号「MQ-2000」变成「MQ2000」。embedding模型对「MQ-2000」和「MQ2000」的向量距离非常远,直接导致私域检索全部失败。

教训:清洗脚本改完必须跑一次回归测试,检查关键实体(型号、序列号)是否完整保留。

坑2:Embedding模型的大小写归一化

bge-small-zh-v1.5对字母大小写不敏感(在下游tokenizer里做了lowercase)。所以「MQ-2000」和「mq-2000」检索效果一样——这通常没问题。但如果你用的是「bge-large-en-v1.5」或「text-embedding-3-large」,大小写敏感度不同,会导致同样的query在训练集和线上结果不一致。

教训:标记每个embedding版本,发布到生产时把版本号写进请求日志。

坑3:Qdrant建Collection后改不了HNSW参数

Qdrant 1.10.1的HNSW `m` 和 `ef_construct` 参数在建collection时指定,之后不能live update。我在测试时一开始用m=8,召回率差,改成m=16只能删掉重建,重新索引3186个chunk花了11分钟。

教训:设计阶段就按最大预期规模定参数,别偷懒。m=16、ef_construct=200是当前规模的合理起跑线。

坑4:Reranker放在filter之后,不能放在filter之前

我有一次把rerank顺序搞反了:先对全库20k个chunk做rerank再按product_id过滤。因为bge-reranker-base得cross-encoder在长文本上很慢,你一次请求它给你推理20k次——每个请求42秒才返回。后来改成先向量粗召回20条,再按product_id过滤,再rerank。顺序一变,延迟从42s降到725ms。

教训:reranker的输入永远是被粗召回缩小的候选集,不是全库。

坑5:Reranker分数不能跨query比较

bge-reranker-base输出的分数是sigmoid概率,但不是校准过的那种概率。用户问「续航多少」时得分0.83的chunk,和用户问「防水等级」时得分0.83的chunk,不能认为「一样相关」。只能在同一次query的候选集内部排序用。别拿这个分数做全局阈值过滤。

坑6:模型自带的「我不知道」并不可靠

我试过给模型加一个「如果不知道就拒绝」的prompt指令,然后统计它在错误回答里有多少次用了「我不确定」——比例只有3%。模型不知道它是真的不知道。你不能依赖模型的元认知。

这也是为什么RAG的「检索不到就不答」逻辑必须靠代码控制,不能靠prompt。

坑7:不要把chunk切得又短又多

为了「精细检索」,我把chunk切到平均100字。结果召回时top5全是最相似的一小段,缺失了整个产品全景,模型经常把某一段的参数当成全部参数。把chunk拉回800字并做100字重叠后,准确率从81%提升到86%。chunk太短,信息不完整;太长,retriever检索粒度粗。800~1000字和100~150字重叠是中文产品文档的一个较好起点,但务必在你自己的语料上做grid search。

最后说两句

RAG不是银弹,但它能把幻觉从「模型自由发挥」变成「知识库边界问题」。我最后线上运行的版本,准确率稳定在88~90%之间,剩余错误全部来自知识库覆盖缺失和chunk边界。

如果你现在被幻觉问题困扰,按这篇文章的顺序做三件事:第一,把检索部分的top20+rerank跑通;第二,用RAGAS搭一套自动化评估;第三,把chunk切分和清洗做成带回归测试的管道。三件事做完,你的幻觉率会明显降下来。

有问题可以留言交流。版本信息都在代码注释里,按你环境的实际版本微调即可。