LLM Zoomcamp 的 RAG Helper:用 ingest.py 与 RAGBase 把 RAG 流水线封装成可复用模块
2026/9/17 0:59:42 网站建设 项目流程

LLM Zoomcamp 的 RAG Helper:用 ingest.py 与 RAGBase 把 RAG 流水线封装成可复用模块

【免费下载链接】llm-zoomcampLLM Zoomcamp - a free online course about real-life applications of LLMs. In 10 weeks you will learn how to build an AI system that answers questions about your knowledge base. Register here 👇🏼项目地址: https://gitcode.com/GitHub_Trending/ll/llm-zoomcamp

导读

在 LLM Zoomcamp 的 Agentic RAG 模块中,前几课分别实现了搜索、提示词构建和 LLM 调用,RAG 流水线已经能跑通,但每次使用都要重复粘贴同样的代码。本篇指南基于课程第 8 课 08-rag-helper.md,讲解如何把这些逻辑收敛到两个可复用文件——ingest.py(数据加载与索引构建)和rag_helper.py(搜索、提示词、LLM 调用三合一的RAGBase类)——并在 Notebook 中一行导入、即插即用。读完本文,你将掌握一套面向小数据集的可复用 RAG 工程骨架,并理解"索引即依赖"的模块化设计如何为后续替换持久化搜索后端(sqlitesearch)铺平道路。

为什么需要 RAG Helper:从零散函数到两个文件

在前几课中,我们是分步搭建 RAG 流水线的:

  • 05-search.md 用 minsearch 构建了关键字搜索,并把搜索封装成search(question)函数;
  • 06-building-prompt.md 定义了INSTRUCTIONSbuild_contextbuild_prompt
  • 07-llm.md 通过 OpenAI Responses API 实现了llm(),最后用rag()把三者串起来。

流水线是工作的,但存在一个明显问题:这些函数和indexopenai_client等全局变量耦合在单个 Notebook 中。每次新建一个实验场景,都要把代码重新抄一遍;索引和客户端也被"钉死"在文件里,难以替换。

课程的解决思路是把代码整理成两个可复用的文件(这也是整门课程后续所有模块的基础设施):

  • code/ingest.py:负责加载数据、构建搜索索引,即"搜索之前要做的一切";
  • code/rag_helper.py:负责RAG 核心逻辑(搜索、提示词构建、LLM 调用)。

此后在 Notebook 中只需from ingest import ...from rag_helper import RAGBase,即可直接使用。

ingest.py:数据加载与索引构建

ingest.py只做两件事:从 DataTalksClub 拉取 FAQ 数据,以及用 minsearch 构建内存索引。完整实现见 code/ingest.py,源码与文档完全一致:

import requests from minsearch import Index def load_faq_data(): docs_url = 'https://datatalks.club/faq/json/courses.json' response = requests.get(docs_url) courses_raw = response.json() documents = [] url_prefix = 'https://datatalks.club/faq' for course in courses_raw: course_url = f'{url_prefix}{course["path"]}' course_response = requests.get(course_url) course_response.raise_for_status() course_data = course_response.json() documents.extend(course_data) return documents def build_index(documents): index = Index( text_fields=['question', 'section', 'answer'], keyword_fields=['course'] ) index.fit(documents) return index

load_faq_data():两级 JSON 拉取

load_faq_data()的工作方式值得拆解:

  1. 先请求courses.json这个索引文件,拿到所有课程的元数据(每条包含一个path字段);
  2. https://datatalks.club/faq为前缀拼接出每门课的 FAQ 地址,逐个请求;
  3. raise_for_status()在请求失败时立即抛出异常,避免静默拿到损坏数据;
  4. 把所有课程的文档extend进同一个列表并返回。

注意课程数据包含不止一门课(如 ML Engineering、Data Engineering、MLOps 等 Zoomcamp),所以最终documents里混有多个课程的 FAQ 条目,这为后面的course关键字过滤埋下伏笔。

build_index():minsearch 索引的字段划分

build_index()使用 minsearch 的Index构建索引,字段划分沿用了 05-search.md 的设计:

  • text_fields(文本字段)questionsectionanswer。搜索引擎会把这些字段分词、转小写、去停用词,用于相关性打分与排序;
  • keyword_fields(关键字字段)course。关键字字段不做分词,只做精确匹配,相当于 SQL 里的WHERE course = 'llm-zoomcamp',用于把检索范围限制在某一门课内。

index.fit(documents)的名称沿袭 scikit-learn 的惯例——像在数据上"拟合模型"一样在文档上"拟合索引"。FAQ 数据集规模不大(约 1100 条文档),minsearch 的内存索引在启动时构建耗时不足一秒,这正是第一阶段选择它的原因。

