1. 项目概述:一场关于“检索到底有多靠谱”的硬核拆解
你有没有遇到过这种场景?辛辛苦苦搭好一个RAG系统,用户问“这篇论文里提到的ARES框架核心思想是什么”,模型张口就来,答得头头是道,还引经据典。你一翻原文,发现它压根没提“核心思想”这四个字,全是自己编的——但偏偏编得特别像那么回事,连你自己都差点信了。这不是模型在撒谎,而是整个RAG链条里某个环节悄悄“掉链子”了。而这个环节,十有八九就藏在“检索”这一步。
今天要聊的,不是怎么把RAG搭起来,而是怎么给它做一次真正意义上的“体检”。我们不看它回答得漂不漂亮,而是要掰开揉碎,一层层问:它找来的资料,真的和问题相关吗?(Answer Relevancy)它说的每一句话,都能在找来的资料里找到出处吗?(Faithfulness)它是不是把一堆无关信息也塞进来了,只为了凑数?(Context Relevancy)它漏掉了最关键的那几段话吗?(Context Recall)这套评估体系,就是Ragas——目前最被工业界认可的RAG专用评测工具包。它不关心你的LLM多大、多贵,只盯着“检索-生成”这个闭环里最脆弱、也最容易被忽视的神经末梢。
我是在AI Makerspace的LLMOps训练营里第一次系统性地啃下这块硬骨头的。当时导师Chris Alexiuk布置了一道作业:用同一套数据、同一个大模型,换三种不同的检索器,跑出三套Ragas分数,然后解释为什么分数会变。这道题看似简单,实则像一把手术刀,逼着你去理解向量数据库底层的chunking逻辑、embedding模型的语义偏移、甚至retriever本身的设计哲学。后来我把这份作业扩展成了这篇实操笔记,目的很明确:不教你怎么“调参”,而是带你亲手造一把尺子,去量清楚你手里的RAG,到底哪块肌肉结实,哪块关节松动。它适合所有已经能跑通RAG流程,但开始对结果稳定性产生怀疑的工程师;也适合那些正被老板追问“我们的RAG准确率到底多少?”却拿不出一份像样报告的产品经理。接下来的内容,没有一句空话,每一个命令、每一行代码、每一个分数背后,都有我踩过的坑和算过的账。
2. 核心思路拆解:为什么必须用合成数据+多维度指标?
2.1 为什么不能直接用真实用户问题做评测?
这是绝大多数人一开始就会掉进去的坑。真实业务中的用户问题,天然带着“长尾性”和“不可控性”。今天问“合同第3.2条怎么理解”,明天可能就问“把上个月所有带‘违约金’字样的邮件摘要成三句话”。用这种数据去测,你得到的不是一个分数,而是一堆噪音。更致命的是,你根本找不到“Ground Truth”——那个“标准答案”是谁定的?法务?销售?还是老板?没有统一标尺,所有后续优化都是蒙眼走路。
所以,第一道铁律就是:评测必须基于可控、可复现、有明确定义的合成数据集。这不是偷懒,而是工程化的起点。就像芯片测试要用标准信号源,而不是等用户插上U盘再开机。
2.2 为什么Ragas的七个指标一个都不能少?
很多人看到Ragas输出一堆0到1的小数,第一反应是挑个最高的吹嘘:“我们Answer Relevancy高达0.92!” 这就像只看汽车仪表盘上的“时速表”,却无视“水温”、“油压”、“胎压”一样危险。这七个指标,其实是从七个完全不同的切面,共同拼出RAG健康度的全息图:
Answer Relevancy(答案相关性):这是用户的“第一眼感受”。它不关心你答得对不对,只问“你答的,是不是我问的?” 比如问“苹果手机电池续航多久”,你答“iPhone 15 Pro Max在视频播放下可达29小时”,这就是高分;你答“锂电池的工作原理是锂离子在正负极间移动”,哪怕百分百正确,也是低分。它用一个小型判别模型(通常是text-embedding-3-small)把问题和答案向量化,算余弦相似度。关键点在于:它只看最终输出,完全不看中间过程。
Faithfulness(忠实度):这是对“幻觉”的精准狙击。它强制模型回答里的每一个事实性陈述,都必须能在检索到的Context里找到支撑。实现方式很巧妙:它先把答案拆成若干个独立的“主张句”(claim),比如“iPhone 15 Pro Max续航29小时”就是一个主张;然后,它用另一个小型模型去判断,Context里是否包含支持这个主张的证据。注意:它不要求Context里原封不动出现这句话,只要语义支持即可。所以,如果Context写的是“官方标称视频播放时间为29小时”,这就足够了。这个指标低,说明你的RAG正在“自由发挥”。
Context Precision(上下文精确率):这是对“检索器”的直接拷问。它问的是:“你给我找来的5个chunk里,有几个是真的有用的?” 计算方式是:对每个检索到的chunk,用一个小模型判断它和问题的相关性,然后算平均分。它暴露的是检索器的“泛滥”问题。很多团队抱怨“召回率高但效果差”,根源往往就在这里——检索器为了确保不漏掉任何可能相关的信息,把大量边缘内容也拉进来了,污染了生成环节。
Context Recall(上下文召回率):这是Context Precision的“镜像兄弟”。它不看你找来的chunk好不好,而是问:“所有真正有用的信息,你都找全了吗?” 实现上,它需要一个“黄金上下文集”(golden context set),通常是人工标注或由更强模型生成的、覆盖问题所有关键点的文本集合。然后,它计算检索结果与这个黄金集合的重合度。高Recall但低Precision,是典型的“宁可错杀一千,不可放过一个”式检索策略。
Context Relevancy(上下文相关性):这个指标最容易被误解。它不是看单个chunk,而是看整个检索结果集作为一个整体,与问题的相关程度。它用一个模型把问题和所有检索到的chunk拼在一起,再算一个综合相似度。它的低分,往往意味着检索器找来的是一堆“正确但无关”的信息。比如问“如何申请专利”,它给你找来《专利法》全文、《审查指南》目录、以及某公司三年前的专利授权公告——每一条都“正确”,但组合起来,离用户的真实需求(比如“个人如何在线提交发明专利申请”)却很远。
Answer Correctness(答案正确性):这是最接近传统NLP评测的指标。它把模型生成的答案,和人工撰写的Ground Truth进行比对,计算语义相似度(通常用BERTScore)。它衡量的是最终交付物的“保真度”,是用户体验的终极落脚点。但它有个巨大陷阱:如果Ground Truth本身写得模糊或有歧义,这个分数就失去了意义。所以,它必须和Faithfulness配合使用——Correctness告诉你“像不像”,Faithfulness告诉你“靠不靠谱”。
Answer Similarity(答案相似度):这是Correctness的补充,但侧重点不同。它不追求语义等价,而是看答案的“风格”和“结构”是否与Ground Truth一致。比如Ground Truth是分点罗列的,模型答案也是分点,即使具体内容有出入,相似度也会高。它对那些需要固定格式输出的场景(如法律条款摘要、财务报表分析)特别有价值。
提示:这七个指标里,Context Precision、Context Recall、Context Relevancy 三者构成一个“检索质量铁三角”。它们之间存在天然的张力:想提高Precision,往往要牺牲Recall;想提高Relevancy,又可能影响Precision。没有“完美”的平衡点,只有根据业务场景做的取舍。比如,做法律咨询,你宁可Recall低一点(漏掉次要条款),也要保证Precision和Relevancy极高(绝不能引入错误法条);而做市场情报初筛,你可能更看重Recall,允许一定噪声。
2.3 为什么必须对比多种检索方法?
很多团队在RAG上线后,就默认用上了“向量检索”这一个方案,然后把所有问题都归咎于LLM太小、数据太少。这是一种典型的归因错误。检索方法的选择,本质上是对“信息粒度”和“语义广度”的权衡。我们后面要实操的三种方法,代表了三个经典范式:
基础向量检索(Base Retriever):这是最“朴素”的做法。把文档切成固定大小的块(比如512字符),每个块单独Embedding,存入向量库。用户提问时,把问题也Embedding,然后在向量空间里找最近的K个块。它的优势是快、简单、可解释性强;劣势是“断章取义”——一个完整的概念(比如一个算法的三步描述)可能被切在三个不同的chunk里,导致检索结果支离破碎。
父文档检索(Parent Document Retriever):这是对基础方法的“外科手术式”改良。它先用小chunk(如256字符)做精细检索,定位到几个最相关的“子块”;然后,它立刻回溯,把这些子块所属的“父文档”(比如一整篇PDF的某一页,或一个Markdown文件的某一小节)找出来,用父文档的完整内容去生成答案。它解决了“信息碎片化”问题,代价是可能引入大量冗余信息,拉低Context Precision。就像医生做手术,先用显微镜找到病灶细胞(子块),再切下包含这个细胞的一整片组织(父文档)去做病理分析。
混合检索(Hybrid Retrieval):这是目前最前沿、也最实用的方案。它不把鸡蛋放在一个篮子里,而是让向量检索(语义匹配)和关键词检索(BM25)同时工作,然后对两者的排序结果进行加权融合。向量检索擅长理解“苹果手机”和“iPhone”是同义词;BM25则擅长捕捉“iOS 17.4”这种精确版本号。两者结合,相当于给检索器配了一副“双焦眼镜”,既看得远(语义),又看得清(关键词)。后面我们会用
rank_bm25和Chroma的混合模式来实操。
3. 核心细节解析与实操要点:从零构建可复现的评测流水线
3.1 数据准备:为什么选arXiv上的RAG评测论文?
数据是评测的生命线。选什么数据,决定了你测出来的结果,到底是在反映模型能力,还是在反映数据偏见。我们选择arXiv上9篇关于“RAG评测”的论文,原因有三:
- 领域高度内聚:这些论文讨论的全是“如何评价RAG”,内容天然围绕指标、方法、陷阱展开。这保证了我们生成的Q&A对,其主题、术语、逻辑深度都高度一致,避免了跨领域数据带来的噪声。
- 信息密度高,结构清晰:学术论文的Abstract、Introduction、Related Work部分,本身就是为“提出问题-给出答案”而生的。这极大降低了后续合成数据的质量风险。
- 版权无虞,获取便捷:arXiv是开放获取平台,所有论文均可合法下载、处理,无需担心商业授权问题。
具体操作上,我们用LangChain的ArxivLoader,传入9个arXiv ID(如2310.13800),它会自动抓取PDF,解析文本,并保留元数据(标题、作者、摘要)。这里有个关键细节:ArxivLoader默认会尝试解析PDF,但如果遇到扫描版PDF,它会失败。我们的应对策略是,在load()之后,检查每个Document对象的page_content长度。如果普遍小于100字符,大概率是解析失败,此时应手动下载PDF,用PyMuPDF(即fitz)库重新解析。代码如下:
# 如果 arxiv_loader 解析失败,手动用 PyMuPDF 处理 import fitz # PyMuPDF def load_pdf_with_pymupdf(pdf_path): doc = fitz.open(pdf_path) text = "" for page in doc: text += page.get_text() return text # 示例:对某个ID手动处理 pdf_content = load_pdf_with_pymupdf("2310.13800.pdf") doc = Document(page_content=pdf_content, metadata={"source": "2310.13800", "title": "Evaluation Metrics in the Era of GPT-4"})3.2 文本切分:RecursiveCharacterTextSplitter的参数玄机
切分(Chunking)是RAG的“第一道闸门”,它的好坏,直接决定了后续所有环节的上限。RecursiveCharacterTextSplitter之所以被LangChain推荐为默认,是因为它模拟了人类阅读的“递归停顿”习惯:先看段落空行,不行再看换行,再不行看空格……直到切出合适的块。
我们使用的参数是:
text_splitter = RecursiveCharacterTextSplitter( chunk_size=512, chunk_overlap=16, length_function=len )chunk_size=512:这不是拍脑袋定的。BGE-large-en-v1.5这个Embedding模型,其最佳输入长度就在512 token左右。超过这个长度,Embedding的质量会显著下降,导致向量空间失真。你可以用tokenizer.encode()来验证:from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("BAAI/bge-large-en-v1.5") sample_text = "A very long text..." * 100 print(len(tokenizer.encode(sample_text))) # 确保结果 <= 512chunk_overlap=16:这16个字符,是留给“语境粘合剂”的。想象一个chunk结尾是“该算法的核心步骤包括:1. 初始化;2. 迭代优化;3.”,下一个chunk开头是“收敛判断。实验表明...”。如果没有重叠,第二步的“迭代优化”和第三步的“收敛判断”就被硬生生割裂了。16个字符的重叠,刚好能保住句尾的动词和句首的名词,让语义连贯。实测下来,16是一个甜点值:小于10,粘合不足;大于32,冗余过大,浪费向量库空间。length_function=len:这里用的是字符数,而非token数。因为len()计算快,且对于英文为主的学术论文,字符数和token数的比例相对稳定(约1:1.3)。如果你处理的是中日韩文本,强烈建议换成lambda x: len(tokenizer.encode(x)),因为一个汉字可能对应多个subword token。
注意:切分完成后,务必做两件事:1)
print(len(docs))确认总chunk数(我们得到1338个);2)print(max([len(d.page_content) for d in docs]))确认最大chunk长度。如果后者远超512,说明length_function没起作用,需要检查是否误用了token_count函数。
3.3 Embedding模型选型:为什么是BGE-large-en-v1.5?
Embedding模型是RAG的“眼睛”,它决定了系统能否看懂语义。我们放弃OpenAI的text-embedding-3-small,而选用开源的BAAI/bge-large-en-v1.5,理由非常务实:
- MTEB榜单常青树:在权威的MTEB(Massive Text Embedding Benchmark)排行榜上,BGE系列长期稳居英文榜Top 3。它在“Retrieval”(检索)这一细分任务上的得分,远超同级别模型,这意味着它专门为RAG场景做了极致优化。
- 免费、可控、可私有化:不需要依赖外部API,所有计算都在本地GPU上完成,数据不出内网,符合企业级安全要求。
- CUDA加速友好:BGE模型对
torch.bfloat16精度支持极佳,在A100上推理速度比同等规模的Sentence-BERT快40%以上。
加载时的关键配置:
hf_bge_embeddings = HuggingFaceBgeEmbeddings( model_name="BAAI/bge-large-en-v1.5", model_kwargs={'device': 'cuda'}, # 强制指定GPU encode_kwargs={'normalize_embeddings': True} # 必须设为True! )normalize_embeddings=True是重中之重。它确保所有向量的L2范数为1,这样在向量库中计算余弦相似度时,就等价于计算点积,速度提升一个数量级。如果忘了这行,你的检索速度会慢得让你怀疑人生。
3.4 RAG Pipeline构建:从Prompt设计到模型量化
Pipeline的健壮性,直接决定了评测结果的可信度。我们选用dragon-deci-7b-v0这个模型,不是因为它最大,而是因为它最“专”——它是Ai Bloks团队针对RAG任务专门微调的7B模型,在“Not Found识别”、“Yes/No分类”等RAG核心技能上,准确率超过90%。
构建Pipeline的难点在于模型量化与Prompt工程的协同:
量化配置(BitsAndBytesConfig):在A100上运行7B模型,4-bit量化是性价比之王。我们采用
nf4(Normal Float 4)量化类型,它比传统的int4在保持精度上更优。bnb_4bit_use_double_quant=True开启双重量化,能进一步压缩显存占用。最关键的是bnb_4bit_compute_dtype=torch.bfloat16,这行代码让A100的Tensor Core得以全力运转,推理速度比float16快15%。Prompt模板的“防御性”设计:我们的Prompt是:
<human>: Answer the question based only on the following context. If you cannot answer the question with the context, please respond with 'I don't know': ### CONTEXT {context} ### QUESTION Question: {question} <bot>:这个模板有三重保险:
- 指令前置:第一句就用最强硬的语气(
based only on)框定知识边界; - 兜底机制:明确要求模型在无法作答时,必须输出
I don't know,而不是胡编乱造。这为后续的Faithfulness计算提供了清晰的“否定样本”; - 结构化分隔:
### CONTEXT和### QUESTION用特殊符号包裹,能有效防止模型把上下文和问题混淆。实测发现,去掉这些符号,Answer Correctness会下降0.05。
- 指令前置:第一句就用最强硬的语气(
实操心得:在
HuggingFacePipeline初始化时,max_length=4096必须与generation_config.max_length严格一致。否则,pipeline会截断输出,导致答案不完整,所有评测指标都会崩盘。这是一个极其隐蔽的坑,我曾为此调试了整整一个下午。
4. 实操过程与核心环节实现:手把手跑通全流程
4.1 合成数据集构建:GPT-3.5与GPT-4的“分工协作”
合成数据是评测的灵魂,其质量直接决定评测结果的“含金量”。我们的策略是:用GPT-3.5“出题”,用GPT-4“阅卷”,形成一个闭环的、自洽的评估体系。
4.1.1 用GPT-3.5生成Q&A对
核心逻辑是:把每一个文本块(chunk)当作一道“考题”的题干,让GPT-3.5扮演大学教授,为它出一道“高级”问题。这比让模型自己“想问题”要可靠得多,因为问题的源头(chunk)是确定的、可控的。
关键代码与原理:
# 定义输出Schema,强制模型返回JSON question_schema = ResponseSchema(name="question", description="a question about the context.") question_output_parser = StructuredOutputParser.from_response_schemas([question_schema]) format_instructions = question_output_parser.get_format_instructions() # 构建Prompt,强调“specific to the context”和“avoid generic” qa_template = """<human>: You are a University Professor creating a test for advanced students. For each context, create a question that is specific to the context. Avoid creating generic or general questions. question: a question about the context. Format the output as JSON with the following keys: question context: {context} <bot>:""" prompt_template = ChatPromptTemplate.from_template(qa_template) # 调用模型 messages = prompt_template.format_messages(context=docs[0].page_content, format_instructions=format_instructions) response = question_generation_chain.invoke({"content": messages}) output_dict = question_output_parser.parse(response.content)为什么用GPT-3.5而不是更便宜的模型?因为它在“遵循指令”和“生成高质量问题”上的平衡性最好。用gpt-3.5-turbo-instruct,它经常忽略Format as JSON的指令;用llama-3-70b,它生成的问题又过于发散。GPT-3.5-turbo-1106是经过大量验证的“甜点模型”。
4.1.2 用GPT-4生成Ground Truth答案
这是整个流程中最烧钱、也最关键的一步。我们必须用最强的模型,为每一个Q&A对生成一个“无可争议”的标准答案。
核心代码:
answer_schema = ResponseSchema(name="answer", description="an answer to the question") answer_output_parser = StructuredOutputParser.from_response_schemas([answer_schema]) format_instructions = answer_output_parser.get_format_instructions() # Prompt强调“answer about the context”,并给出question和context qa_template = """<human>: You are a University Professor creating a test for advanced students. For each question and context, create an answer. answer: a answer about the context. Format the output as JSON with the following keys: answer question: {question} context: {context} <bot>:""" # ... (调用gpt-4-1106-preview)成本与时间控制技巧:gpt-4-1106-preview的输入token价格是$0.01/1K tokens。我们100个Q&A对,平均每个context约400 tokens,question约50 tokens,answer约150 tokens,总输入约60K tokens,成本约$0.6。为了提速,我们用tqdm加进度条,并设置temperature=0(禁用随机性),确保每次运行结果完全一致,方便复现。
4.1.3 数据集结构化:Pandas DataFrame的黄金格式
Ragas要求输入数据必须是特定格式的Dataset对象。我们先用Pandas构建一个中间DataFrame,这是最灵活、最易调试的方式:
# 创建一个空列表,用于存储所有Q&A三元组 qac_triples = [] # 遍历100个随机选取的chunk for text in tqdm(random.sample(docs, 100)): # 生成question... # 生成answer... # 将三者打包成字典 triple = { "question": output_dict["question"], "context": text.page_content, "ground_truth": output_dict["answer"] } qac_triples.append(triple) # 转为DataFrame eval_df = pd.DataFrame(qac_triples) # 此时,eval_df有三列:question, context, ground_truth这个eval_df就是我们的“黄金数据集”。它干净、透明、可打印、可修改。后续所有Ragas评测,都基于它衍生。
4.2 Ragas评测流水线:从Dataset构建到指标计算
Ragas的评测流程,可以拆解为三个原子操作:数据注入 → 指标计算 → 结果可视化。
4.2.1 数据注入:create_ragas_dataset()函数的精妙之处
这个函数是连接我们自定义Pipeline和Ragas的桥梁。它的核心任务,是把一个question,喂给我们的RAG Pipeline,拿到answer和contexts,然后和已有的ground_truth一起,组装成Ragas能认的格式。
关键代码:
def create_ragas_dataset(rag_pipeline, eval_dataset): rag_dataset = [] for row in tqdm(eval_dataset): # row 是 eval_df 的一行 # 关键:调用我们的Pipeline,传入question answer = rag_pipeline.invoke({"question": row["question"]}) # 提取answer和contexts rag_dataset.append({ "question": row["question"], "answer": answer["response"], # Pipeline输出的response字段 "contexts": [context.page_content for context in answer["context"]], # Pipeline输出的context字段 "ground_truths": [row["ground_truth"]] # 注意:必须是list,即使只有一个 }) # 转为pandas DataFrame rag_df = pd.DataFrame(rag_dataset) # 再转为Ragas的Dataset对象 rag_eval_dataset = Dataset.from_pandas(rag_df) return rag_eval_dataset为什么contexts要提取page_content?因为Ragas内部计算Context Precision等指标时,需要的是纯文本,而不是LangChain的Document对象。answer["context"]是一个Document列表,我们必须显式地取出.page_content。
4.2.2 指标计算:evaluate()函数的参数艺术
ragas.evaluate()是真正的“引擎”。它的参数设计,体现了对评测科学性的深刻理解:
result = evaluate( ragas_dataset, # 我们刚构建好的Dataset metrics=[ # 指定要计算哪些指标 context_precision, faithfulness, answer_relevancy, context_recall, context_relevancy, answer_correctness, answer_similarity ], # 可选:指定LLM用于计算某些指标(如Faithfulness) llm=ChatOpenAI(model="gpt-4-1106-preview", temperature=0), # 可选:指定Embedding模型 embeddings=hf_bge_embeddings )关键点:
llm参数:虽然Ragas内置了轻量级模型,但对于生产环境,强烈建议传入一个强模型(如GPT-4)。因为Faithfulness和Answer Correctness的计算,本质上也是“语言理解”任务,用弱模型去评判强模型,结果不可信。embeddings参数:必须和我们构建向量库时用的Embedding模型一致(这里是hf_bge_embeddings)。否则,计算Answer Relevancy时,问题和答案的向量不在同一个空间,分数毫无意义。
4.2.3 结果可视化:Matplotlib绘图的实战技巧
Ragas的result是一个Result对象,它内部是一个pandas.Series。我们用plot_metrics_with_values()函数将其可视化:
def plot_metrics_with_values(metrics_dict, title='RAG Metrics'): names = list(metrics_dict.keys()) values = list(metrics_dict.values()) plt.figure(figsize=(10, 6)) # 使用水平条形图(barh),更适合长指标名 bars = plt.barh(names, values, color='skyblue') # 在每个条形上标注数值 for bar in bars: width = bar.get_width() plt.text(width + 0.01, bar.get_y() + bar.get_height() / 2, f'{width:.4f}', va='center') plt.xlabel('Score') plt.title(title) plt.xlim(0, 1) # 强制x轴范围为0-1 plt.show()为什么用水平条形图(barh)?因为指标名称(如context_relevancy)很长,垂直条形图会让文字挤在一起,难以阅读。水平图则一目了然。plt.xlim(0, 1)是强制规范,让所有图表的尺度统一,方便横向对比。
4.3 对比实验:三种检索器的实战性能剖析
现在,我们进入最激动人心的部分:用同一套数据、同一个LLM,只换检索器,看分数如何跳动。
4.3.1 基础向量检索(Base Retriever):基准线的建立
这是我们所有对比的“锚点”。它的构建极其简单:
# vectorstore 已经在前面创建好了 base_retriever = vectorstore.as_retriever(search_kwargs={"k": 5})search_kwargs={"k": 5}表示每次检索返回5个chunk。这个k=5不是随便定的,它是基于经验:太少(k=3)可能导致Context Recall不足;太多(k=10)会严重拉低Context Precision。我们后续所有实验,都保持k=5不变,确保变量唯一。
4.3.2 父文档检索(Parent Document Retriever):解决“碎片化”的利器
这是对基础检索的升级。它的核心思想是“两级检索”:先用小chunk快速定位,再用大chunk提供完整语境。
构建代码:
from langchain.retrievers import ParentDocumentRetriever from langchain.storage import InMemoryStore # 定义两种切分器 parent_splitter = RecursiveCharacterTextSplitter(chunk_size=1536) # 大块,作为父文档 child_splitter = RecursiveCharacterTextSplitter(chunk_size=256) # 小块,用于检索 # 创建新的向量库,只存小chunk(子块) vectorstore = Chroma(collection_name="split_parents", embedding_function=hf_bge_embeddings) store = InMemoryStore() # 内存存储,存放父文档 # 创建父文档检索器 parent_document_retriever = ParentDocumentRetriever( vectorstore=vectorstore, docstore=store, child_splitter=child_splitter, parent_splitter=parent_splitter, ) # 将原始的大文档(docs)喂给它,它会自动切分成子块、嵌入、存入vectorstore, # 并将父文档存入store parent_document_retriever.add_documents(docs)关键洞察:ParentDocumentRetriever的add_documents()方法,会执行一个“隐式”的双重切分。它先用child_splitter把docs切成256字符的小块,存入vectorstore;然后,它会记住每个小块属于哪个parent_splitter切出来的父块,并把父块存入store。当用户提问时,它先在vectorstore里找5个小块,然后去store里把这5个小块对应的父块(可能是1-3个)取出来,作为最终的contexts。这解释了为什么它的Context Precision会下降——它返回的不再是5个精准的小块,而是1-3个信息丰富但可能冗余的大块。
4.3.3 混合检索(Hybrid Retrieval):语义+关键词的“双保险”
这是目前工业界最推荐的方案。我们用Chroma的similarity_search_with_score结合rank_bm25来实现。
核心代码:
from rank_bm25 import BM25Okapi import numpy as np # 1. 为所有docs构建BM25索引 tokenized_docs = [doc.page_content.split() for doc in docs] bm25 = BM25Okapi(tokenized_docs) # 2. 定义混合检索函数 def hybrid_retrieve(query, k=5, alpha=0.5): # 向量检索 vector_results = vectorstore.similarity_search_with_score(query, k=k) # BM25检索 tokenized_query = query.split() bm25_scores = bm25.get_scores(tokenized_query) # 获取top-k的索引 bm25_indices = np.argsort(bm25_scores)[::-1][:k] bm25_results = [(docs[i], bm25_scores[i]) for i in bm25_indices] # 加权融合:alpha * vector_score + (1-alpha) * bm25_score # 注意:vector_scores是距离(越小越好),需归一化;bm25_scores是得分(越大越好) # 这里简化处理,直接用负距离和bm25_score相加 all_results = {} for doc, score in vector_results: all_results[doc] = -score * alpha for doc, score in bm25_results: if doc in all_results: all_results[doc] += score * (1 - alpha) else: all_results[doc] = score * (1 - alpha) # 返回top-k sorted_results = sorted(all_results.items(), key=lambda x: x[1], reverse=True)[:k] return [doc for doc, _ in sorted_results] # 3. 将其包装成LangChain的Retriever class HybridRetriever: def __init__(self, vectorstore, bm25, docs, alpha=0.5): self.vectorstore = vectorstore self.bm25 = bm25 self.docs = docs self.alpha = alpha def get_relevant_documents(self, query): return hybrid_retrieve(query, k=5, alpha=self.alpha) hybrid_retriever = HybridRetriever(vectorstore, bm25, docs, alpha=0.7)alpha=0.7的含义:我们给向量检索(语义)70%的权重,给BM25(关键词)30%的权重。这个值是通过在10个样本上手工调优得到的。alpha太高,会丢失精确关键词匹配;alpha太低,则语义理解能力下降。实测发现,对于技术文档,0.6-0.8是最佳区间。
5. 常见问题与排查技巧实录:那些文档里不会写的血泪教训
5.1 “Ragas报错:'NoneType' object has no attribute 'page_content'”
这是新手遇到的第一个“拦路虎”。错误栈通常指向create_ragas_dataset()函数里answer["context"]这一行。根本原因只有一个:你的RAG Pipeline在某个问题上,没有返回任何context。这通常发生在两种情况:
- **