1. 先把"30分钟"这件事说清楚
RAG 问答系统、FAISS 向量检索、大模型生成,这三个词拼在一起,网上很多教程一上来就给你画一张巨大的架构图,看得人头皮发麻。我先把话撂这儿:30 分钟是能跑通一个端到端可问答的 RAG 原型的,但前提是你清楚哪些环节可以偷懒、哪些环节绝对不能糊弄。装依赖、洗数据、调切分、建索引、写提示词,这五件事里前三件属于"体力活",后两件才是真正决定效果的"手艺活"。我按自己反复搭过十来套类似系统的节奏拆过一遍,纯手工敲代码的时间大概 25 分钟,剩下 5 分钟留给第一次跑通时的报错。
这篇文章面向两类人:一类是手上有几十上百份内部文档,想让大模型基于这些文档答问,又不想花半个月做微调的同学;另一类是已经听过 RAG 这个词,但一直没敢动手,想找个最小可用版本先把链路跑起来的人。你不需要懂分布式、不需要 GPU 集群、不需要向量数据库运维经验,一台 8G 内存的普通笔记本就够跑通全流程。代码我会给到能直接改改就用的程度,但更重要的是每一处参数为什么这么选——这套逻辑你换个场景、换个模型依然成立。
先给一句我自己的判断,免得你走弯路:RAG 的效果上限,八成由切分和检索决定,只有两成由大模型决定。很多人搭完发现答得不好,第一反应是换个更大的模型,结果白花钱。真正该盯的是检索出来的那几段文本到底对不对。这篇文章的篇幅分配也会按这个比例来。
2. 整体设计与思路拆解
2.1 一条最小可用的链路长什么样
RAG 的本质非常朴素:大模型的参数里没有你的私有知识,那就在提问的时候把相关知识"递"给它。所以整条链路的骨架是六步——文档入库、清洗切分、向量化、建索引、检索召回、拼提示词生成。前五步是准备阶段,只有第六步才真正跟大模型打交道。
我把这套架构定成"全离线索引 + 在线检索生成"两段式,原因是文档更新频率通常远低于问答频率。索引建一次可以反复用,问答请求进来时只做一次 embedding 和一次向量检索,延迟能压在几百毫秒内。如果你把切分、向量化都放在请求链路里做,那每次问答都得等好几秒,体验直接崩掉。
结构上我做了一个刻意的简化:不引入任何向量数据库服务,直接用 FAISS 的本地索引文件。理由很实在——几万到几十万条 chunk 的量级,FAISS 的 Flat 索引在单机上做暴力检索也就几十毫秒,完全够用;而上一套独立的向量数据库服务,意味着你要额外维护进程、端口、持久化、备份,对一个 30 分钟要跑起来的东西来说,这是纯粹的成本。等你的文档量到了百万级 chunk,或者要做多租户隔离、实时增删,再考虑迁移,那时候换的也只是存储层,上层代码基本不动。
整条链路的数据流是这样的:原始文档(Word、PDF、Markdown、网页存档都行)→ 纯文本 → 带元数据的 chunk 列表 → 归一化向量矩阵 → FAISS 索引文件 + 一份 id 到原文的映射表 → 查询时得到 top-k 索引 → 反查原文 → 塞进提示词 → 大模型输出带引用的答案。
2.2 为什么是 FAISS,而不是别的方案
选 FAISS 有三个理由,我按重要性排。第一是零运维,它就是个库,import faiss之后全在内存里操作,索引可以一行代码写盘、一行代码读盘,不需要跑任何服务。第二是索引类型齐全,从最精确的 Flat 到带量化的 IVF-PQ 都在同一个 API 下,你业务量涨了不用换代码框架,改个构造函数就行。第三是性能可预期,Flat 索引是精确检索,召回率 100%,不存在"近似索引调参调半天还是漏召回"的问题——对原型阶段来说,"结果确定"比"快"重要得多。
我见过一种常见误区:原型阶段就上 HNSW 或者 IVF,觉得快就是好。结果数据只有八千条,Flat 检索两毫秒,近似索引也是两毫秒,但你要多花半天调参,还引入了召回损失,排查问题时多一个变量。数据量不到十万条,别碰近似索引,这是我的硬建议。
至于 embedding 模型,我建议选一个中小体量的中文向量模型,维度在 512 到 768 之间,最大输入长度 512 token。维度越大索引占内存越多、检索越慢,收益却不一定明显。计算一下就很清楚:5 万条 chunk,768 维 float32,内存占用是 50000 × 768 × 4 字节 ≈ 147 MB;换成 512 维就是 102 MB。这点差别在笔记本上无所谓,但你如果要把索引塞进内存受限的容器里,就有意义了。
2.3 30 分钟的时间预算表
我把这 30 分钟拆成了下面这样,你可以对照着控制节奏,哪一步超时了就知道哪里出问题了:
| 环节 | 预算时间 | 关键动作 | 超时的典型原因 |
|---|---|---|---|
| 环境与依赖 | 5 分钟 | 建虚拟环境、装 faiss 与向量库 | 版本冲突、编译失败 |
| 文档清洗与切分 | 5 分钟 | 转文本、按结构切块 | PDF 解析出乱码 |
| 向量化与建索引 | 6 分钟 | 批量编码、归一化、写盘 | 忘了转 float32 |
| 检索联调 | 5 分钟 | 调 top-k、看相似度分布 | 相似度阈值定错 |
| 提示词与生成 | 6 分钟 | 模板、引用编号、兜底话术 | 模型不按格式回答 |
| 缓冲 | 3 分钟 | 修小 bug | 编码、路径问题 |
这张表最有价值的地方不是时间,而是它告诉你哪几步是可以并行或者跳过的。比如你手上已经有整理好的 Markdown 文档,清洗这一步直接省掉;如果你只是想验证想法,切分参数先用默认值,等效果不满意再回头调。反过来,向量化和建索引这两步千万别图快,一次编码错误会导致整个索引报废,重来更费时间。
3. 环境与依赖:把地基打到不返工
3.1 环境清单与版本约束
这一节我写得细一点,因为我在这一步踩过的坑比后面所有环节加起来都多。基础组合是 Python 3.10 或 3.11、faiss-cpu、numpy、以及一个能跑向量模型的推理库。这里有个非常经典的坑:faiss 的预编译包对 numpy 大版本有依赖,numpy 2.x 出来后,不少环境下 import faiss 会直接抛 ABI 相关的错误。稳妥做法是显式把 numpy 锁在 1.26 这一线。
python -m venv rag-env source rag-env/bin/activate # Windows 下是 rag-env\Scripts\activate pip install --upgrade pip pip install "numpy==1.26.4" pip install faiss-cpu pip install sentence-transformers pip install requests pip freeze > requirements.txtWindows 用户额外提醒一句:faiss-cpu 在某些 Python 小版本上没有预编译 wheel,pip 会尝试从源码编译,然后卡在缺 C++ 编译工具链上。遇到这种情况,最省事的办法是把 Python 降到有 wheel 的版本,别去折腾编译器,那不值得。
3.2 依赖装完之后先做三件事
装完别急着写业务代码,先跑一段十行的自检脚本,确认三件事:faiss 能 import 且能建索引、向量模型能加载且输出维度符合预期、大模型接口能通。这三件事任何一件不通,后面全是白忙。
import numpy as np import faiss # 1) FAISS 自检 d = 8 index = faiss.IndexFlatIP(d) xb = np.random.random((100, d)).astype("float32") faiss.normalize_L2(xb) index.add(xb) print("faiss ok, ntotal =", index.ntotal) # 2) 向量模型自检 from sentence_transformers import SentenceTransformer EMBED_MODEL = "<你的中文向量模型路径或名称>" model = SentenceTransformer(EMBED_MODEL) v = model.encode(["这是一句测试文本"], normalize_embeddings=True) print("embedding dim =", v.shape[1])我特意在自检里就加上了normalize_embeddings=True,这是为了让"向量模型输出"和"FAISS 用内积算余弦"这两件事从一开始就对齐。很多人分开写代码时忘了归一化,检索结果看起来也像模像样,但排序其实是错的,这类问题极难发现。要么在编码时归一化,要么在入库前统一做一次 L2 归一化,二选一,但必须做,而且要写在索引构建的同一个函数里,不给人忘记的机会。
3.3 目录结构:别小看这一步
rag-demo/ ├── data/ │ ├── raw/ # 原始文档 │ └── processed/ # 切分后的 jsonl ├── index/ │ ├── faiss.index # 向量索引 │ └── meta.jsonl # id -> chunk 原文与来源 ├── src/ │ ├── build_index.py │ ├── retrieve.py │ └── answer.py └── requirements.txt这个结构是我试过最省心的。关键点在于索引和元数据分两个文件存:FAISS 只负责存向量和 id,chunk 的原文、来源文件名、页码、切分序号这些信息全部落在meta.jsonl里,行号就是向量 id。这样检索时拿到的是 id,用它去 meta 里反查,一一对应,不会乱。我见过把原文序列化进 FAISS 的IDMap里的写法,能用,但更新和维护都很别扭。
4. 文档清洗与切分:效果天花板就在这一步
4.1 清洗到底在清什么
清洗不是"把文件转成文本"这么简单,它要做的是把对问答无用的噪声剔掉,同时保住结构信息。常见的噪声有三类:页眉页脚和页码、PDF 转换时的断行和多余空白、以及各种特殊字符。结构信息则是标题层级、列表、表格,这些恰恰是理解语义的关键。
我的做法是给每个 chunk 保留一条"面包屑":文档标题 + 一级标题 + 二级标题,拼在正文前面一起送去向量化。比如一段关于"退货流程"的段落,前面加一句"《售后服务手册》> 第三章 退换货 > 3.2 七天无理由",这段文本的向量就会明显偏向"售后、退换货"的语义空间,比光秃秃一段正文命中率高得多。这个技巧几乎没有成本,但对召回率的提升非常直接,尤其是你的文档是大部头手册的时候。
def clean_text(raw: str) -> str: import re # 去页眉页脚常见模式 text = re.sub(r"^\s*\d+\s*/\s*\d+\s*$", "", raw, flags=re.M) # 合并被硬换行拆断的中文句子 text = re.sub(r"(?<=[\u4e00-\u9fa5])\n(?=[\u4e00-\u9fa5])", "", text) # 压缩多余空白 text = re.sub(r"[ \t]{2,}", " ", text) text = re.sub(r"\n{3,}", "\n\n", text) return text.strip()那条"合并被硬换行拆断的中文句子"的正则是关键。中文文档从 PDF 转出来,几乎每行末尾都有一个换行,直接按换行切分,一句话就被切碎了。用正则匹配"前一个字是汉字、后一个字也是汉字"的换行并删除,能救回大量被破坏的句子。英文文档不要用这条,因为英文换行通常是真的分段。
4.2 切分参数怎么算,不是拍脑袋
切分长度是最容易拍脑袋的参数。这里给一套可推导的逻辑。假设你的向量模型最大输入长度是 512 token,中文场景下 1 个汉字大致对应 0.6 到 1 个 token(取决于具体分词器),那么 512 token 大约能装下 400 到 500 个汉字。切分长度必须小于模型最大长度,且要留出重叠部分的余量,否则超长部分会被模型静默截断,你等于只索引了前半段,后半段永远检索不到。
我一般用 400 字一 chunk、80 字重叠。重叠的作用是防止一句话正好被切在边界上导致语义断裂,80 字大约是一个完整句子的长度,够用。如果你追求更精细,可以按 token 数而不是字符数来切,代价是要引入分词器,多一层依赖。
| 文档类型 | 建议 chunk 长度 | 重叠 | 说明 |
|---|---|---|---|
| 产品手册、规章制度 | 400 字 | 80 | 段落完整,语义自洽 |
| 客服问答记录 | 200 字 | 40 | 一问一答本身很短,切太大会混入无关问题 |
| 技术文档、代码说明 | 500 字 | 100 | 需要保留上下文,切太碎反而看不懂 |
| 合同、条款 | 300 字 | 60 | 条款独立性强,必须按条款边界切 |
表格最后一列的"必须按条款边界切"值得展开讲。如果你的文档本身有明确的结构边界(章节、条款、问答对),优先按结构切,其次才按长度切。按长度硬切经常把一个条款劈成两半,前半段说"甲方应当在本合同签署后三十日内",后半段说"逾期未支付的,按日万分之五计违约金",这两段单独检索出来的价值都很低,合在一起才有意义。写切分函数时先尝试按标题正则切分,切出来的块再判断长度,超长的才二次按长度切。
def split_by_structure(text: str, source: str, max_len=400, overlap=80): import re # 先按 Markdown 标题或中文序号切大块 blocks = re.split(r"(?m)^(?=#{1,3}\s|第[一二三四五六七八九十]+[章节条])", text) chunks = [] for bi, block in enumerate(blocks): block = block.strip() if not block: continue if len(block) <= max_len: chunks.append({"text": block, "source": source, "block": bi}) continue start = 0 while start < len(block): piece = block[start:start + max_len] chunks.append({"text": piece, "source": source, "block": bi}) if start + max_len >= len(block): break start += max_len - overlap return chunks注意:切分函数一定要配一个"长度分布检查"。我每次都会统计 chunk 长度的 min、median、max,如果 max 远超预设值,说明有超长块没被二次切断;如果 median 只有几十字,说明切得太碎,检索出来全是片段,大模型拼不出完整答案。
4.3 一个反直觉的经验:垃圾块要主动丢弃
切完之后,一定会有一些块是纯噪声——只有页码的、只有"续表"两个字的、只有一个表格分隔线的。这些块向量化之后会占据索引空间,还可能在某些查询下被召回,挤掉真正有用的内容。我的处理方式是设一条硬门槛:长度小于 30 字的块直接丢弃,另外纯数字、纯符号的块也丢掉。就这一条规则,能让检索的干净程度提升一个档次,而且几乎不损失有效信息。
5. 向量化与 FAISS 索引构建
5.1 编码环节的四个必须
向量化这一步代码不长,但有四个必须,缺一个后面就会出问题。第一,批量编码,不要一条一条 encode,批量能把 GPU/CPU 的利用率拉满,几万条 chunk 的时间从十几分钟压到一两分钟。第二,归一化,前面说过,落在编码函数里。第三,转 float32 且保证内存连续,FAISS 只认np.float32,而且要求数组是 C 连续的,从某些库出来的数组是视图,不copy()一下会报错。第四,顺序必须与 meta 严格一致,chunk 列表的顺序就是向量矩阵的行序,中间任何一步排序、去重、过滤,两边必须同步做。
import json, numpy as np, faiss from sentence_transformers import SentenceTransformer EMBED_MODEL = "<你的中文向量模型>" model = SentenceTransformer(EMBED_MODEL) def load_chunks(path="data/processed/chunks.jsonl"): with open(path, encoding="utf-8") as f: return [json.loads(line) for line in f] chunks = load_chunks() texts = [c["text"] for c in chunks] vectors = model.encode( texts, batch_size=64, normalize_embeddings=True, show_progress_bar=True, convert_to_numpy=True, ) vectors = np.ascontiguousarray(vectors, dtype="float32") print("matrix shape:", vectors.shape) # (N, d)这里有个细节值得说一下:batch_size=64不是随便写的。太小的 batch 会让编码时间翻几倍,太大则可能撑爆内存,尤其是你的模型维度较高、chunk 又长的时候。64 是个比较稳的中间值,跑不动就降到 32,跑得快就往上加,没有玄学。
5.2 索引类型:先 Flat,再谈优化
数据量在十万条以内,我无条件推荐IndexFlatIP。它是精确检索,召回率 100%,你排查问题时可以把"漏召回"这个可能性彻底排除掉,专心看切分和提示词。等数据量上去了,或者你明确测出检索是瓶颈了,再换IndexIVFFlat。
换 IVF 的时候有个参数要算,不是随手填的:nlist(聚类中心数)的经验值是4 * sqrt(N)。假设你有 5 万条 chunk,sqrt(50000) ≈ 224,乘 4 得 896,取最近的 2 的幂就是 1024。检索时还要设nprobe,表示每次查询探多少个簇,nprobe越大越准越慢,一般从nlist的 1% 到 5% 起步往上调。
| 索引类型 | 适用规模 | 召回 | 构建耗时 | 我的建议 |
|---|---|---|---|---|
| IndexFlatIP | < 10 万 | 100% | 极快 | 原型首选,别犹豫 |
| IndexIVFFlat | 10 万 - 500 万 | 95%+ | 中等 | 需训练,调 nprobe |
| IndexIVFPQ | > 500 万 | 85%-95% | 较慢 | 内存吃紧时用,有精度损失 |
d = vectors.shape[1] index = faiss.IndexFlatIP(d) # 内积 + 已归一化 = 余弦相似度 index.add(vectors) print("indexed:", index.ntotal) faiss.write_index(index, "index/faiss.index") with open("index/meta.jsonl", "w", encoding="utf-8") as f: for i, c in enumerate(chunks): f.write(json.dumps({"id": i, **c}, ensure_ascii=False) + "\n")为什么内积能当余弦用?因为余弦相似度 = 内积 / (模长乘积),向量归一化之后模长都是 1,分母变成 1,内积就等于余弦。这个换算关系值得记牢,它解释了为什么归一化那一步不能省——省了之后内积就受向量长度影响,长文本天然分数高,排序全乱。
5.3 增量更新:别每次都重建
文档会更新,每次都全量重建索引是浪费。我的做法是维护一个doc_hash字段,记录每个 chunk 来源文件的哈希,重建时先算出哪些文件变了,只对变化的文件重新切分编码,然后追加到索引里,同时把旧版本对应的 id 标记为失效(在 meta 里加个active字段,检索后过滤)。FAISS 的 Flat 索引支持add,追加很快;IVF 索引也支持,但要注意新增数据多了之后聚类中心的代表性会下降,定期全量重建一次更稳。
提示:追加模式下 id 必须自己管理,别依赖
ntotal自增,否则删除和重建时会撞 id。我的做法是用meta.jsonl的行数作为下一个 id 的起点,写入前先读一遍文件末尾。
6. 检索与生成:把检索结果喂给大模型
6.1 检索这一步的三次调参
检索看着简单,index.search(q_vec, k)一行就完了,但 k 取多少、要不要设分数阈值、要不要去重,这三个问题决定了最终答案的质量。
k 的取值我一般从 5 开始。取 1 太冒险,一次检索错了整个回答就废了;取 20 又太多,无关内容会稀释提示词,还可能让模型被干扰。5 到 8 是个甜蜜区间。分数阈值同样重要:如果 top-1 的相似度只有 0.3,说明库里根本没有相关内容,这时候硬塞给大模型,它就会开始编。设一个 0.35 到 0.45 之间的阈值(具体值要看你用的模型,跑几十条真实问题看分数分布来定),低于阈值就直接返回"资料库中没有找到相关内容",这比编一个答案强得多。
第三个是去重。相邻 chunk 因为有重叠,经常一起被召回,内容高度重复,白占提示词预算。简单的处理办法是对比相邻块文本的相似度,重合度超过八成只保留分数高的那个。
def retrieve(query, index, meta, model, k=5, score_thr=0.35): q = model.encode([query], normalize_embeddings=True, convert_to_numpy=True) q = np.ascontiguousarray(q, dtype="float32") scores, ids = index.search(q, k * 2) # 多取一些,给去重留余量 results, seen = [], set() for score, idx in zip(scores[0], ids[0]): if idx == -1 or score < score_thr: continue item = meta[idx] if not item.get("active", True): continue key = item["text"][:60] if key in seen: continue seen.add(key) results.append({**item, "score": float(score)}) if len(results) >= k: break return results这段代码里k * 2再过滤的设计值得说一下:你先按 2 倍数量取,去重和过滤之后再截断到 k,这样最终拿到的永远是"过滤后最相关的 k 条",而不是"最相关的 k 条里有一部分被过滤掉只剩三条"。差别看似很小,但在文档重复度高的库里,效果差得很明显。
6.2 提示词模板:约束比描述更重要
提示词这块,很多人写一大段"你是一个专业的助手,请根据以下资料回答问题",然后指望模型自己守规矩。实测下来,明确的格式约束和否定约束比角色描述有用得多。我用的模板结构是四段:角色一句话、资料带编号列出、回答规则三条、问题。
规则那三条我固定写:只依据资料回答;资料里没有就明确说没有,不要推测;回答中用 [1][2] 标注引用编号。第三条特别有价值,它让答案可溯源,用户看到引用能自己点回去核对,信任度完全不一样。
SYSTEM = "你是一个严谨的文档问答助手,只依据提供的资料作答。" def build_prompt(question, hits): ctx = "\n\n".join( f"[{i+1}] 来源:{h['source']}\n{h['text']}" for i, h in enumerate(hits) ) user = f"""资料: {ctx} 回答规则: 1. 只使用上面资料中的信息,不要引入资料之外的知识。 2. 如果资料不足以回答,直接回答"资料中未找到相关内容",不要猜测。 3. 引用资料时在句末标注编号,如 [1]。 问题:{question}""" return SYSTEM, user生成参数上,温度调到 0.1 到 0.3 之间。RAG 场景不需要创造力,你需要的是稳定复现。max_tokens给 800 左右够用了,太长反而容易让模型开始发散。
import os, requests def call_llm(system, user, temperature=0.2): resp = requests.post( os.environ["LLM_ENDPOINT"], headers={"Authorization": f"Bearer {os.environ['LLM_API_KEY']}", "Content-Type": "application/json"}, json={ "model": os.environ.get("LLM_MODEL", "<你的模型名>"), "messages": [{"role": "system", "content": system}, {"role": "user", "content": user}], "temperature": temperature, "max_tokens": 800, }, timeout=60, ) resp.raise_for_status() return resp.json()["choices"][0]["message"]["content"]接口地址和密钥从环境变量读,这是底线,别写死在代码里然后传到代码仓库。我见过不止一次密钥泄露的事故,代价远比多写两行os.environ大。
7. 常见问题与排查实录
7.1 检索不命中:按这个顺序查
"明明文档里有,就是答不出来",这是最高频的问题。排查顺序我总结成了一条固定路径,按这个走基本十分钟内能定位。
第一步,直接把问题原文拿去检索,看 top-5 的分数和内容。如果 top-1 分数低于 0.3,先怀疑切分——可能是这段内容被切碎了,或者根本没进索引。第二步,用文档里的原句去检索,如果能命中高分,说明是"用户问法和文档写法差异太大",属于 query 与 document 的语义鸿沟,可以用查询改写(让大模型先把用户问题改写成几个更接近文档表述的版本,分别检索后合并)来解决。第三步,检查归一化和索引类型,如果分数出现大于 1 或者负数,基本就是没归一化或者用了 L2 距离却按内积解读。
| 现象 | 最可能原因 | 处理办法 |
|---|---|---|
| top-1 分数普遍低于 0.3 | 切分过碎或无关内容过多 | 调整 chunk 长度,丢弃短块 |
| 原句能命中、问句不能 | 语义鸿沟 | 加查询改写,多路召回 |
| 分数大于 1 或为负 | 未归一化或距离度量错配 | 统一 normalize + IndexFlatIP |
| 检索结果 id 与原文错位 | meta 与向量顺序不一致 | 重建索引,确保顺序同步 |
| 中文检索效果奇差 | 用了英文向量模型 | 换中文向量模型 |
7.2 答案胡编:三道防线
大模型在 RAG 里编答案,通常是两个原因:检索没给到有用资料,或者提示词没约束住。三道防线依次上。第一道是检索阈值,分数不够直接不调模型,返回固定话术。第二道是提示词的否定约束,明确写"资料不足就说没有"。第三道是回答后的引用校验,解析答案里的引用编号,检查对应的 chunk 是否真的存在,如果没有引用或者引用了不存在的编号,就在前端打个提示。
这三道防线里,第二道的性价比最高,但很多人写的否定约束太软,比如"尽量基于资料回答"。"尽量"这两个字一出现,模型就会开始发挥。改成"只使用资料中的信息",效果立竿见影。这是我在实际项目里反复验证过的,措辞的硬度直接影响模型的服从度。
7.3 其他几个让我返工过的问题
长文档被静默截断。向量模型超过最大长度会直接截断,不报错。如果你的 chunk 长度接近上限,一定要用模型自带的分词器量一下真实 token 数,别用字符数估。
索引加载后搜不到东西。faiss.read_index读回来之后如果不记得同时加载 meta,index.search返回的 id 你根本不知道对应哪段文本。我习惯把这两个加载写在一个load_store()函数里,绑死。
内存爆掉。几百万条 chunk 的 float32 矩阵很吃内存,这时候要么换 IVF-PQ 做量化压缩(768 维压到几十字节,内存降一个数量级,代价是召回下降几个点),要么把索引放磁盘用IndexFlatIP的 mmap 变体。
多轮对话效果差。用户第二句问"那这个要多久",单独拿去检索什么也搜不到。解决办法是把上一轮问题和当前问题拼一起再检索,或者让模型先做指代消解,把"这个"还原成具体名词。
8. 上线前值得花的那些优化时间
8.1 先量一量,再优化
原型跑通之后,别急着堆功能,先做一次最小评估。准备 30 到 50 条真实问题,人工标注每条问题应该命中哪个文档的哪一段,然后跑一遍脚本统计 top-5 命中率。这个工作量大概一小时,但它能告诉你现在到底是个什么水平——是 60% 还是 90%,决定了你是该调检索还是该调提示词。
def evaluate(questions, index, meta, model, k=5): hit = 0 for q, expect_source in questions: hits = retrieve(q, index, meta, model, k=k) if any(h["source"] == expect_source for h in hits): hit += 1 return hit / len(questions) # questions = [("七天无理由怎么算时间", "售后手册.md"), ...] # print("recall@5 =", evaluate(questions, index, meta, model))评估里最容易被忽略的是负样本。除了"该答对的问题",还要准备一批"库里根本没有的问题",看系统是不是老老实实说"未找到"。如果这些也答得有模有样,说明阈值太低或者提示词太软。
8.2 缓存与成本控制
问答系统跑起来之后,成本主要在两块:embedding 调用和生成调用。embedding 这块,如果向量模型是本地跑的,主要成本是 CPU 时间,做好批处理就行。生成这块,优化空间更大:相同的检索结果 + 相同的问题可以缓存,命中缓存直接返回;不同用户问相似问题时,检索结果往往高度重合,可以在检索层做一层短时缓存。
还有一个容易被忽视的成本点:提示词长度。k 取 8 和取 5,输入 token 差 60%,如果每天几千次问答,这个差距是实打实的。所以 k 别盲目往大了取,测出来 5 够用就别设 8。
8.3 我自己的几条土办法
最后分享几条我在实际搭系统时攒下来的习惯,都是文档里不会写、但挺管用的。
第一条,每次改切分策略,都重建索引并重跑同一套评估问题,别凭感觉判断好坏。我吃过凭感觉优化的亏,改完之后自我感觉良好,一评估发现召回掉了五个点。
第二条,在 meta 里永远存原始文本和来源,别只存向量。你永远想不到哪天要回溯"这条答案到底是从哪份文件的第几页来的",那时候 meta 就是救命稻草。
第三条,先用最简单的方式跑通,再谈"架构"。我见过太多人一上来就纠结要不要上消息队列、要不要做多路召回融合,结果两周过去连个能问的系统都没有。30 分钟能跑出来的东西,先跑出来,让它能回答问题,然后你才知道下一步该优化哪里。这套 RAG 的骨架我用过很多次,改动的地方无非是换向量模型、换索引类型、换提示词,骨架本身一直没变过,这也是我愿意把它写成一篇可以照着做的文章的底气。