这个文件还预留了演进空间:课程说明中明确提到,后续会在同一个文件中加入 sqlitesearch 支持,用于持久化搜索索引(见 09-data-ingestion.md)。

rag_helper.py:把 RAG 逻辑封装成 RAGBase 类

rag_helper.py的前半部分是两条 Prompt 常量,与 06-building-prompt.md 中定义的内容一脉相承:

INSTRUCTIONS = ''' Your task is to answer questions from the course participants based on the provided context. Use the context to find relevant information and provide accurate answers. If the answer is not found in the context, respond with "I don't know." ''' PROMPT_TEMPLATE = ''' QUESTION: {question} CONTEXT: {context} '''.strip()
  • INSTRUCTIONS固定不变的系统级指令:告诉模型只依据给定上下文作答,找不到答案就说 "I don't know.",这是把回答"锚定"在知识库、抑制幻觉的关键;
  • PROMPT_TEMPLATE每次变化的用户提示词模板,预留了{question}{context}两个占位符,供每轮请求填充。

为什么用类而不是继续用全局函数?

课程原文给出了非常清晰的工程理由:在前几课的 Notebook 中,indexopenai_client全局变量,函数直接闭包引用它们。一旦把函数抽到独立文件,这些全局变量就不存在了。两条出路:

  • 把全局变量 import 回来:文件就被"绑死"在某个特定索引和某个特定客户端上,换个索引或换家模型就得改代码,复用性差;
  • 把依赖装进类里:索引和 LLM 客户端变成构造函数参数,创建对象时想传什么就传什么。

课程选择了后者。类的另一个好处是可以继承:将来想替换其中某一块(例如把 OpenAI 换成本地模型),只需子类化RAGBase并覆写对应方法,其余部分原样保留。

RAGBase 的构造函数与六个方法

class RAGBase: def __init__( self, index, llm_client, instructions=INSTRUCTIONS, prompt_template=PROMPT_TEMPLATE, course='llm-zoomcamp', model='gpt-5.4-mini' ): self.index = index self.llm_client = llm_client self.instructions = instructions self.course = course self.prompt_template = prompt_template self.model = model

构造函数有两条必传依赖、四个带默认值的参数:

参数是否必传默认值说明
index必传任何带search方法的索引对象,minsearch、sqlitesearch 均可
llm_client必传OpenAI 风格的 LLM 客户端(如OpenAI()
instructions可选INSTRUCTIONS覆写系统指令
prompt_template可选PROMPT_TEMPLATE覆写提示词模板
course可选'llm-zoomcamp'检索过滤用的课程关键字
model可选'gpt-5.4-mini'调用的模型名

index的抽象约定是"只要有search方法就行"——这正是后续用 sqlitesearch 无缝替换 minsearch 的接口基础。

1.search():委托给索引,并应用提升与过滤

def search(self, query, num_results=5): boost_dict = {'question': 3.0, 'section': 0.5} filter_dict = {'course': self.course} return self.index.search( query, num_results=num_results, boost_dict=boost_dict, filter_dict=filter_dict )

search方法把 boost/filter 规则固化下来:question字段权重提到 3.0(命中问题的关键词比命中章节名更有信号价值),section降到 0.5,同时强制只返回course等于当前课程的结果。相比前序课程中 05-search.md 里question: 2.0的示例,这里把问题字段的权重进一步提高,说明这些数值是可调的超参数,可以按数据集反复实验。

2.build_context():把检索结果格式化成上下文文本

def build_context(self, search_results): lines = [] for doc in search_results: lines.append(doc['section']) lines.append('Q: ' + doc['question']) lines.append('A: ' + doc['answer']) lines.append('') return '\n'.join(lines).strip()

每条文档被展开成"章节名 + Q + A"三行文本块,多个文档用空行分隔。这一步把检索返回的字典列表"预处理"成 LLM 易读的字符串——与 06-building-prompt.md 中的build_context完全一致。

3.build_prompt():组装最终提示词

def build_prompt(self, query, search_results): context = self.build_context(search_results) return self.prompt_template.format( question=query, context=context )

str.format把查询和上下文填进PROMPT_TEMPLATE的占位符。因为模板是可配置的,想调整提示词结构只需换一个prompt_template字符串。

4.llm():调用 LLM 客户端

def llm(self, prompt): input_messages = [ {'role': 'developer', 'content': self.instructions}, {'role': 'user', 'content': prompt} ] response = self.llm_client.responses.create( model=self.model, input=input_messages ) return response.output_text

这里沿用了 07-llm.md 介绍的消息历史格式:发送两条消息——developer角色携带固定的INSTRUCTIONS(系统级行为约束),user角色携带每次变化的提示词。调用的是 OpenAI 的Responses APIresponses.create,而非旧的 chat completions),response.output_text是直达答案文本的快捷属性,免去逐层解析response.output[0].content[0].text的麻烦。

