1.2 RAG 经典架构全景图:从数据摄取到响应生成的闭环

39 阅读11分钟

在上一章,我们将 RAG 定义为企业级 AI 的“外挂知识库”。现在,我们要推开这个黑盒的引擎盖,像拆解内燃机一样,从零部件级别审视 RAG 的内部构造。

RAG 并非单一的技术,而是一条精密的数据供应链(Data Supply Chain) 。在这条链路上,非结构化的业务文档(PDF、Word、HTML)需要经历多次形态的“蜕变”,最终才能成为大模型口中的精彩回答。在这个过程中,任何一个环节的工程瑕疵——无论是字符编码的错乱,切分窗口的语义截断,还是向量空间的维度坍缩——都会在最终的生成阶段被放大为严重的幻觉(Hallucination)。

本章将带您全景式俯瞰标准 RAG 的全生命周期,并以此为蓝图,规划全书的技术路径。我们将这一闭环拆解为五个核心阶段,并辅以工业级的代码实现。

一、 架构蓝图:数据流动的五个阶段

标准的 RAG 架构遵循 ETL (Extract, Transform, Load) + Retrieval 的设计范式,但在 AI 语境下,它有着更为特殊的含义。我们可以将其抽象为以下数据流:

RAG 简单流程.png

这个流程看似线性,实则每一个箭头处都隐藏着分布式系统常见的“坑”。让我们逐一拆解这个闭环中的关键节点。

阶段 1:Data Ingestion(数据摄取与标准化)

这是数据进入系统的第一道关卡,也是脏活累活最集中的地方。企业数据通常是“反人类阅读”的——PDF 包含复杂的双栏排版、页眉页脚、水印、跨页表格,甚至是扫描件图片。

在这一阶段,我们的目标是将多源异构的数据(PDF, Docx, Markdown, SQL)统一转化为标准的文档对象(Document Object)

核心对象模型:Document

在 LangChain 或 LlamaIndex 中,Document 不仅仅是一串字符串,它通常包含两个核心属性:

  1. page_content (str): 文本内容本身。
  2. metadata (dict): 数据的血缘信息。

⚠️ 工程警示: 元数据的重要性

很多初学者会忽略 metadata,直接存文本。这是大忌。

在生产环境中,你必须保留 source(来源文件)、page(页码)、author(作者)、created_at(时间)等字段。这不仅是为了引用溯源,更是为了后续实现混合检索(Hybrid Search)(例如:“只搜索 2023 年之后发布的财务报告”)。

挑战: PDF 是一种“视觉描述语言”而非“语义描述语言”。它只知道“在坐标 (100, 200) 画一个字符 'A'”,而不知道这是否是标题。解析器(Parser)必须通过启发式算法重构语义结构。如果解析失败,引入的乱码(Artifacts)会直接污染后续的向量空间。

阶段 2:Transformation(切片与清洗)

由于 Embedding 模型和 LLM 都有上下文窗口(Context Window)的物理限制(例如 text-embedding-3 限制 8191 tokens),我们不能将整本书塞进去。我们必须将长文档切分为较小的文本块(Chunks)。

核心博弈:粒度(Granularity)

切分策略是 RAG 性能的第一个分水岭:

切得太细(Small Grain):

  • 优势:包含的信息非常精确,检索时噪音少。
  • 劣势:缺乏上下文。比如切出一段“它同比增长了 50%”,模型不知道“它”是指营收还是净利润,导致语义歧义。

切得太粗(Large Grain):

  • 优势:保留了完整的叙事逻辑和上下文。
  • 劣势:包含过多无关信息(Noise),稀释了关键语义,且容易撑爆 Prompt 窗口。

常用策略:Sliding Window(滑动窗口)

为了解决语义断裂问题,我们通常采用带重叠的切分(Chunk Overlap)。例如:Chunk Size = 1000, Overlap = 200。这就好比接力赛跑,前后两棒之间有一段重合区,确保逻辑不断档。

1.png

阶段 3:Embedding(向量化表示)

