目 录CONTENT

文章目录

RAG 工程化:不是接个向量库就完事了

PySuper
2025-11-08 / 0 评论 / 0 点赞 / 0 阅读 / 0 字
温馨提示:
本文最后更新于2026-05-20,若内容或图片失效,请留言反馈。 所有牛逼的人都有一段苦逼的岁月。 但是你只要像SB一样去坚持,终将牛逼!!! ✊✊✊

在过去 18 个月里,RAG 已经悄无声息地完成了一次本质跃迁:从最初"搜索 + LLM"的简单拼接模式,进化成了一套完整、分层、体系化的工程化学科。本文将带你从 0 到 1 建立对 RAG 工程化体系的完整认知。

一、RAG 到底是什么?它解决了什么核心问题?

RAG(Retrieval-Augmented Generation,检索增强生成) 是一种将大语言模型与外部知识检索系统相结合的技术架构。它的核心思想是:在生成回答之前,先从外部知识库中检索相关信息,然后将这些信息作为上下文提供给大模型,从而生成更准确、更可靠的回答。

1.1 RAG 解决的核心痛点

大语言模型虽然能力强大,但存在三个致命的缺陷:

痛点

说明

RAG 的解决方案

幻觉问题

模型可能生成看似合理但实际错误的内容

强制模型基于检索到的真实上下文生成回答

知识陈旧

模型知识有截止日期,无法获取最新信息

实时更新向量数据库,保持知识新鲜度

私有知识无法使用

企业内部文档、业务数据无法被模型利用

将私有知识向量化,提供给模型检索

1.2 为什么"接个向量库"远远不够?

很多开发者对 RAG 的认知还停留在"写个检索逻辑,调用一下大模型 API"的阶段。但实际上,生产级 RAG 系统需要解决以下问题:

数据提取层面

  • 复杂文档格式(PDF、Word、扫描件)的精准解析

  • 表格、公式、图片内嵌内容的提取

  • 多语言混合文档的处理

文本分块层面

  • 如何划分块的大小?太大导致上下文稀释,太小丢失语义

  • 段落边界、语义完整性如何保持

  • 递归分块 vs 语义分块的选择

嵌入模型层面

  • 中文场景需要专门的中文嵌入模型

  • 不同嵌入模型对检索准确率的影响巨大

  • 长文档需要支持更长上下文的嵌入模型

检索策略层面

  • 纯向量检索的局限性

  • 混合检索(关键词 + 向量)的重要性

  • 查询改写、意图扩展的必要性

  • 重排序(Rerank)的价值

上下文管理层面

  • Token 窗口限制

  • 超长上下文的摘要策略

  • 对话历史的处理

效果评估层面

  • 如何量化 RAG 系统的好坏

  • 持续优化的闭环机制

二、RAG 七层架构详解

┌─────────────────────────────────────────────────────────────────────────────┐
│                          RAG 系统七层架构                                    │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │                     第七层:效果评估层 (Evaluation)                   │   │
│  │         Ragas / TruLens / Giskard → 三级评估体系                    │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
│                                    ↓                                        │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │                     第六层:编排框架层 (Orchestration)               │   │
│  │           LangChain / LlamaIndex / Haystack → 流程串联               │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
│                                    ↓                                        │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │                     第五层:大语言模型层 (LLMs)                      │   │
│  │            GPT-4 / Claude / DeepSeek / Qwen → 答案生成               │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
│                                    ↓                                        │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │                     第四层:开源模型接入层 (Open LLMs)               │   │
│  │              Ollama / HuggingFace / Groq → 模型灵活性               │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
│                                    ↓                                        │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │                     第三层:向量数据库层 (Vector Store)              │   │
│  │         Milvus / Qdrant / Weaviate / ChromaDB / Pinecone            │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
│                                    ↓                                        │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │                     第二层:文本嵌入层 (Embeddings)                   │   │
│  │         BGE / Voyage AI / OpenAI Embeddings → 语义向量化             │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
│                                    ↓                                        │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │                     第一层:数据提取层 (Data Extraction)             │   │
│  │           FireCrawl / LlamaParse / Docling → 内容解析                │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

2.1 第一层:数据提取层

数据质量决定了 RAG 效果的天花板。即使后续环节再完美,如果源数据就是错误或混乱的,结果也不可能正确。

核心工具选型

工具

适用场景

特点

FireCrawl

企业级网页抓取

支持整站爬取、动态内容渲染、结构化输出

Crawl4AI

原型开发

开源轻量、支持 JS 渲染、代理池

LlamaParse

复杂文档解析

表格、公式、多模态内容精准提取

Docling

企业级文档处理

PDF/Office 深度优化、标准化 JSON 输出

2.2 第二层:文本嵌入层

嵌入模型的质量直接决定了检索环节的准确率。

中文场景推荐

