news 2026/11/5 20:21:25

从零实现一个“答案能指回原文“的最小 RAG——Python 实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零实现一个“答案能指回原文“的最小 RAG——Python 实战

关键词:RAG、检索增强生成、父子分块、混合检索、答案溯源、Python、Chromadb、Sentence-Transformers

把 PDF、论文、产品文档丢给大模型聊天,最让人不安的不是它答得慢,而是它答出来的东西你没法验证。RAG(Retrieval-Augmented Generation,检索增强生成)本来就是为了解决这个问题而生的——先检索、再生成,让答案有据可依。

但大多数"五分钟搭 RAG"的教程只做到了"能问答",没做到"敢用":回答里没有出处,你不知道这句话来自哪一段,只能选择相信。本文用不到 200 行 Python,带你实现一个最小可用、且答案能指回原文的 RAG,并讲清楚三个决定它"敢不敢用"的设计:父子分块、混合检索、强制溯源的提示词。

代码全部可运行,检索部分纯本地、零 API key;只有最后调用大模型那一步需要 key(也可换成本地 ollama)。


一、最小架构

整个流程分四步:解析文档 → 父子分块入库 → 混合检索 → 拼提示词让模型带引用回答。

┌──────────────┐ 文档(PDF/MD)→│ 解析 + 清洗 │ └──────┬───────┘ │ ┌──────▼───────┐ │ 父子分块 │ 父块(大,保上下文) / 子块(小,供检索) └──────┬───────┘ ┌───────────┼───────────┐ ┌─────▼────┐ ┌──▼────┐ ┌────▼─────┐ │ 子块向量 │ │ BM25 │ │ 父块原文 │ │ (chroma) │ │ │ │ (内存) │ └─────┬────┘ └──┬────┘ └──────────┘ └─────┬─────┘ 混合检索(RRF 融合) → 取命中的父块原文 │ ┌──────▼──────┐ │ 拼 prompt │ 强制模型只据资料、用 [1][2] 标注 └──────┬──────┘ │ ┌──────▼──────┐ │ LLM │ → 答案 + 引用编号 └─────────────┘

核心思想:检索用子块(小,命中准),回答用父块(大,上下文整)。模型看到的永远是完整段落,但触发命中的是小片段——这就是"父子分块"要解决的矛盾。


二、环境准备

依赖用途是否需要 key
pypdf解析 PDF 文本否
sentence-transformers本地 embedding 模型否(首次运行会下载约 80MB 模型)
chromadb本地向量库(内存模式)否
rank_bm25BM25 关键词检索否
openai调用大模型(兼容通义/智谱/ollama)是(LLM 本身需要)

安装:

pipinstallpypdf sentence-transformers chromadb rank_bm25 openai

说明:embedding 用all-MiniLM-L6-v2,完全本地、中文可用。LLM 调用走 OpenAI 兼容接口,把base_url换成通义https://dashscope.aliyuncs.com/compatible-mode/v1或本地http://localhost:11434/v1(ollama)即可零 key 跑通。


三、解析与父子分块

先读文档,再切块。固定长度切分会把一句完整的论证从中间切断,所以这里用"父子分块":先切大父块保上下文,再在父块内切小子块供检索。

importrefrompypdfimportPdfReaderdefload_text(path:str)->str:"""PDF 走 pypdf,Markdown/文本直接读。"""ifpath.endswith(".pdf"):reader=PdfReader(path)return"\n".join((p.extract_text()or"")forpinreader.pages)returnopen(path,encoding="utf-8").read()defparent_child_chunk(text:str,parent_size=1200,child_size=300,overlap=50):"""返回 (父块列表, 子块列表)。子块记录所属父块 id。"""sentences=[s.strip()forsinre.split(r"(?<=[。!?!\?\n])",text)ifs.strip()]# 1) 切父块(大)parents,buf=[],""forsinsentences:iflen(buf)+len(s)>parent_size:parents.append(buf)buf=selse:buf+=sifbuf:parents.append(buf)# 2) 父块内再切子块(小,供检索命中)children=[]fori,pinenumerate(parents):start=0whilestart<len(p):children.append({"parent_id":i,"text":p[start:start+child_size]})start+=child_size-overlapreturnparents,children