这是 RAG 的核心魔法,也是计算机理解人类语言的物理基础。通过 Embedding 模型,我们将离散的自然语言压缩为连续的稠密向量(Dense Vector)

降维打击与语义压缩

想象一下,我们将每一个文本块映射到一个 1536 维(OpenAI text-embedding-3-small 的维度)的超空间中。

在这个空间里,坐标距离代表了语义相似度。

  • “猫”和“狗”的向量距离很近(都是宠物)。
  • “猫”和“微积分”的向量距离很远。

Embedding(Text)[0.012,0.981,...,0.115]R1536\text{Embedding}(Text) \rightarrow [0.012, -0.981, ..., 0.115] \in \mathbb{R}^{1536}

Technical Sidebar: 为什么是余弦相似度?

在高维空间中,我们通常使用 余弦相似度(Cosine Similarity) 而不是欧氏距离(Euclidean Distance)来衡量相关性。

  • 欧氏距离衡量的是空间中两点的绝对距离,它对向量的模长(Magnitude)敏感。

    d=ABd = \|A - B\|

  • 余弦相似度衡量的是两个向量夹角的余弦值:

    similarity=cos(θ)=ABAB\text{similarity} = \cos(\theta) = \frac{A \cdot B}{\|A\| \|B\|}

直觉理解: 如果两段文本谈论的是同一个主题,它们在向量空间中的“指向”应该是相同的。余弦相似度关注的是方向的一致性,而忽略长度(即文本长短)的影响。这对于检索至关重要。

阶段 4:Indexing & Storage(索引与存储)

向量生成后,需要存入专门的向量数据库(Vector Database),如 ChromaDB, Milvus, Pinecone 或 Elasticsearch。

为什么不能用 MySQL?

传统的数据库使用 B-Tree 索引,擅长精确匹配(WHERE id = 1)。但在高维向量空间中,我们需要寻找“最近的邻居”(Nearest Neighbor)。在数百万数据量下,暴力的全量遍历计算(Brute Force)极其缓慢。

HNSW:RAG 的速度引擎

绝大多数现代向量库默认使用 HNSW (Hierarchical Navigable Small World) 算法。

  • 它构建了一个多层的图结构,类似城市的交通网络。
  • 顶层是“高速公路”,用于快速定位大概区域。
  • 底层是“街道”,用于精确定位目标。
  • 这种结构将检索的时间复杂度从O(N)O(N)降低到了O(logN)O(\log N),实现了毫秒级响应。

阶段 5:Retrieval & Generation(检索与生成)

这是用户可见的“临门一脚”。

  1. Query Embedding: 将用户的问题也转化为向量。
  2. ANN Search: 在数据库中查找与 Question 向量最相似的 Top-K 个 Chunk。
  3. Context Stuffing: 将这 K 个 Chunk 拼接到 Prompt 中。
  4. Generation: LLM 阅读 Context,回答 Question。

现象警示: Lost in the Middle(中间人丢失)

研究表明,当 Context 变得很长(例如 K=20),LLM 倾向于关注开头和结尾的信息,而忽略中间的内容。这就像人读长文章一样。

对策: 不要盲目增加 K 值。应当引入 Rerank(重排序) 机制,将最相关的内容强行置顶。

二、 Python 工程实战:构建生产级 MVP

为了让理论落地,我们构建一个具备类型检查模块化设计错误处理的 RAG 管道。

2.1 环境配置与依赖

我们将使用 LangChain 作为编排框架,但会剥离其冗余部分,保持代码清爽。

# 安装核心依赖
pip install langchain langchain-openai langchain-community pypdf chromadb tiktoken pydantic

2.2 定义核心配置 (Configuration)

在工程实践中,硬编码参数是万恶之源。我们使用 pydantic 来管理配置。

import os
from pydantic import BaseModel, Field