# 嵌入模型选型配置
embedding_config:
  # 中文首选:BGE 系列
  bge_large_zh:
    model_name: "BAAI/bge-large-zh-v1.5"
    dimension: 1024
    max_seq_length: 512
    use_case: "通用中文场景"
  
  bge_m3:
    model_name: "BAAI/bge-m3"
    dimension: 1024
    max_seq_length: 8192
    use_case: "长文档场景"
  
  # 备选方案
  voyage_multilingual:
    provider: "Voyage AI"
    model: "voyage-multilingual-2"
    use_case: "多语言场景"

2.3 第三层:向量数据库层

向量数据库是 RAG 系统的记忆中枢,负责存储和检索向量化的知识。

数据库

适用场景

优点

缺点

ChromaDB

原型开发

零配置、开箱即用

不适合生产环境

Milvus

企业级生产

分布式支持、亿级向量

运维复杂

Qdrant

企业级生产

高性能、丰富过滤

生态相对年轻

Weaviate

多模态场景

原生多模态支持

资源消耗较高

Pinecone

云端 SaaS

零运维、弹性扩展

数据需上云

FAISS

轻量本地

高性能、Facebook 出品

无原生云支持

2.4 第四-六层:模型与编排层

这三层决定了 RAG 系统的智能程度和灵活性。推荐组合:

# 生产级 RAG 配置示例
rag_config:
  # 编排框架
  orchestration:
    framework: "langchain"  # 或 llama_index / haystack
  
  # 大语言模型
  llm:
    primary: "gpt-4o-mini"    # 通用场景
    fallback: "deepseek-v3"   # 降级选项
    long_context: "claude-3-5-sonnet"  # 长文档场景
  
  # 检索配置
  retrieval:
    top_k: 5
    hybrid_search: true      # 混合检索
    rerank: true             # 重排序
    query_expansion: true    # 查询扩展

三、文档解析与分块策略

3.1 递归字符分块

递归分块是当前最常用的分块策略,通过递归地按特定分隔符划分文本,直到块大小满足要求。

from typing import List, Optional
from langchain.text_splitter import RecursiveCharacterTextSplitter

class DocumentChunker:
    """
    文档分块器:支持递归分块和语义分块两种策略
    """
    
    def __init__(
        self,
        chunk_size: int = 1000,
        chunk_overlap: int = 200,
        separators: Optional[List[str]] = None
    ):
        """
        初始化分块器
        
        Args:
            chunk_size: 每个块的 token 数量(近似)
            chunk_overlap: 块之间的重叠 token 数,保证语义连续性
            separators: 分隔符列表,按优先级排序
        """
        if separators is None:
            # 默认分隔符:先按段落,再按句子,最后按单词
            separators = ["\n\n", "\n", ". ", " ", ""]
        
        self.splitter = RecursiveCharacterTextSplitter(
            separators=separators,
            chunk_size=chunk_size,
            chunk_overlap=chunk_overlap,
            length_function=self._count_tokens,  # 自定义 token 计数
            is_separator_regex=False,
        )
    
    def _count_tokens(self, text: str) -> int:
        """估算 token 数量(中文约 2 字符 = 1 token)"""
        # 简单估算:中文 * 1.5 + 英文 / 4
        chinese_chars = sum(1 for c in text if '\u4e00' <= c <= '\u9fff')
        other_chars = len(text) - chinese_chars
        return int(chinese_chars * 1.5 + other_chars / 4)
    
    def chunk_text(self, text: str, metadata: Optional[dict] = None) -> List[dict]:
        """
        将文本分割成块
        
        Args:
            text: 输入文本
            metadata: 元数据(如文档标题、来源等)
        
        Returns:
            块列表,每个块包含内容和元数据
        """
        # 使用 langchain 的分块器
        from langchain.schema import Document
        
        langchain_docs = [Document(page_content=text, metadata=metadata or {})]
        chunks = self.splitter.split_documents(langchain_docs)
        
        # 转换为标准格式
        return [
            {
                "content": chunk.page_content,
                "metadata": {
                    **chunk.metadata,
                    "chunk_id": idx,
                    "total_chunks": len(chunks)
                }
            }
            for idx, chunk in enumerate(chunks)
        ]
    
    def chunk_documents(
        self, 
        documents: List[dict]
    ) -> List[dict]:
        """
        批量分块处理多个文档
        
        Args:
            documents: 文档列表,每个文档包含 content 和可选的 metadata
        
        Returns:
            所有文档的分块列表
        """
        all_chunks = []
        
        for doc in documents:
            chunks = self.chunk_text(
                text=doc.get("content", ""),
                metadata=doc.get("metadata", {})
            )
            all_chunks.extend(chunks)
        
        return all_chunks

3.2 语义分块

语义分块根据文本的语义相似性自动划分边界,比固定长度分块效果更好。

from typing import List, Optional
import numpy as np

