一、为什么还是 RAG,不直接微调?
- 数据敏感 / 频繁更新:业务私有知识库变化快,RAG 的检索拼装相比微调上线周期更短。
- 成本与可控性:微调与持续再训练成本高,RAG 以结构化组件可局部优化(embedding、召回、重排、缓存等)。
- 合规与解释性:RAG 能回链路据(source of truth),更便于审计与解释。
结论:先用 RAG 快速可用,有稳定数据形态和 ROI 后,再考虑小范围指令微调/adapter。
二、目标与边界
我们要做一个文档问答型 RAG 应用:支持 PDF/Markdown/网页抓取,中文优先,返回答案 + 证据片段 ,带流式输出 、观测日志 、离线评测 和成本监控。
成功标准:
- Top-k 证据可读、命中率高于基线(FAQ 测试集)。
- 平均首 token 延迟 < 1.5s,完整回答 < 5s(中等上下文)。
- 召回/重排/生成各环节可独立替换,便于 A/B。
- 单次问答平均可变现成本 < ¥0.02--0.2(视模型与长度而定)。
三、系统架构(模块化)
scss
┌──────────┐
Query → │ API 层 │ ← Metrics/Tracing
└────┬─────┘
│
┌──────▼──────┐
│ Orchestrator│ (Prompt模板/上下文裁剪/策略)
└───┬─────┬───┘
│ │
┌─────────▼─┐ ┌─▼─────────┐
│ 检索层 │ │ 重排层 │
│(向量+BM25) │ │(Cross-Enc)│
└──────┬────┘ └─────┬──────┘
│ │
┌────▼────┐ ┌───▼─────┐
│ 知识库 │ │ 缓存层 │(答案/embedding)
│ (pgvector│ │ (Redis) │
└────┬─────┘ └───┬─────┘
│ │
┌───▼──────────────▼───┐
│ 生成层(LLM Provider)│
└───────────────────────┘
- 检索:向量召回(dense)+ BM25(sparse)混合,常见组合是 pgvector + Meilisearch/Elastic。
- 重排:Cross-Encoder(如 bge-reranker)把候选段落与问题两两打分。
- 生成:大模型(可选闭源/开源)。
- 缓存:问题归一化+Top-k 证据签名做二级缓存。
- 观测:请求链路、Token 用量、延迟分解、命中率、答案可用度反馈。
四、数据入库:清洗与切块(Chunking)
切块原则
- 以语义自然段 为主,配合滑窗(overlap 50--120 token)减少跨段断裂。
- 每块携带来源元数据:文档ID、页码、标题层级、更新时间、URL。
- 中文 PDF 优先抽文本层;无文本层走 OCR(注意版面噪声)。
- 统一字符集与标点(中文全角/半角),去脚注/页眉/目录噪声。
推荐切块大小:300--500 tokens;FAQ/条例可更小(150--300)。
五、落地选型(示例)
- 向量库:PostgreSQL + pgvector(易运维,事务一致性好;MVP 足够)
- 倒排检索:Meilisearch(部署轻 / 中文需要自定义分词)或 Elasticsearch(成熟但重)
- Embedding :开源
bge-m3
/bge-large-zh-v1.5
(中文强),或云端嵌入 API - 重排 :
bge-reranker-v2-m3
(CrossEncoder),小模型延迟低 - LLM:按预算切换(如中型闭源 8--32k ctx;或本地 Qwen/Llama 系列)
- 服务层:FastAPI(Python)+ Uvicorn
- 缓存:Redis(问答结果 / 片段集合签名)
- 观测:OpenTelemetry + Prometheus + Grafana(或直接接入 APM)
以上都是可替换件。先跑通,再迭代。
六、最小可用 Demo(FastAPI + pgvector)
1)数据库建表
sql
-- PostgreSQL
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY,
title TEXT,
url TEXT,
updated_at TIMESTAMP
);
CREATE TABLE IF NOT EXISTS chunks (
id TEXT PRIMARY KEY,
doc_id TEXT REFERENCES documents(id),
content TEXT NOT NULL,
meta JSONB,
embedding vector(1024) -- 视模型维度
);
-- 索引(ivfflat 需设置列表数,基于数据规模调参)
CREATE INDEX ON chunks USING ivfflat (embedding vector_l2_ops) WITH (lists = 100);
CREATE INDEX chunks_docid_idx ON chunks (doc_id);
2)入库脚本(简化)
python
# ingest.py
import json, uuid
import psycopg2, psycopg2.extras
from pathlib import Path
from some_embedder import embed # 替换为你的 embedding 调用
def chunk_text(text, size=400, overlap=80):
tokens = text.split()
out = []
for i in range(0, len(tokens), size - overlap):
out.append(" ".join(tokens[i:i+size]))
return out
conn = psycopg2.connect("postgresql://user:pwd@localhost:5432/rag")
cur = conn.cursor(cursor_factory=psycopg2.extras.DictCursor)
doc_id = str(uuid.uuid4())
cur.execute("INSERT INTO documents (id, title, url, updated_at) VALUES (%s,%s,%s,NOW())",
(doc_id, "示例文档", "https://example.com/doc",))
text = Path("sample.txt").read_text(encoding="utf-8")
for chunk in chunk_text(text):
vec = embed(chunk) # list[float]
cur.execute("""
INSERT INTO chunks (id, doc_id, content, meta, embedding)
VALUES (%s,%s,%s,%s,%s)
""", (str(uuid.uuid4()), doc_id, chunk, json.dumps({"source":"sample.txt"}), vec))
conn.commit()
3)检索 + 重排 + 生成(API)
python
# app.py
from fastapi import FastAPI
from pydantic import BaseModel
import psycopg2, psycopg2.extras
from some_embedder import embed
from cross_encoder import rerank # 返回[(chunk, score), ...]
from llm import generate_stream # 生成器,yield tokens
app = FastAPI()
conn = psycopg2.connect("postgresql://user:pwd@localhost:5432/rag")
class Q(BaseModel):
query: str
topk: int = 8
@app.post("/qa")
def qa(q: Q):
qvec = embed(q.query)
cur = conn.cursor(cursor_factory=psycopg2.extras.DictCursor)
cur.execute("""
SELECT content, meta
FROM chunks
ORDER BY embedding <-> %s
LIMIT %s
""", (qvec, q.topk))
candidates = [{"content": r["content"], "meta": r["meta"]} for r in cur.fetchall()]
ranked = rerank(q.query, candidates, topn=4) # CrossEncoder 重排
context = "\n\n".join([c["content"] for c, _ in ranked])
prompt = f"""你是检索增强问答助手。结合下列资料回答问题,标注出引用顺序[1][2]:
资料:
{context}
问题:{q.query}
请给出中文答案,并在结尾附上引用列表。
"""
# 流式返回(伪代码)
return generate_stream(prompt, references=[c["meta"] for c, _ in ranked])
生产中需要:异常兜底、输入清洗、长度裁剪、流式 SSE、超时/重试、打点埋点。
七、评测与回归:离线 & 在线
1)构造评测集
- 从客服/搜索日志中抽取真实问题 (去隐私),人工配对目标答案 与应命中片段。
- 覆盖:事实性、枚举类、政策条款、歧义问题、极短与极长问题。
2)指标
- 检索:Recall@k、nDCG@k、命中段数/平均位置。
- 生成 :答案可用度 (3/5 分以上算可用)、事实性 (ClaimCheck/RAGAS 支持)、格式遵循度。
- 体验:TTFT(首 token 时间)、TBT(完整输出时间)、拒答率。
- 成本:平均 Token、平均请求费用,缓存命中率。
3)回归流程
- 每次更换 embedding / 重排 / 模型 / 切块策略 → 自动跑评测 → 导出对比报告。
- 线上观测:把用户"有帮助/无帮助"点选接回训练集(RLHF/分类器可选)。
八、提示词(Prompt)与裁剪策略
结构化提示(简化示例):
css
System:
你是企业知识库问答助手。必须基于"资料"回答,若资料无答案,请明确说"不确定"。
User:
问题:{query}
资料(按相关性降序):
[1] {chunk1}
[2] {chunk2}
[3] {chunk3}
要求:
- 先直接给出答案(不超过150字)。
- 然后列出要点(<=5条)。
- 最后输出"引用:[1][2]..."
- 如果不确定,请输出"需要更多资料"并建议下一步。
裁剪要点
- 先重排再拼接,避免把低质片段塞进上下文。
- 控制上下文总长(<= 模型 ctx 的 1/3--1/2),优先保留含数字/实体/定义的段落。
- 若问题主题明确,做片段内句级裁剪(减少噪声)。
九、性能与成本优化清单
召回
- 双检索:Dense + BM25;用户短问时 BM25 权重可提高。
- Embedding 归一化 + 余弦距离;同义词词表/Query 扩展(如"退货/退款/退货政策")。
重排
- Cross-Encoder 小模型优先;把 topk 从 10→4,延迟可降 40--60%。
- 对于 FAQ 类问题,可缓存重排结果(query hash + corpus version)。
生成
- 流式 输出提升主观速度;JSON Mode提升结构化输出稳定度。
- 减少温度随机性(
temperature 0--0.3
),避免啰嗦(max_tokens
抑制)。 - 分段生成:先摘要后扩写,有助于控时控费。
缓存
- Q&A 结果缓存:
cache_key = normalize(query) + top_docs_signature
。 - Embedding 缓存:同一段落/版本只算一次。
并发与连接
- 统一 HTTP 连接池;对外模型服务开启重试 + 抖动退避。
- 限流熔断 + 过载时降级为"检索结果直出摘要"。
粗略成本估算
-
设平均上下文总长 2k tokens、输出 400 tokens:
- 以 ¥X/1k tokens 计费,则单次 ≈
2*X + 0.4*X = 2.4X
。 - 缓存命中 30% 时,平均可降至
~1.7X
。
- 以 ¥X/1k tokens 计费,则单次 ≈
-
Embedding:一次入库向量化成本按文档量线性增长,可通过夜间批处理 与增量更新摊销。
十、安全与合规
- 越权风险:对问题做权限校验,片段级 ACL;未授权片段禁止进入上下文。
- 隐私:入库前脱敏(邮箱/手机号/地址等),日志中掩码。
- 滥用输入:Prompt 注入("忽略以上")→ 在系统层过滤"越权指令",仅以片段为信任源。
- 输出审计:命中证据与答案强绑定;敏感领域(法务/医疗)严格拒答模板。
十一、上线与观测
-
可观测:为每次问答生成 trace_id,记录:
- embedding/检索/重排/生成 各阶段耗时
- top-k 文本与打分
- 模型 token 用量
- 最终答案/引用
-
仪表盘:
- P50/P95 延迟、TTFT、TBT
- 召回命中率、用户"有帮助率"
- 缓存命中、各环节错误率
- 每日成本 & 单问题成本分布
十二、常见坑位与避坑策略
- 中文 PDF OCR 乱序 → 预处理版面,按列合并;必要时用版面恢复(layout detection)。
- 切块过大 → 召回噪声高、重排压力大;先减块长再看指标。
- 只用向量检索 → 对短问/专有名词效果差;务必加 BM25。
- 重排缺席 → 回答飘忽;Cross-Encoder 是性价比最高的提升点。
- 上下文塞满 → 生成变慢且跑题;严格做裁剪与顺序控制。
- 无评测闭环 → 每次"感觉更好"都是幻觉;先做小评测集再谈上线。
- 忽视权限 → 片段泄露风险大;片段级 ACL 是底线。
十三、进阶:多步 Agent 与工具调用(可选)
-
当问题需要计算/查表/检索多个域 时,引入工具路由:
- Step1:问题分类(FAQ/计算/搜索/跨域)
- Step2:按类走"RAG → 计算器/SQL/HTTP 搜索 → 汇总"
-
控制 Agent 最大步数 与工具白名单,避免无限循环与越权爬取。
十四、项目结构建议
bash
/rag-app
├─ ingest/ # 文档清洗/切块/入库
├─ retriever/ # 向量/BM25 混检
├─ reranker/ # CrossEncoder
├─ generator/ # LLM Provider 封装
├─ prompts/ # 模板 & A/B 变体
├─ eval/ # 离线评测脚本与数据
├─ telemetry/ # 打点/Tracing
├─ api/ # FastAPI 路由与SSE
└─ deploy/ # docker-compose / k8s 清单
十五、可执行的下一步
- 选一批 50--100 条真实问答,做标注评测集。
- 用 pgvector + Cross-Encoder 跑通 MVP(上面的 FastAPI 即可)。
- 接入观测:记录每次问答的 top-k 片段、分数与 Token。
- 首轮 A/B:对比不同切块 、不同 topk 、不同重排。
- 上线后 2 周做一次错误分类(召回错 / 重排错 / 生成幻觉),针对性改进。
附:Docker Compose(示例骨架)
yaml
version: "3.9"
services:
pg:
image: postgres:16
environment:
POSTGRES_PASSWORD: ragpwd
ports: ["5432:5432"]
volumes:
- ./data/pg:/var/lib/postgresql/data
redis:
image: redis:7
ports: ["6379:6379"]
api:
build: .
environment:
DB_DSN: postgresql://postgres:ragpwd@pg:5432/postgres
REDIS_URL: redis://redis:6379/0
ports: ["8000:8000"]
depends_on: [pg, redis]
结语
RAG 的工程化核心不是"换个更大的模型",而是把数据、检索、重排、生成、评测、观测、成本拆成可迭代的部件------每一项都能量化、替换、复盘。当这条链路跑顺了,你就拥有了一个能持续演进、成本可控、可解释的 AI 应用底座。