parent_size=1200/child_size=300是字符数(按中文习惯)。overlap让相邻子块重叠一点,避免一个关键句恰好被切在边界上。


四、建向量库

用本地 embedding 把子块向量化,存进 Chroma。父块原文留在内存,靠parent_id关联。

importchromadbfromsentence_transformersimportSentenceTransformer embedder=SentenceTransformer("all-MiniLM-L6-v2")# 本地,零 keyclient=chromadb.Client()# 内存模式col=client.get_or_create_collection("rag_min")parents,children=parent_child_chunk(load_text("doc.pdf"))child_texts=[c["text"]forcinchildren]child_embs=embedder.encode(child_texts,normalize_embeddings=True)col.add(ids=[f"c{i}"foriinrange(len(children))],embeddings=child_embs.tolist(),documents=child_texts,metadatas=[{"parent_id":c["parent_id"]}forcinchildren],)

五、混合检索

纯向量检索会漏掉精确的字符串匹配——比如你问"mIoU 是多少",向量可能召回语义相近但没写具体数字的那段,而"mIoU 72.4"这种精确词应该靠 BM25 命中。所以这里用BM25(关键词)+ 稠密向量两路召回,再用 RRF(Reciprocal Rank Fusion,倒数排名融合)合并,不引入额外超参。

fromrank_bm25importBM25Okapi# 中文按字做 BM25(演示够用;生产建议用 jieba 分词)tokenized=[list(c["text"])forcinchildren]bm25=BM25Okapi(tokenized)defrrf(rank_lists,k=60):"""多路排名融合:出现越靠前、出现路数越多,得分越高。"""score={}forranksinrank_lists:fori,doc_idinenumerate(ranks):score[doc_id]=score.get(doc_id,0)+1.0/(k+i+1)returnsorted(score,key=score.get,reverse=True)defhybrid_search(q:str,top_k=8):q_emb=embedder.encode([q],normalize_embeddings=True)[0]vec_ids=col.query(query_embeddings=[q_emb.tolist()],n_results=top_k)["ids"][0]bm25_scores=bm25.get_scores(list(q))bm25_ranked=sorted(range(len(children)),key=lambdai:bm25_scores[i],reverse=True)[:top_k]bm25_ids=[f"c{i}"foriinbm25_ranked]fused=rrf([vec_ids,bm25_ids])ctx=[]forcidinfused[:4]:# 取前 4 段父块原文pid=col.get(ids=[cid])["metadatas"][0]["parent_id"]ctx.append(parents[pid])returnctx

fused[:4]就是喂给模型的"资料"。注意我们返回的是父块,不是命中的子块——模型看到的是完整段落,不会被截断的半句话误导。


六、拼提示词,强制模型溯源

"能溯源"的关键不在检索,而在提示词:明确要求模型只使用给定资料、并用[1][2]标注出处。这一步任何人都能加,但很多人漏了。

importosfromopenaiimportOpenAI llm=OpenAI(api_key=os.getenv("OPENAI_API_KEY"),base_url=os.getenv("OPENAI_BASE_URL","https://api.openai.com/v1"),)defask(q:str)->tuple[str,list[str]]:ctx=hybrid_search(q)system_p=("你是基于给定资料回答的助手。只能使用资料中的内容,""回答中引用资料时用 [1][2] 这样的编号标注对应段落,""不要编造资料之外的信息。若资料无法回答,明确说'资料中未提及'。")user_p="资料:\n"+"\n\n".join(f"[{i+1}]{c}"fori,cinenumerate(ctx))user_p+=f"\n\n问题:{q}"resp=llm.chat.completions.create(model=os.getenv("OPENAI_MODEL","gpt-4o-mini"),messages=[{"role":"system","content":system_p},{"role":"user","content":user_p}],)returnresp.choices[0].message.content,ctxif__name__=="__main__":# 上面的建库代码已在本文件前半部分执行过answer,sources=ask("本文提到的核心方法是什么?")print(answer)print("\n--- 引用来源 ---")fori,cinenumerate(sources,1):print(f"[{i}]{c[:120]}...")