class SemanticChunker:
    """
    语义分块器:基于语义相似性自动划分文档边界
    
    核心思想:
    1. 将文本切分成较小的句子单元
    2. 计算相邻句子之间的语义相似度
    3. 相似度骤降处即为语义边界
    4. 将连续的句子聚合为块
    """
    
    def __init__(
        self,
        embed_model,  # 嵌入模型实例
        min_chunk_size: int = 3,      # 最少句子数
        max_chunk_size: int = 10,    # 最多句子数
        similarity_threshold: float = 0.5,  # 相似度阈值
        buffer_size: int = 1          # 边界处保留的相邻句子数
    ):
        self.embed_model = embed_model
        self.min_chunk_size = min_chunk_size
        self.max_chunk_size = max_chunk_size
        self.similarity_threshold = similarity_threshold
        self.buffer_size = buffer_size
    
    def _split_into_sentences(self, text: str) -> List[str]:
        """简单句子分割(实际项目可使用更复杂的 NLP 工具)"""
        import re
        # 按常见句末标点分割
        sentences = re.split(r'[。!?\n]+', text)
        return [s.strip() for s in sentences if s.strip()]
    
    def _compute_similarity(self, text1: str, text2: str) -> float:
        """计算两个文本的语义相似度"""
        emb1 = self.embed_model.embed_query(text1)
        emb2 = self.embed_model.embed_query(text2)
        
        # 余弦相似度
        dot_product = np.dot(emb1, emb2)
        norm1 = np.linalg.norm(emb1)
        norm2 = np.linalg.norm(emb2)
        
        return dot_product / (norm1 * norm2 + 1e-8)
    
    def _find_breaking_points(
        self, 
        sentences: List[str]
    ) -> List[int]:
        """
        找到语义边界点
        
        策略:
        1. 计算相邻句子的相似度
        2. 相似度低于阈值视为语义断裂
        3. 强制在 max_chunk_size 处截断
        """
        if len(sentences) <= 1:
            return []
        
        breaking_points = []
        
        for i in range(len(sentences) - 1):
            similarity = self._compute_similarity(
                sentences[i], 
                sentences[i + 1]
            )
            
            # 语义断裂 或 达到最大块大小
            if similarity < self.similarity_threshold:
                breaking_points.append(i + 1)
            elif (i + 1) % self.max_chunk_size == 0 and i + 1 < len(sentences) - 1:
                breaking_points.append(i + 1)
        
        return sorted(set(breaking_points))
    
    def chunk_text(self, text: str, metadata: Optional[dict] = None) -> List[dict]:
        """执行语义分块"""
        sentences = self._split_into_sentences(text)
        
        if len(sentences) < self.min_chunk_size:
            # 文本太短,直接返回整个文本
            return [{
                "content": text,
                "metadata": {
                    **(metadata or {}),
                    "chunk_id": 0,
                    "total_chunks": 1,
                    "chunking_method": "semantic"
                }
            }]
        
        breaking_points = self._find_breaking_points(sentences)
        breaking_points = [0] + breaking_points + [len(sentences)]
        
        chunks = []
        for i in range(len(breaking_points) - 1):
            start, end = breaking_points[i], breaking_points[i + 1]
            chunk_sentences = sentences[start:end]
            
            chunks.append({
                "content": "".join(chunk_sentences),
                "metadata": {
                    **(metadata or {}),
                    "chunk_id": i,
                    "total_chunks": len(breaking_points) - 1,
                    "chunking_method": "semantic",
                    "num_sentences": len(chunk_sentences)
                }
            })
        
        return chunks

四、检索策略:混合检索与重排序

4.1 为什么需要混合检索?

纯向量检索虽然能捕获语义相似性,但存在以下问题:

  • 对专有名词、型号、代码等精确匹配能力弱

  • 无法处理拼写变体

  • 长尾查询效果不稳定

关键词检索(BM25/TF-IDF)正好弥补这些缺陷。

from typing import List, Dict, Any, Optional
import numpy as np