class RAGConfig(BaseModel):
    """RAG 系统的全局配置"""
    chunk_size: int = Field(1000, description="切片大小")
    chunk_overlap: int = Field(200, description="切片重叠大小")
    k_neighbors: int = Field(5, description="检索召回数量")
    # 推荐使用 text-embedding-3-small,性价比极高
    embedding_model: str = "text-embedding-3-small"
    llm_model: str = "gpt-4o"
    collection_name: str = "enterprise_knowledge_base"
    persist_directory: str = "./chroma_db"

# 实例化配置
config = RAGConfig()

# 确保 API KEY 存在
if not os.getenv("OPENAI_API_KEY"):
    raise ValueError("请设置 OPENAI_API_KEY 环境变量")

2.3 模块化实现:Data Ingestion Service

将数据处理逻辑封装为独立的服务类。

from typing import List
from langchain_community.document_loaders import PyPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_core.documents import Document

class IngestionService:
    """负责数据的加载、清洗与切分"""
    
    def __init__(self, cfg: RAGConfig):
        self.cfg = cfg
        # 初始化切分器
        # 策略:优先在段落(\n\n)、句子(\n)处断句,尽可能保持语义完整
        self.splitter = RecursiveCharacterTextSplitter(
            chunk_size=cfg.chunk_size,
            chunk_overlap=cfg.chunk_overlap,
            separators=["\n\n", "\n", "。", "!", ",", " ", ""]
        )

    def process_pdf(self, file_path: str) -> List[Document]:
        """加载 PDF 并切分为 Chunks"""
        print(f"🏗️ [Ingestion] Loading PDF: {file_path}")
        
        try:
            loader = PyPDFLoader(file_path)
            # load_and_split 内部自动调用了加载和切分,但为了演示,我们分步执行
            raw_docs = loader.load()
            print(f"   - Loaded {len(raw_docs)} pages.")
            
            chunks = self.splitter.split_documents(raw_docs)
            print(f"   - Split into {len(chunks)} chunks (Size: {self.cfg.chunk_size}).")
            
            # [Optional] 这里可以添加额外的清洗逻辑,如去除页眉页脚的 Regex
            
            return chunks
        except Exception as e:
            print(f"❌ Error processing PDF: {e}")
            return []

2.4 模块化实现:Vector Service

封装向量数据库的操作,隐藏底层实现细节。

from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma

class VectorService:
    """负责向量化与存储索引"""
    
    def __init__(self, cfg: RAGConfig):
        self.cfg = cfg
        self.embedding_fn = OpenAIEmbeddings(model=cfg.embedding_model)
        # 初始化持久化的 ChromaDB
        self.vector_store = Chroma(
            collection_name=cfg.collection_name,
            embedding_function=self.embedding_fn,
            persist_directory=cfg.persist_directory
        )

    def upsert(self, documents: List[Document]):
        """将文档向量化并存入数据库"""
        if not documents:
            return
        print(f"💾 [VectorStore] Indexing {len(documents)} chunks...")
        # Chroma 会自动处理 Batch 写入
        self.vector_store.add_documents(documents)
        print("   - Indexing complete.")

    def as_retriever(self):
        """暴露检索器接口"""
        return self.vector_store.as_retriever(
            search_type="similarity", # 可选 "mmr" (Maximal Marginal Relevance)
            search_kwargs={"k": self.cfg.k_neighbors}
        )

2.5 核心引擎:RAG Chain

将检索与生成串联起来。这里我们使用了 LCEL(LangChain Expression Language),这是现代 LangChain 的标准写法,具有更好的流式支持和可观测性。

from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough

