做什么 设计一些基础的 Pipeline,实现最基本的 Rag 功能。
开始做 文件入库的做法 新建 Pipeline 文件夹,新建 base_pipeline.py 文件,创建 Pipeline 基类,后续所有的 Pipeline 都将继承这个类。
1 2 3 4 5 6 7 8 9 10 from abc import ABC, abstractmethodclass BasePipeline (ABC ): def __init__ (self, **kwargs ): pass @abstractmethod def run (self, **kwargs ): """执行业务流程""" pass
新建 ingestion_pipeline.py 文件,实现文件加载、解析、切片录入数据库。但是在写这个 Pipeline 之前,我们应该还实现一个 ProcessorFactor 功能,让文件自适应解析,而非我们制定 processor。在 file_process 文件夹中新建 processor_factory.py 文件,并写入如下内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 from pathlib import Pathfrom file_process.PPTx_peocess import PPTxProcessorclass ProcessorFactory : @staticmethod def create (file_path:str ): suffix = Path(file_path).suffix.lower() if suffix == ".pptx" : return PPTxProcessor() else : raise ValueError( f"不支持的文件类型:{suffix} " )
接下来在 pipeline 中新建 ingestion_pipeline 文件,写入如下内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 from pipeline.base_pipeline import BasePipelinefrom embedding .tongyi_embedding import TongyiEmbeddingfrom file_process.PPTx_peocess import PPTxProcessorfrom vector_store.chroma_store import ChromaVectorStorefrom file_process.processor_factory import ProcessorFactoryclass IngestionPipeline (BasePipeline ): def __init__ (self, embedding, vector_store, processor_factory=ProcessorFactory ): self .embedding = embedding self .vector_store = vector_store self .processor_factory = processor_factory def run (self, file_path: str ): """执行业务逻辑""" processor = self .processor_factory.create(file_path=file_path) chunks = processor.run(file_path=file_path) print (f"已切分 {len (chunks)} 个切块..." ) vector_chunks = self .embedding.embed(chunks=chunks) print ("向量化已完成..." ) self .vector_store.add(vector_chunks) print ("文件成功入库!" ) return
在 main.py 中调用,非常成功,文件入库。
调用 LLM 回答 首先还是得建立 LLM 基类,后期就可以通过继承基类来实现不同厂家的调用。新建 llm 文件夹,建立 base_llm.py,输入如下内容:
1 2 3 4 5 6 7 8 from abc import ABC, abstractmethodclass BaseLLM (ABC ): @abstractmethod def invoke (self, prompt: str ) -> str : """调用大模型进行回答""" pass
新建 deepseek_llm.py,输入如下内容
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 from .base_llm import BaseLLMfrom openai import OpenAIfrom langchain_core.messages import ( BaseMessage, SystemMessage, HumanMessage, AIMessage ) from config import ( DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL ) class DeepseekLLM (BaseLLM ): def __init__ (self, model ): self .client = OpenAI( api_key=DEEPSEEK_API_KEY, base_url=DEEPSEEK_BASE_URL, ) self .model = model def invoke (self, prompt: list [BaseMessage] ) -> str : openai_messages=[] for msg in prompt: if isinstance (msg, HumanMessage): role = "user" elif isinstance (msg, SystemMessage): role = "system" elif isinstance (msg, AIMessage): role = "assistant" else : raise ValueError( f"不支持的消息类型:{type (msg)} " ) openai_messages.append( { "role" : role, "content" : msg.content } ) response = self .client.chat.completions.create( model=self .model, messages=openai_messages ) return response.choices[0 ].message.content
经过这两步后,我已经明白如何实现一个 LLM Client。那么现在就让我们使用 LangChain 的官方 ChatModel,嵌入我们的系统。安装包:
1 pip install langchain-openai
在 deepseek_llm.py 中写下如下内容,然后在主程序中调用该 Client:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 from langchain_openai import ChatOpenAIfrom langchain_core.messages import ( BaseMessage, SystemMessage, HumanMessage, AIMessage ) from config import ( DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL ) Client = ChatOpenAI( model="deepseek-v4-flash" , api_key=DEEPSEEK_API_KEY, base_url=DEEPSEEK_BASE_URL, )
测试代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 from llm.deepseek_llm import Clientfrom langchain_core.messages import ( HumanMessage, SystemMessage ) messages = [ SystemMessage( content="你是企业知识助手" ), HumanMessage( content="hello,你是谁" ) ] response = Client.invoke(messages) print (response.content)
如果需要流式传输的效果,那么就需要在 ChatModel 创建时加入关键字 stream=True,然后在测试时写下如下代码,即可实现流式传输效果:
1 2 3 4 5 6 for chunk in deepseek_client.stream(messages): print ( chunk.content, end="" , flush=True )
接下来我们应该实现文本的召回。在这一步需要先将提问的文本转换为向量,在 tongyi_embedding.py 中新建 embed_text 函数,内容如下:
1 2 3 4 5 6 7 def embed_text (self, text:str ) -> list [float ]: response = self .client.embeddings.create( model=self .model, input =text ) return response.data[0 ].embedding
在 chroma_store.py 中新增 search 函数,实现在数据库中的相似向量检索:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 def search (self, query_vector: list [float ], top_k:int = 5 )-> list [Chunk]: result = self .collection.query( query_embeddings=[ query_vector ], n_results=top_k ) print (result) documents = result["documents" ][0 ] metadatas = result["metadatas" ][0 ] distances = result["distances" ][0 ] chunks = [] for doc, metadata, distance in zip (documents, metadatas, distances): chunks.append(Chunk( chunk_content=doc, metadata={ **metadata, "distance" :distance } ) ) return chunks
在经过转化,返回的就是相似的 chunks 列表。那么接下来我们就应该做上下文管理了。新建 context 文件夹,新建 rag_context.py 和 context_builder.py 文件。 在 rag_context.py 文件中,我们定义专门用于 RAG 的专属上下文类型,代码如下:
1 2 3 4 5 6 7 8 9 from dataclasses import dataclass, field@dataclass class RAGContext : query: str documents: list history: list = field(default_factory=list ) system_prompt: str = "" metadata: dict = field(default_factory=dict )
在 context_builder.py 中,我们创建 ContextBuilder 类,我们将在其中实现各种类型的上下文构建。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 from langchain_core.messages import HumanMessage, SystemMessage, AIMessage, BaseMessagefrom .rag_context import RAGContextclass ContextBuilder (): def __init__ (self ): pass def rag_context_build (self, context: RAGContext ) -> list [BaseMessage]: messages=[] messages.append( SystemMessage( content=context.system_prompt ) ) docs_text = "\n\n" .join( [ doc.chunk_content for doc in context.documents ] ) user_content = f""" 参考资料: {docs_text} 问题: {context.query} """ messages.append( HumanMessage(content = user_content) ) return messages
新建 query_pipeline.py 文件,实现 rag 自动化,如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 from .base_pipeline imuilder import ContextBuilderfrom context.rag_context import RAGContextSYSTEM_PROMPT = "你是企业知识助手,请严格按照提示词中提供的资料回答问题,如果资料不全无法回答,请如实说明。" port BasePipeline from context.context_bclass QueryPipeline (BasePipeline ): def __init__ (self, client, embedding, store ): self .context_builder = ContextBuilder() self .client = client self .embedding = embedding self .store = store def run (self, query: str ) -> str : """运行 RAG 流程""" query_vector = self .embedding.embed_text(query) docs = self .store.search(query_vector) context= RAGContext( query=query, documents=docs, system_prompt=SYSTEM_PROMPT ) messages = self .context_builder.rag_context_build(context=context) answer = self .client.invoke(messages) return answer
优化 到目前为止,我们已经实现了 RAG 的最小链路,但是文件结构仍需优化。我们询问 codex 如何优化这个架构,其给出如下建议:
1、新建独立领域层,集中定义常用的类型,解除反向依赖。 2、重构入库链路,将 PPTX 加载、清洗、切片拆成基础设施适配器,通过处理器注册表按扩展名选择实现;未知格式返回明确的“不支持文件类型”错误。 3、重构查询链路。拆分为RetrievalService、PromptBuilder 和 AnswerService:Retrieval 负责查询向量化、top-k 检索、阈值过滤;PromptBuilder 负责上下文格式、来源编号和 token/字符预算;AnswerService 负责组合调用并返回结构化答案。 4、集中配置。使用不可变 Settings 管理 API、模型、Chroma 路径、collection、检索参数、切片参数和网络策略。
整体框架 codex 修改完成后,我们再次学习代码结构设计。经过更新后的项目采用了四层架构:
1 2 3 4 5 6 7 presentation 用户交互层 ↓ application 业务编排层 ↓ domain 领域模型与接口层 ↑ infrastructure 外部技术实现层
bootstrap.py 负责把四层组件组装取来,config.py 则负责提供配置
各层职责及文件作用 Domain:领域层 领域层定义系统中的核心数据和接口,不关心 Chroma、OpenAI SDK 或 PPTX 等具体技术,其理想依赖关系如下,应用层使用领域接口编排业务,基础设施层实现领域接口,领域层不依赖其他层:
1 Application ──→ Domain ←── Infrastructure
domain/__init__.py,标记 domain 为 Python 包,并说明该目录存放核心领域类型及端口,没有额外业务逻辑。 domain/models.py,定义整个 RAG 系统使用的数据模型,如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @dataclass(frozen=True ) class Document : page_content: str metadata: Metadata = field(default_factory=dict ) @dataclass(frozen=True ) class Chunk : chunk_id: str chunk_content: str metadata: Metadata = field(default_factory=dict ) @dataclass(frozen=True ) class ChatMessage : role: Literal ["system" , "user" , "assistant" ] content: str
domain/prots.py,使用 Python Protocol 定义系统组件应遵循的接口。
Application:应用层 应用层描述了系统要完成的业务,例如如何完成一次文档入库、如何完成一次检索。它不应该关心 API 请求细节和数据库内部实现。
application/errors.py 定义了统一的应用异常,如下:
1 2 3 4 5 6 RAGError ├── ConfigurationError ├── DocumentProcessingError ├── EmbeddingError ├── StorageError └── GenerationError
CLI 只需要捕获 RAGError,不必分别理解 OpenAI、Chroma 和 python-pptx 的底层异常。
application/ingestion.py,定义 IngestionService,负责完整的文档入库流程:选择处理器、处理文件、处理文档 ID、批量向量化、写入向量库。它不会直接实现 PPTX 解析或 API 请求,只负责编排这些组件。 application/retrieval.py,定义了 RetrievalService,负责:检查查询是否为空;调用 Embedder 生成查询向量;使用 SearchOptions 查询向量库;返回 SearchResult。top_k 和最大距离阈值在构造服务时注入。 application/prompting.py,定义系统提示词和 Prompting.py。它将检索格式化,然后构造系统提示词、用户提示词、参考资料及用户问题。 application/answering.py,定义 AnswerService,是查询链路的总编排器。
Infrastructure:基础设施层 基础设施层处理外部技术,如读取 PPT,调用千问 Embedding 等等。
infrastructure/loaders/registry.py 根据文件扩展名选择处理器。 infrastructure/loaders/pptx.py 定义 PPTXProcessor,负责完整的 PPTX 预处理。 infrastructure/embeddings/openai_compatible.py 实现 OpenAI 兼容的 Embedding 适配器。 infrastructure/vectorstores/__init__.py 实现 Chroma 向量数据库适配器。
Presentation:交互层 交互层负责接收用户输入、显示结果和处理命令行参数,不包含核心 RAG 逻辑。
异常处理机制的重构 经过 Codex 优化后,该项目采用了统一应用异常 + 基础设施异常转换 + CLI 集中处理的机制,其核心目标为不让用户直接看到 OpenAI SDK、Chroma、文件系统等底层异常,而是把它们转换成 RAG 业务能够理解的异常。其整体结构如下:
1 2 3 4 5 6 7 8 9 10 11 底层库异常 OpenAI / Chroma / python-pptx / 文件系统 ↓ 转换 应用级异常 EmbeddingError / StorageError / ... ↓ 传播 Application Service ↓ 传播 CLI 统一捕获 ↓ 向用户打印友好错误并返回退出码 1
其异常类型集中定义在 application/errors.py 文件中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 class RAGError (Exception ): """Base error exposed by the application layer.""" class ConfigurationError (RAGError ): pass class DocumentProcessingError (RAGError ): pass class EmbeddingError (RAGError ): pass class StorageError (RAGError ): pass class GenerationError (RAGError ): pass
定义自己的异常,CLI 只需要处理 RAGError 即可,无需关注底层库,可以去耦合。在项目中,大量使用了如下写法:
1 2 except Exception as exc: raise EmbeddingError("文档向量化失败" ) from exc
第一句:如果 try 里面发生任何继承自 Exception 的错误,把这个错误对象保存到变量 exc。第二行表示主动抛出自己定义的 EmbeddingError 异常,from exc 会保留原始错误以便排查错误原因。 其中,exc 是原始底层异常,EmbeddingError 是新的应用异常,from exc 将两者连成一条异常链。
Py 基础知识补充 Protocol 的使用 我们之前使用的是 ABC 基类,如下:
1 2 3 4 5 6 7 from abc import ABC, abstractmethodclass BaseEmbedding (ABC ): @abstractmethod def embed (self, chunks ): pass
然后需要在创建新的类时继承基类。但是如果你这样定义:
1 2 3 4 class MyEmbedding : def embed (self, chunks ): print ("我的Embedding" )
那么 MyEmbedding 和 BaseEmbedding 就没有继承关系。
Protocol 的思想不同,它只关心你有没有这个能力,比如:
1 2 3 4 5 6 7 from typing import Protocolclass EmbeddingProtocol (Protocol ): def embed (self, chunks ): ...
现在在定义类,如下:
1 2 3 4 5 6 7 8 9 class TongyiEmbedding : def embed (self, chunks ): print ("通义" ) class LocalEmbedding : def embed (self, chunks ): print ("本地模型" )
都符合 EmbeddingProtocol,因为他们都有 embed()。接下来举一个具体的实例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 class Duck : def speak (self ): print ("嘎嘎" ) class Person : def speak (self ): print ("你好" ) def talk (obj ): obj.speak() talk(Duck()) talk(Person())
Protocol 只关心方法不关心类型。domain/prots.py 就是使用了 Protocol 定义了不同组件的必需方法。
__init__ 的使用 假设目录结构为
1 2 3 4 5 infrastructure/ ├── __init__.py └── embeddings/ ├── __init__.py └── openai_compatible.py
当 Python 看到 __init__.py 时,会把对应目录当作一个可导入的包,当一个包第一次被导入时,Python会执行这个包中的 __init__.py。
1 2 import infrastructureimport infrastructure.embeddings
当导入 infrastructure.embeddings 时,其大致顺序为:
1 2 3 1. 执行 infrastructure/__init__.py 2. 执行 infrastructure/embeddings/__init__.py 3. 得到 infrastructure.embeddings 模块对象
同一个 Python 进程中,模块通常只会在第一次导入时执行,之后会从 sys.modules 缓存中复用。
Python 异常机制基础 Python 中错的错误本质是一个异常对象。例如
1 2 3 4 5 try : a = 1 / 0 except Exception as e: print (e)
这样运行程序,会输出 division by zero,这里的 e 就是异常对象。 try / except 基本结构是 py 中常用的错误检测机制。例如:
1 2 3 4 5 try : x = 1 / 0 except ZeroDivisionError: print ("不能除0" )
执行时先进入 try,发生异常后跳转 except,执行处理代码。