跑起来后,回答里会带[1][2],对照下面打印的「引用来源」即可逐句核对。这就是"敢用"的底线:每一句结论都能回原文。


七、两个关键设计,为什么这么做

7.1 为什么是父子分块,而不是固定长度切分

维度固定长度切分父子分块
上下文完整度低(论证常被切断)高(父块保完整)
检索命中精度中高(子块小、干扰少)
实现复杂度低中
长文档适配一般好

矛盾点在于:块切得小,检索准但模型看不到上下文;块切得大,上下文全但检索容易命中无关句。父子分块用"小块检索、大块回答"同时解决了两者。

7.2 为什么要混合检索,而不只是向量

维度纯向量检索混合检索(BM25 + 向量)
精确字符串(如 “mIoU 72.4”)易漏能命中
语义相似问法好好
专有名词 / 编号弱强
依赖仅 embeddingembedding + BM25

向量的长处是"语义相似",短处是"字面对不上";BM25 正好反过来。两者融合后,既能懂你问的意思,又不漏掉你问的具体词。

背景:RAG 这一范式来自 Lewis 等人 2020 年在 NeurIPS 发表的《Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks》。本文的"检索 + 生成 + 可溯源"正是它的核心思想,只是把它落到一份本地文档上。


八、局限:别把它当真理

这套最小实现能跑,但有几个坑要心里有数(EEAT 里的可信度,恰恰来自把边界说清):

  1. 公式和表格:PDF 文本提取常丢公式/表格,模型据此回答会出错,关键结论务必回原文核对。
  2. 扫描版 PDF:图片型 PDF 提取出来是空的,要先做 OCR 再入库。
  3. 引用会编:模型可能标错[1][2],关键论断人工回看来源段落,别全信编号。
  4. 中文分词:上面的 BM25 按字切,演示够用;生产用jieba分词,关键词召回会更准。
  5. 不替代精读:溯源是辅助检索,不是替你把文献读透。写综述、做决策前,原文该读还得读。

九、如果你不想写这些代码

本文的全部逻辑——父子分块、混合检索、强制溯源的提示词——都是为了一个目标:让 AI 的回答能回到你的原文。如果你不想自己维护这套分块、检索和溯源管线,CoreNote 把这些做成了一个开箱即用的无代码版本:导入 PDF / Markdown 后直接提问,答案自动带原文出处。本文的代码,本质上是它的底层原理拆解。


作者注:文中代码在 Python 3.11 实测可运行;embedding 模型首次使用会自动下载。LLM 部分依赖外部服务,请自行配置 key 或换本地 ollama。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 6:59:06

开发者成长路径:从技术筑基到商业价值实现

1. 个人开发者成长全景图刚入行时我以为学会编程语言就能成为合格开发者&#xff0c;直到第一次独立交付项目时才意识到&#xff1a;从代码搬运工到具备完整交付能力的开发者之间&#xff0c;隔着产品思维、工程化能力、用户洞察等多重维度。真正的成长路径应该像建造金字塔——…

作者头像 李华
网站建设 2026/10/25 22:34:15

IIFE立即调用函数表达式:从语法解析到模块化实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/25 15:30:26

深入理解volatile与内存可见性:从CPU缓存到JMM的实战解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/25 21:15:02

Java二叉树前序遍历:递归与非递归实现及栈模拟原理详解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/27 2:24:24

Proteus 8.17 安装避坑完整指南:版本选型、激活汉化与仿真验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/11/4 23:36:02

Word/WPS接入DeepSeek R1:VBA集成实战与办公AI协作者构建

简介&#xff1a;本资源是一份面向办公自动化场景的AI集成实战指南&#xff0c;专为希望提升Word与WPS文档处理效率的专业人士及AI工具尝鲜者设计&#xff0c;解决传统办公软件缺乏智能文本生成、润色与理解能力的痛点。教程系统覆盖DeepSeek R1 API密钥申请规范、Word宏配置全…

作者头像 李华