class RAGEngine:
    def __init__(self, cfg: RAGConfig, retriever):
        self.cfg = cfg
        self.retriever = retriever
        self.llm = ChatOpenAI(model=cfg.llm_model, temperature=0) # Temp=0 严控幻觉
        self.chain = self._build_chain()

    def _format_docs(self, docs):
        """将检索到的文档列表转换为单一字符串"""
        return "\n\n".join([f"---片段来源: {d.metadata.get('page', 'N/A')}页---\n{d.page_content}" for d in docs])

    def _build_chain(self):
        # 严谨的 System Prompt 设计
        # 包含:角色设定、任务边界、引用要求、语气约束
        template = """你是一名严谨的企业级技术顾问。请基于下方的【参考信息】回答用户问题。

        【参考信息】:
        {context}

        【用户问题】:
        {question}

        【回答要求】:
        1. 仅基于提供的参考信息回答,不要使用你原本的训练知识编造。
        2. 如果参考信息中没有答案,请直接回复“当前知识库中未找到相关信息”。
        3. 回答需逻辑清晰,分点表述。
        4. 引用信息时,请在句末标注来源页码。
        """
        
        prompt = ChatPromptTemplate.from_template(template)

        # 构建 LCEL 流水线
        chain = (
            {
                "context": self.retriever | self._format_docs, 
                "question": RunnablePassthrough()
            }
            | prompt
            | self.llm
            | StrOutputParser()
        )
        return chain

    def query(self, question: str):
        print(f"🔎 [RAG] Searching & Generating for: '{question}'")
        return self.chain.invoke(question)

2.6 整合运行

将所有模块组装起来。

# main.py
if __name__ == "__main__":
    # 1. 配置
    config = RAGConfig()
    
    # 2. 初始化服务
    ingestion = IngestionService(config)
    vector_svc = VectorService(config)
    
    # 3. 数据处理 (模拟首次运行)
    # 假设有一个名为 data.pdf 的文件
    if os.path.exists("data.pdf"):
        chunks = ingestion.process_pdf("data.pdf")
        vector_svc.upsert(chunks)
    else:
        print("⚠️ Warning: data.pdf not found. Skipping ingestion.")
    
    # 4. 构建引擎
    rag_engine = RAGEngine(config, vector_svc.as_retriever())
    
    # 5. 提问
    response = rag_engine.query("本年度的研发预算是多少?")
    
    print("\n" + "="*30)
    print("🤖 AI Response:")
    print(response)
    print("="*30)

三、 Naive RAG 的局限与进阶

虽然上述代码构建了一个清晰的闭环,但在架构师眼中,这只是一个 Naive RAG 系统。在真实的生产环境中,它就像一辆没有避震器的车,虽然能跑,但稍微遇到一点坑洼(复杂文档、模糊提问)就会颠得散架。本专栏后续章节将致力于解决 Naive RAG 面临的三大核心挑战:

1. 解析精度的瓶颈

PyPDFLoader 本质上是在处理文本流。如果你的文档中包含:

  • 复杂表格:普通的 Loader 会将表格按行读取,导致列与列之间的逻辑关系错乱。
  • 流程图/图片:其中的信息会直接丢失。
  • 多栏排版:可能导致跨栏读取,语义混乱。

2. 检索语义的偏差

余弦相似度并不完美。

  • 关键词缺失: 用户搜索“CEO 是谁”,文档里写的是“首席执行官是张三”。虽然语义相近,但在某些简单的 Embedding 模型下可能匹配度不够。
  • 反直觉匹配: 搜索“不含糖的饮料”,向量搜索可能会召回大量包含“糖”字的文本,因为它无法理解“不”这个逻辑否定词,只捕捉到了“糖”这个强特征。

3. 上下文窗口的噪音

这是一个反直觉的现象:给模型的信息越多,效果可能越差。

当 K 值设大时,Retriever 可能会召回 8 个相关片段和 12 个不相关的噪音片段。这些噪音会稀释 Prompt 的信噪比,甚至导致模型产生幻觉。

本章小结

1.2 章节为您展示了 RAG 的骨架。我们看到,从 PDF 到 Answer,数据经历了一场惊险的旅程。

  • Loading 决定了数据的下限。
  • Splitting 决定了上下文的连贯性。
  • Embedding 决定了语义理解的深度。
  • Retrieval 决定了答案的准确性。

每一个环节的微小偏差,都会在最终的生成结果中被放大。现在,全景图已经展开,是时候深入细节了。

下一章 [2.1 解析 PDF 的艺术]:当文档极其复杂时,我们如何从乱码中提取出结构化的黄金?