关键词: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_bm25 | BM25 关键词检索 | 否 |
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,childrenparent_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])returnctxfused[: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”) | 易漏 | 能命中 |
| 语义相似问法 | 好 | 好 |
| 专有名词 / 编号 | 弱 | 强 |
| 依赖 | 仅 embedding | embedding + BM25 |
向量的长处是"语义相似",短处是"字面对不上";BM25 正好反过来。两者融合后,既能懂你问的意思,又不漏掉你问的具体词。
背景:RAG 这一范式来自 Lewis 等人 2020 年在 NeurIPS 发表的《Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks》。本文的"检索 + 生成 + 可溯源"正是它的核心思想,只是把它落到一份本地文档上。
八、局限:别把它当真理
这套最小实现能跑,但有几个坑要心里有数(EEAT 里的可信度,恰恰来自把边界说清):
- 公式和表格:PDF 文本提取常丢公式/表格,模型据此回答会出错,关键结论务必回原文核对。
- 扫描版 PDF:图片型 PDF 提取出来是空的,要先做 OCR 再入库。
- 引用会编:模型可能标错
[1][2],关键论断人工回看来源段落,别全信编号。 - 中文分词:上面的 BM25 按字切,演示够用;生产用
jieba分词,关键词召回会更准。 - 不替代精读:溯源是辅助检索,不是替你把文献读透。写综述、做决策前,原文该读还得读。
九、如果你不想写这些代码
本文的全部逻辑——父子分块、混合检索、强制溯源的提示词——都是为了一个目标:让 AI 的回答能回到你的原文。如果你不想自己维护这套分块、检索和溯源管线,CoreNote 把这些做成了一个开箱即用的无代码版本:导入 PDF / Markdown 后直接提问,答案自动带原文出处。本文的代码,本质上是它的底层原理拆解。
作者注:文中代码在 Python 3.11 实测可运行;embedding 模型首次使用会自动下载。LLM 部分依赖外部服务,请自行配置 key 或换本地 ollama。