class HybridRetriever:
    """
    混合检索器:结合向量检索和关键词检索的优势
    
    检索流程:
    1. 同时执行向量检索和关键词检索
    2. 对结果进行标准化分数融合
    3. 可选:使用重排序模型进一步优化
    """
    
    def __init__(
        self,
        vector_store,      # 向量数据库
        bm25_index,        # BM25 索引
        embed_model,       # 嵌入模型
        reranker: Optional[Any] = None,  # 重排序模型
        vector_weight: float = 0.7,      # 向量检索权重
        keyword_weight: float = 0.3,     # 关键词检索权重
        top_k: int = 20,                 # 初步召回数量
        final_k: int = 5                 # 最终返回数量
    ):
        self.vector_store = vector_store
        self.bm25_index = bm25_index
        self.embed_model = embed_model
        self.reranker = reranker
        self.vector_weight = vector_weight
        self.keyword_weight = keyword_weight
        self.top_k = top_k
        self.final_k = final_k
    
    def _vector_search(self, query: str) -> List[Dict[str, Any]]:
        """向量语义检索"""
        query_embedding = self.embed_model.embed_query(query)
        results = self.vector_store.similarity_search_by_vector(
            embedding=query_embedding,
            k=self.top_k
        )
        return [
            {
                "id": r["id"],
                "content": r["content"],
                "score": r.get("score", 0.0),
                "source": "vector"
            }
            for r in results
        ]
    
    def _keyword_search(self, query: str) -> List[Dict[str, Any]]:
        """关键词 BM25 检索"""
        results = self.bm25_index.search(query, k=self.top_k)
        return [
            {
                "id": r["id"],
                "content": r["content"],
                "score": r.get("score", 0.0),
                "source": "keyword"
            }
            for r in results
        ]
    
    def _normalize_scores(self, results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
        """Min-Max 分数标准化"""
        if not results:
            return results
        
        scores = [r["score"] for r in results]
        min_score, max_score = min(scores), max(scores)
        
        if max_score == min_score:
            # 所有分数相同,设为 1.0
            return [{**r, "normalized_score": 1.0} for r in results]
        
        return [
            {**r, "normalized_score": (r["score"] - min_score) / (max_score - min_score)}
            for r in results
        ]
    
    def _fusion_scores(
        self,
        vector_results: List[Dict[str, Any]],
        keyword_results: List[Dict[str, Any]]
    ) -> List[Dict[str, Any]]:
        """RRF(Reciprocal Rank Fusion)分数融合"""
        # 构建 id -> 结果 的映射
        all_results = {}
        
        for rank, result in enumerate(vector_results):
            result["fusion_score"] = (
                self.vector_weight * result["normalized_score"] +
                (1 / (rank + 1)) * 0.1  # RRF 调整
            )
            all_results[result["id"]] = result
        
        for rank, result in enumerate(keyword_results):
            result["fusion_score"] = (
                self.keyword_weight * result["normalized_score"] +
                (1 / (rank + 1)) * 0.1  # RRF 调整
            )
            if result["id"] in all_results:
                all_results[result["id"]]["fusion_score"] += result["fusion_score"]
                all_results[result["id"]]["source"] = "hybrid"
            else:
                all_results[result["id"]] = result
        
        # 按融合分数排序
        sorted_results = sorted(
            all_results.values(),
            key=lambda x: x["fusion_score"],
            reverse=True
        )
        
        return sorted_results[:self.top_k]
    
    def _rerank(self, query: str, results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
        """使用重排序模型优化结果顺序"""
        if not self.reranker:
            return results[:self.final_k]
        
        # 准备重排序输入
        doc_texts = [r["content"] for r in results]
        reranked = self.reranker.rank(query=query, documents=doc_texts)
        
        # 更新分数
        for i, idx in enumerate(reranked["indices"]):
            results[idx]["rerank_score"] = reranked["scores"][i]
        
        # 按重排序分数排序
        return sorted(
            results,
            key=lambda x: x.get("rerank_score", x["fusion_score"]),
            reverse=True
        )[:self.final_k]
    
    def retrieve(self, query: str) -> List[Dict[str, Any]]:
        """
        执行混合检索
        
        Args:
            query: 用户查询
        
        Returns:
            检索结果列表
        """
        # 1. 并行执行向量检索和关键词检索
        vector_results = self._vector_search(query)
        keyword_results = self._keyword_search(query)
        
        # 2. 标准化分数
        vector_results = self._normalize_scores(vector_results)
        keyword_results = self._normalize_scores(keyword_results)
        
        # 3. 融合分数
        fused_results = self._fusion_scores(vector_results, keyword_results)
        
        # 4. 重排序(如配置)
        if self.reranker:
            final_results = self._rerank(query, fused_results)
        else:
            final_results = fused_results[:self.final_k]
        
        return final_results

4.2 查询改写与扩展

用户的自然语言查询往往不够精确,需要通过查询改写提升检索效果。

class QueryExpander:
    """
    查询扩展器:通过大模型生成多个相关查询,提升召回率
    
    策略:
    1. 同义词扩展
    2. 概念分解
    3. 假设性问题生成
    """
    
    def __init__(self, llm, max_queries: int = 3):
        self.llm = llm
        self.max_queries = max_queries
    
    def expand(self, query: str) -> List[str]:
        """生成扩展查询列表"""
        
        prompt = f"""给定用户查询,生成 {self.max_queries} 个不同的搜索查询,
        帮助从不同角度检索相关信息。

        用户查询:{query}

        要求:
        1. 生成多样化但相关的查询
        2. 包含同义词和专业术语
        3. 可以包含更具体或更宽泛的变体
        4. 每个查询一行,直接输出,不要编号

        扩展查询:"""
        
        response = self.llm.invoke(prompt)
        
        # 解析响应
        queries = [q.strip() for q in response.split('\n') if q.strip()]
        return [query] + queries[:self.max_queries]  # 保留原始查询

五、上下文窗口管理与三级应答机制

5.1 Token 限制与摘要策略

当检索内容超过模型上下文窗口时,需要采用摘要策略。

from typing import List, Dict, Any, Optional
import tiktoken

class ContextWindowManager:
    """
    上下文窗口管理器:处理超长上下文场景
    
    策略:
    1. Token 计数与截断
    2. 基于相关性的选择
    3. 摘要压缩
    """
    
    def __init__(
        self,
        model_name: str = "gpt-4o",
        max_tokens: int = 128000,  # GPT-4o 支持 128K
        reserved_tokens: int = 4000  # 保留给生成内容的空间
    ):
        self.model_name = model_name
        self.max_tokens = max_tokens
        self.reserved_tokens = reserved_tokens
        self.available_tokens = max_tokens - reserved_tokens
        
        # 初始化 tokenizer
        self.encoding = tiktoken.encoding_for_model(model_name)
    
    def count_tokens(self, text: str) -> int:
        """计算文本的 token 数量"""
        return len(self.encoding.encode(text))
    
    def truncate_to_limit(
        self,
        texts: List[str],
        max_tokens: Optional[int] = None
    ) -> List[str]:
        """
        智能截断:在 token 限制内尽可能保留内容
        
        策略:
        1. 按顺序添加文本
        2. 如果添加后超限,截断当前文本或跳过
        3. 优先保留靠前的相关内容
        """
        limit = max_tokens or self.available_tokens
        result = []
        current_tokens = 0
        
        for text in texts:
            text_tokens = self.count_tokens(text)
            
            if current_tokens + text_tokens <= limit:
                result.append(text)
                current_tokens += text_tokens
            else:
                # 尝试截断当前文本
                remaining_tokens = limit - current_tokens
                if remaining_tokens > 100:  # 至少保留 100 tokens
                    truncated = self._truncate_text(text, remaining_tokens)
                    result.append(truncated)
                # 超出限制,停止添加
                break
        
        return result
    
    def _truncate_text(self, text: str, max_tokens: int) -> str:
        """截断文本到指定 token 数"""
        tokens = self.encoding.encode(text)
        truncated_tokens = tokens[:max_tokens]
        return self.encoding.decode(truncated_tokens)
    
    def summarize_long_content(
        self,
        content: str,
        llm,
        target_tokens: int = 2000
    ) -> str:
        """
        对超长内容进行摘要
        
        使用"map-reduce"风格摘要:
        1. 将内容分成小块
        2. 每块生成摘要
        3. 合并摘要再次摘要
        """
        chunk_size = 5000  # 每次处理 5000 字符
        chunks = [
            content[i:i+chunk_size] 
            for i in range(0, len(content), chunk_size)
        ]
        
        if len(chunks) == 1:
            # 内容不长,直接摘要
            return self._summarize_single(content, llm, target_tokens)
        
        # Map: 每块摘要
        chunk_summaries = []
        for chunk in chunks:
            summary = self._summarize_single(
                chunk,
                llm,
                target_tokens // len(chunks)
            )
            chunk_summaries.append(summary)
        
        # Reduce: 合并摘要
        combined = "\n\n".join(chunk_summaries)
        return self._summarize_single(combined, llm, target_tokens)
    
    def _summarize_single(
        self,
        content: str,
        llm,
        target_tokens: int
    ) -> str:
        """单次摘要"""
        prompt = f"""请用简洁的语言总结以下内容,保留关键信息:

{content}

摘要(不超过 {target_tokens} tokens):"""
        
        response = llm.invoke(prompt)
        return response.content

5.2 三级应答机制

根据检索置信度决定回答策略,实现精准路由。

from enum import Enum
from dataclasses import dataclass
from typing import Optional, List, Dict, Any

class ConfidenceLevel(Enum):
    """置信度级别"""
    HIGH = "high"      # 检索到高相关上下文
    MEDIUM = "medium"  # 检索到中等相关上下文
    LOW = "low"        # 检索结果不理想
    NONE = "none"      # 未检索到相关内容

@dataclass
class ResponseContext:
    """应答上下文"""
    confidence: ConfidenceLevel
    retrieved_docs: List[Dict[str, Any]]
    context_tokens: int
    needs_generation: bool
    fallback_strategy: Optional[str] = None

class ThreeTierResponseRouter:
    """
    三级应答路由器:根据置信度选择最佳应答策略
    
    ┌──────────────────────────────────────────────────────────────┐
    │                      用户查询                                │
    └──────────────────────────────────────────────────────────────┘
                                ↓
    ┌──────────────────────────────────────────────────────────────┐
    │                    置信度评估                                │
    │  - 检索文档数                                                │
    │  - 平均相似度分数                                            │
    │  - 答案完整性                                                │
    └──────────────────────────────────────────────────────────────┘
                                ↓
           ┌─────────────────────┼─────────────────────┐
           ↓                     ↓                     ↓
    ┌──────────────┐     ┌──────────────┐     ┌──────────────┐
    │  HIGH        │     │  MEDIUM      │     │  LOW/NONE    │
    │  直接生成    │     │  增强生成    │     │  降级处理    │
    └──────────────┘     └──────────────┘     └──────────────┘
           ↓                     ↓                     ↓
    ┌──────────────┐     ┌──────────────┐     ┌──────────────┐
    │ RAG + 严格   │     │ RAG + 宽松   │     │ 拒答/转人工  │
    │ 引用控制    │     │ 引用控制    │     │ /知识库推荐  │
    └──────────────┘     └──────────────┘     └──────────────┘
    """
    
    def __init__(
        self,
        retriever,      # 检索器
        llm,            # 大语言模型
        context_manager: ContextWindowManager,  # 上下文管理器
        
        # 置信度阈值
        high_threshold: float = 0.85,
        medium_threshold: float = 0.60,
        
        # 其他配置
        min_docs_required: int = 2,
        max_context_docs: int = 5
    ):
        self.retriever = retriever
        self.llm = llm
        self.context_manager = context_manager
        self.high_threshold = high_threshold
        self.medium_threshold = medium_threshold
        self.min_docs_required = min_docs_required
        self.max_context_docs = max_context_docs
    
    def _evaluate_confidence(
        self,
        query: str,
        retrieved_docs: List[Dict[str, Any]]
    ) -> ResponseContext:
        """
        评估检索置信度
        
        综合考虑:
        1. 检索到的文档数量
        2. 文档的平均相似度分数
        3. 文档内容与查询的相关性
        """
        if not retrieved_docs:
            return ResponseContext(
                confidence=ConfidenceLevel.NONE,
                retrieved_docs=[],
                context_tokens=0,
                needs_generation=False,
                fallback_strategy="no_context"
            )
        
        # 计算平均相似度
        scores = [doc.get("score", 0) for doc in retrieved_docs]
        avg_score = sum(scores) / len(scores)
        max_score = max(scores)
        
        # 综合评估
        # 考虑:文档数量、平均分数、最高分数
        confidence_score = (avg_score * 0.4 + max_score * 0.6)
        
        if confidence_score >= self.high_threshold and len(retrieved_docs) >= self.min_docs_required:
            confidence = ConfidenceLevel.HIGH
        elif confidence_score >= self.medium_threshold:
            confidence = ConfidenceLevel.MEDIUM
        else:
            confidence = ConfidenceLevel.LOW
        
        # 计算上下文 token 数
        context_texts = [doc["content"] for doc in retrieved_docs]
        context_tokens = self.context_manager.count_tokens(
            " ".join(context_texts)
        )
        
        return ResponseContext(
            confidence=confidence,
            retrieved_docs=retrieved_docs,
            context_tokens=context_tokens,
            needs_generation=True,
            fallback_strategy=None
        )
    
    def _build_prompt(
        self,
        query: str,
        context_docs: List[Dict[str, Any]],
        confidence: ConfidenceLevel
    ) -> str:
        """
        构建不同置信度级别的 prompt
        
        策略:
        - HIGH: 严格基于上下文,使用引用
        - MEDIUM: 基于上下文但允许一定灵活性
        - LOW: 提示不确定性,建议其他渠道
        """
        context_text = "\n\n".join([
            f"[文档 {i+1}]\n{doc['content']}"
            for i, doc in enumerate(context_docs)
        ])
        
        if confidence == ConfidenceLevel.HIGH:
            return f"""你是一个专业的问答助手。请严格基于以下上下文回答用户问题。

上下文:
{context_text}

用户问题:{query}

要求:
1. 只使用上下文中的信息回答
2. 对于每个关键信息,使用 [文档X] 格式标注来源
3. 如果上下文中没有相关信息,请明确说明"根据现有资料,我无法回答这个问题"

回答:"""
        
        elif confidence == ConfidenceLevel.MEDIUM:
            return f"""你是一个专业的问答助手。请主要基于以下上下文回答用户问题,允许适当补充相关背景知识。

上下文:
{context_text}

用户问题:{query}

要求:
1. 优先使用上下文中的信息
2. 可以适当补充上下文未涵盖但明显相关的知识
3. 对于上下文未明确的信息,请标注"根据补充知识..."
4. 如果上下文完全不相关,请明确说明

回答:"""
        
        else:  # LOW or NONE
            return f"""你是一个专业的问答助手。

用户问题:{query}

注意:当前检索到的资料与问题的相关性不高。请:
1. 坦诚告知用户当前资料有限
2. 提供你已有的通用知识作为参考
3. 建议用户通过哪些渠道获取更准确的信息
4. 避免编造具体的数据或细节

请基于你的通用知识给出一个参考性回答:"""
    
    def _generate_response(
        self,
        query: str,
        context: ResponseContext
    ) -> Dict[str, Any]:
        """生成最终响应"""
        
        if context.confidence == ConfidenceLevel.NONE:
            return {
                "answer": "抱歉,我目前没有找到与您问题相关的信息。建议您:\n1. 尝试重新表述问题\n2. 咨询相关领域的专业人员\n3. 查看官方文档获取更多帮助",
                "confidence": "none",
                "source_documents": [],
                "needs_human_review": True,
                "suggestions": [
                    "转人工客服",
                    "查看知识库",
                    "提交工单"
                ]
            }
        
        # 限制上下文文档数量
        docs_to_use = context.retrieved_docs[:self.max_context_docs]
        
        # 构建 prompt
        prompt = self._build_prompt(query, docs_to_use, context.confidence)
        
        # 生成回答
        response = self.llm.invoke(prompt)
        
        return {
            "answer": response.content,
            "confidence": context.confidence.value,
            "source_documents": [
                {
                    "content": doc["content"][:200] + "...",  # 截断显示
                    "score": doc.get("score", 0),
                    "metadata": doc.get("metadata", {})
                }
                for doc in docs_to_use
            ],
            "needs_human_review": context.confidence == ConfidenceLevel.LOW,
            "context_used_tokens": context.context_tokens
        }
    
    def answer(self, query: str) -> Dict[str, Any]:
        """
        主入口:回答用户问题
        
        流程:
        1. 检索相关文档
        2. 评估置信度
        3. 根据置信度选择策略生成回答
        """
        # 1. 检索
        retrieved_docs = self.retriever.retrieve(query)
        
        # 2. 评估置信度
        context = self._evaluate_confidence(query, retrieved_docs)
        
        # 3. 生成回答
        return self._generate_response(query, context)

六、完整 RAG Pipeline 代码

"""
完整的 RAG Pipeline 实现
集成:数据加载 → 分块 → 嵌入 → 存储 → 检索 → 重排 → 生成
"""

from typing import List, Dict, Any, Optional
from dataclasses import dataclass
from pathlib import Path
import yaml
from langchain_community.document_loaders import PyPDFLoader, TextLoader
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_chroma import Chroma
from langchain_community.retrievers import BM25Retriever
from rank_bm25 import BM25Okapi

@dataclass
class RAGConfig:
    """RAG 系统配置"""
    # 嵌入模型
    embedding_model: str = "bge-large-zh-v1.5"
    embedding_dimension: int = 1024
    
    # 大语言模型
    llm_model: str = "gpt-4o-mini"
    llm_temperature: float = 0.0
    
    # 分块配置
    chunk_size: int = 1000
    chunk_overlap: int = 200
    
    # 检索配置
    retrieval_top_k: int = 20
    final_top_k: int = 5
    hybrid_alpha: float = 0.7  # 向量检索权重
    
    # 置信度阈值
    high_confidence_threshold: float = 0.85
    medium_confidence_threshold: float = 0.60


class CompleteRAGPipeline:
    """
    完整的 RAG 处理流水线
    
    ┌─────────────┐    ┌─────────────┐    ┌─────────────┐
    │  文档加载    │ -> │   文档分块   │ -> │  嵌入向量化  │
    └─────────────┘    └─────────────┘    └─────────────┘
                                                   │
                                                   ↓
┌─────────────┐    ┌─────────────┐    ┌─────────────┐    ┌─────────────┐
│  生成回答    │ <- │  置信度路由  │ <- │  混合检索    │ <- │  向量存储    │
└─────────────┘    └─────────────┘    └─────────────┘    └─────────────┘
    """
    
    def __init__(self, config: RAGConfig):
        self.config = config
        
        # 初始化组件
        self._init_embedding()
        self._init_llm()
        self._init_chunkers()
        
        # 存储组件
        self.vector_store: Optional[Chroma] = None
        self.bm25_retriever: Optional[BM25Retriever] = None
        self.docs: List[Any] = []
    
    def _init_embedding(self):
        """初始化嵌入模型"""
        self.embeddings = OpenAIEmbeddings(
            model="text-embedding-3-large",
            dimensions=self.config.embedding_dimension
        )
    
    def _init_llm(self):
        """初始化大语言模型"""
        self.llm = ChatOpenAI(
            model=self.config.llm_model,
            temperature=self.config.llm_temperature
        )
    
    def _init_chunkers(self):
        """初始化分块器"""
        self.chunker = DocumentChunker(
            chunk_size=self.config.chunk_size,
            chunk_overlap=self.config.chunk_overlap
        )
    
    def load_documents(self, paths: List[str]) -> List[Dict[str, Any]]:
        """
        加载文档
        
        支持格式:PDF, TXT, MD
        """
        from langchain.schema import Document
        
        all_docs = []
        
        for path in paths:
            p = Path(path)
            
            if p.suffix == ".pdf":
                loader = PyPDFLoader(str(p))
            else:
                loader = TextLoader(str(p))
            
            pages = loader.load()
            
            for page in pages:
                all_docs.append({
                    "content": page.page_content,
                    "metadata": {
                        **page.metadata,
                        "source": str(p)
                    }
                })
        
        self.docs = all_docs
        return all_docs
    
    def process_documents(self) -> List[Dict[str, Any]]:
        """
        处理文档:分块 + 嵌入
        
        Returns:
            处理后的块列表
        """
        if not self.docs:
            raise ValueError("请先调用 load_documents 加载文档")
        
        # 分块
        chunks = self.chunker.chunk_documents(self.docs)
        
        # 准备向量数据库数据
        texts = [chunk["content"] for chunk in chunks]
        metadatas = [chunk["metadata"] for chunk in chunks]
        
        # 创建向量存储
        self.vector_store = Chroma.from_texts(
            texts=texts,
            embedding=self.embeddings,
            metadatas=metadatas,
            persist_directory="./chroma_db"
        )
        
        # 创建 BM25 索引
        tokenized_corpus = [text.split() for text in texts]
        self.bm25 = BM25Okapi(tokenized_corpus)
        self.bm25_corpus = texts
        
        return chunks
    
    def query(self, question: str) -> Dict[str, Any]:
        """
        查询入口
        
        Args:
            question: 用户问题
        
        Returns:
            包含答案和来源的响应
        """
        if not self.vector_store:
            raise ValueError("请先调用 process_documents 处理文档")
        
        # 创建混合检索器
        retriever = HybridRetriever(
            vector_store=self.vector_store,
            bm25_index=self,
            embed_model=self.embeddings,
            top_k=self.config.retrieval_top_k,
            final_k=self.config.final_top_k,
            vector_weight=self.config.hybrid_alpha,
            keyword_weight=1 - self.config.hybrid_alpha
        )
        
        # 创建上下文管理器
        context_manager = ContextWindowManager(
            model_name=self.config.llm_model
        )
        
        # 创建路由器
        router = ThreeTierResponseRouter(
            retriever=retriever,
            llm=self.llm,
            context_manager=context_manager,
            high_threshold=self.config.high_confidence_threshold,
            medium_threshold=self.config.medium_confidence_threshold
        )
        
        # 获取回答
        return router.answer(question)
    
    # BM25 接口适配
    def search(self, query: str, k: int = 10) -> List[Dict[str, Any]]:
        """BM25 搜索接口"""
        tokenized_query = query.split()
        scores = self.bm25.get_scores(tokenized_query)
        
        top_indices = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:k]
        
        return [
            {
                "id": idx,
                "content": self.bm25_corpus[idx],
                "score": scores[idx]
            }
            for idx in top_indices
        ]


# 使用示例
if __name__ == "__main__":
    # 加载配置
    config = RAGConfig()
    
    # 初始化流水线
    rag = CompleteRAGPipeline(config)
    
    # 加载并处理文档
    rag.load_documents(["./docs/faq.pdf"])
    chunks = rag.process_documents()
    print(f"处理完成,共 {len(chunks)} 个块")
    
    # 查询
    result = rag.query("产品的退换货政策是什么?")
    print(f"置信度: {result['confidence']}")
    print(f"回答: {result['answer']}")

七、评估体系:Ragas 指标

# Ragas 评估指标配置
ragas_config:
  metrics:
    # 忠实度(Faithfulness):生成答案与上下文的吻合程度
    faithfulness:
      enabled: true
      threshold: 0.8
      
    # 答案相关性(Answer Relevancy):答案与问题的相关程度
    answer_relevancy:
      enabled: true
      threshold: 0.75
      
    # 上下文召回率(Context Recall):检索到的上下文覆盖正确答案的比例
    context_recall:
      enabled: true
      threshold: 0.85
      
    # 上下文精准度(Context Precision):检索到的上下文的相关性排序质量
    context_precision:
      enabled: true
      threshold: 0.80
      
  evaluation_sets:
    # 测试集路径
    test_data: "./data/eval_testset.json"
    
    # 自动生成测试集配置
    auto_generate:
      enabled: true
      sample_count: 50
      llm_model: "gpt-4o-mini"

八、总结

RAG 工程化是一个系统工程,不是简单的"接个向量库"就能完成的。从数据提取到效果评估,每个环节都需要精心设计和持续优化。

核心要点回顾:

  1. 数据质量是天花板:脏数据再好的算法也救不回来

  2. 分块策略影响检索效果:递归分块适合通用场景,语义分块适合结构化文档

  3. 嵌入模型决定检索下限:中文场景务必使用 BGE 等中文优化模型

  4. 混合检索 + 重排序是黄金组合:弥补单一检索方式的不足

  5. 三级应答机制提升用户体验:精准路由,避免"答不上来硬答"

  6. 评估体系是生产级系统的标配:没有量化指标就无法优化

技术选型建议:

场景

推荐组合

快速原型

ChromaDB + BGE + LlamaIndex

企业级生产

Milvus/Qdrant + BGE + LangChain + Ragas

多模态场景

Weaviate + CLIP + LangChain

云端 SaaS

Pinecone + Voyage AI + LangChain

RAG 的竞争,本质上是工程化体系的竞争。掌握完整的 RAG 技术栈,理解每一层的核心逻辑与选型策略,是 AI 时代开发者的核心竞争力。

0
  1. 支付宝打赏

    qrcode alipay
  2. 微信打赏

    qrcode weixin

评论区