5.rag():流水线收口

def rag(self, query): search_results = self.search(query) prompt = self.build_prompt(query, search_results) answer = self.llm(prompt) return answer

rag()是面向使用者的唯一入口:搜索 → 构建提示词 → 调 LLM,三步串成一条完整的 RAG 链路。六个方法形成清晰的分层——外层使用者只关心rag(),内部每一环又可单独覆写。

在 Notebook 中使用:导入即用

课程给出的使用方式非常简洁(完整参考见 code/notebook.ipynb):

from dotenv import load_dotenv load_dotenv() from ingest import load_faq_data, build_index from rag_helper import RAGBase from openai import OpenAI documents = load_faq_data() index = build_index(documents) openai_client = OpenAI() assistant = RAGBase( index=index, llm_client=openai_client, ) answer = assistant.rag("I just discovered the course. Can I join now?") print(answer)

几个细节值得注意:

  • load_dotenv():从.env文件加载OPENAI_API_KEY,之后OpenAI()无需显式传 key(环境配置见 02-environment.md);
  • 只传两个必选参数instructionsprompt_templatecoursemodel全部走rag_helper.py里的默认值;
  • 数据流一目了然load_faq_data()build_index()完成索引,RAGBase拿到索引与客户端后即可回答。

覆写默认行为:定制指令与更多问题

默认指令不一定满足所有场景。课程展示了如何传入自定义指令来改变模型行为:

custom_instructions = """ You're a course teaching assistant. Answer the QUESTION based on the CONTEXT from the FAQ database. Use only the facts from the CONTEXT when answering the QUESTION. """.strip() assistant = RAGBase( index=index, llm_client=openai_client, instructions=custom_instructions, )

同样的索引、同样的客户端,只换一段指令,模型就换了一种人设与约束。这种"只覆写单一依赖"的能力正是构造函数参数化设计的红利。

接着可以连续追问多轮:

assistant.rag("How do I get a certificate?") assistant.rag("Can I still join the course after it started?")

每轮调用都会独立完成检索、提示词组装与生成,答案引用的是 FAQ 中的具体条目,而不是模型的通用知识——这正是 RAG 与裸 LLM 的本质区别。

模块化设计的实战回报:无缝切换持久化索引

RAGBase的接口抽象在下一课 09-data-ingestion.md 中得到直接验证:当数据集变大、需要持久化索引时,课程用 sqlitesearch(与 minsearch 同 API 的 SQLite FTS5 封装)替换内存索引,RAG 代码一行不改

from sqlitesearch import TextSearchIndex sqlite_index = TextSearchIndex( text_fields=["question", "section", "answer"], keyword_fields=["course"], db_path="faq.db" ) assistant = RAGBase( index=sqlite_index, llm_client=openai_client, )

正如课程 09 课所强调的:minsearch 是纯内存索引,进程一停数据即失;而 sqlitesearch 把索引写入faq.db文件,支持"一个进程写入、另一个进程查询"。这种能力之所以能零成本获得,正是因为RAGBase只依赖index.search(query, boost_dict, filter_dict, num_results)这一统一接口——如果后端 API 不同,才需要子类化RAGBase覆写search方法去适配。

小结:课程后续模块的地基

这两个文件的价值远超第 8 课本身。从模块 README.md 可以看到,它们是整个 Agentic RAG 模块的公共基础设施:后续 09-data-ingestion.md 用它接持久化索引,第 13 课函数调用、第 14 课 agentic loop 也建立在RAGBase之上;再往后,04-evaluation 与 05-monitoring 模块中的同名文件,都是这一设计的延续与演化。

整体分工可以概括为三句话:

  • code/ingest.py 负责数据准备:拉取 FAQ、构建索引;
  • code/rag_helper.py 负责RAG 流水线:搜索、提示词、LLM;
  • Notebook 只负责把它们组装起来,并可按需覆写指令、模板、课程过滤与模型。

这种"数据加载 / 检索生成 / 装配使用"三层分离,正是把实验代码演进为可维护工程的第一步。对任何准备长期迭代的 RAG 项目来说,这套模式都值得直接借鉴。

【免费下载链接】llm-zoomcampLLM Zoomcamp - a free online course about real-life applications of LLMs. In 10 weeks you will learn how to build an AI system that answers questions about your knowledge base. Register here 👇🏼项目地址: https://gitcode.com/GitHub_Trending/ll/llm-zoomcamp

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询