
最终成果一个能针对你的文档回答问题、且能量化效果的 RAG约 150 行代码 标签RAG、BGE、向量检索、文档问答、评测、LLM标签RAG、BGE、向量检索、文档问答、评测、LLM前言RAG检索增强生成听起来复杂拆开只有三步把文档切块 → 检索相关片段 → 带着片段让模型回答。之前写过的进阶文章里说过RAG效果不好的根源在检索不在生成。所以这篇不堆概念直接从「切块」和「评测」两个决定成败的点讲起代码全部可运行。一、环境准备pipinstallsentence-transformers openai numpy检索用 sentence-transformers 本地跑 BGE 中文模型免费、离线、不用 API key生成用任意 OpenAI 兼容接口DeepSeek、通义、Kimi 都行.env沿用上一篇的配置API_KEYsk-xxxxxxxx BASE_URL[https://api.deepseek.com](https://api.deepseek.com) MODELdeepseek-chat二、第一步切块ChunkingRAG 的第一道坎。块太大检索出来一大段废话块太碎语义被切断。经验值中文 300~500 字一块重叠 10%~20%。# rag.pydefsplit_text(text,chunk_size300,overlap50):按长度滑动切块尽量在句号/换行处断开texttext.replace(\r\n,\n).strip()chunks,start[],0whilestartlen(text):endmin(startchunk_size,len(text))# 在后半段找最近的句号或换行避免把一句话切断cutmax(text.rfind(。,start,end),text.rfind(\n,start,end))ifcutstartchunk_size//2:endcut1chunktext[start:end].strip()ifchunk:chunks.append(chunk)ifendlen(text):breakstartend-overlap# 回退 overlap 个字符形成重叠returnchunksoverlap 的作用如果关键信息正好卡在两个块的边界重叠部分保证它至少完整出现在其中一块里。三、第二步向量化importnumpyasnpfromsentence_transformersimportSentenceTransformer modelSentenceTransformer(BAAI/bge-small-zh-v1.5)# BGE 官方建议查询语句要加指令前缀文档不加QUERY_INSTRUCTION为这个句子生成表示以用于检索相关文章defembed_chunks(chunks):文档编码归一化后内积 余弦相似度returnmodel.encode(chunks,normalize_embeddingsTrue)defembed_query(query):returnmodel.encode([QUERY_INSTRUCTIONquery],normalize_embeddingsTrue)[0]两个容易被忽略的细节查询加指令前缀BGE 是「非对称检索」模型query 和 passage 分开编码不加前缀召回率会明显下降normalize_embeddingsTrue归一化之后点积就是余弦相似度不用再手算换 Embedding 模型必须重建整个向量库——不同模型的向量空间不可混用。四、第三步检索没必要一上来就上向量数据库。几百个块用 numpy 暴力算余弦就够了defretrieve(query,chunk_vecs,chunks,top_k3):qvembed_query(query)scoreschunk_vecs qv# (N,) 余弦相似度idxnp.argsort(-scores)[:top_k]return[{chunk:chunks[i],score:float(scores[i])}foriinidx]几千块以上再换 FAISS / Milvus / pgvector接口是一样的。五、第四步拼 Prompt 生成这是 RAG 里唯一和模型交互的地方prompt 约束决定了幻觉率importosfromdotenvimportload_dotenvfromopenaiimportOpenAI load_dotenv()clientOpenAI(api_keyos.getenv(API_KEY),base_urlos.getenv(BASE_URL))MODELos.getenv(MODEL,deepseek-chat)defanswer(question,retrieved):context\n\n.join(f[{i1}]{r[chunk]}fori,rinenumerate(retrieved))prompt只能根据下面的资料回答问题资料中没有的信息就回答「资料中未提及」。 回答尽量简洁末尾用 [编号] 标注引用的资料。 资料 {context} 问题{question}respclient.chat.completions.create(modelMODEL,temperature0,messages[{role:user,content:prompt.format(contextcontext,questionquestion)}],)returnresp.choices[0].message.content三个约束缺一不可只依据资料防幻觉、没有就说没有防编造、标注引用可核查。六、串起来跑起来if__name____main__:withopen(docs/notes.txt,encodingutf-8)asf:textf.read()chunkssplit_text(text)vecsembed_chunks(chunks)print(f共切出{len(chunks)}块)questionpytest fixture 有哪些作用域retrievedretrieve(question,vecs,chunks,top_k3)forrinretrieved:print(f[{r[score]:.3f}]{r[chunk][:60]}...)print(\n回答,answer(question,retrieved))把任意.txt文档笔记、博客、产品手册放进docs/就能用。建议第一次就拿自己写过的文章当语料效果好坏一眼能看出来。七、评测分两层先测检索这是本文最重要的部分。RAG 调优必须分层层指标检查什么检索层RecallK正确片段有没有被召回生成层答案关键词命中最终答案对不对7.1 评测集 eval_cases.json[{q:pytest fixture 有哪些作用域,gold:session,must_contain:[function,session]},{q:conftest 文件有什么用,gold:conftest.py,must_contain:[fixture]},{q:参数化怎么写,gold:parametrize,must_contain:[parametrize]}]gold正确答案所在的块应包含的关键词must_contain最终回答必须包含的词7.2 评测脚本importjsondefeval_rag(cases,chunks,vecs,k3):recall,answer_hit0,0forcincases:rsretrieve(c[q],vecs,chunks,top_kk)ifc[gold]in\n.join(r[chunk]forrinrs):recall1# 检索层finalanswer(c[q],rs)ifall(winfinalforwinc[must_contain]):answer_hit1# 生成层nlen(cases)print(fRecall{k}:{recall/n:.0%}答案命中率:{answer_hit/n:.0%})if__name____main__:# 前面加载文档、切块、向量化代码放这里casesjson.load(open(eval_cases.json,encodingutf-8))eval_rag(cases,chunks,vecs)判断顺序Recall3 低于 80%先修切块和检索别去调 prompt。检索没给对材料生成不可能答对。这是最常见的误诊。八、常见坑切块不调参300 字不是万能值。块大小是 RAG 第一个该调的参数用 RecallK 衡量查询没加 BGE 指令前缀召回率白掉一大截忘记归一化用点积当相似度时长向量的分数被放大只测最终答案答错分不清是「没召回」还是「模型乱说」必须分层prompt 没说「资料中没有就说没有」模型会自由发挥读文件忘写 encoding“utf-8”中文环境老问题评测集太简单问题里直接出现答案原文测不出真实水平要用「同义改写」提问九、总结与下一步最小 RAG 就三件事切块大小重叠→ 检索向量top_k→ 生成约束 prompt加上一层分层评测让它可控。下一步按收益排序用 RecallK 调切块大小和 top_k加 RerankCross-Encoder 精排召回结果加 混合检索BM25 关键词 向量RRF 融合换更大语料时再上向量数据库