大模型RAG+AI Agent企业级落地实战:检索增强生成架构与生产部署全指南

人工智能

摘要

  • 掌握RAG系统的核心架构与文档处理流水线,理解从原始文档到高质量检索结果的完整链路
  • 深入混合检索(向量+关键词+知识图谱)与重排序技术,实现企业级95%+的检索准确率
  • RAG+AI Agent深度融合实战:工具增强检索、多轮对话记忆、企业知识库权限控制与生产部署

目录


一、RAG系统架构与核心流程

1.1 RAG的核心价值与局限

检索增强生成(Retrieval-Augmented Generation, RAG)是2024-2026年大模型应用最广泛的技术范式。其核心价值在于:无需微调模型,通过检索外部知识库来增强大模型的回答质量和事实准确性。

然而,生产级RAG系统面临的核心挑战远不止"检索+拼接"那么简单:

  • 检索质量瓶颈:向量检索的语义相似不等于答案相关,Top-K结果可能包含大量噪声
  • 上下文窗口浪费:无关的检索结果挤占有限的上下文窗口,降低模型推理质量
  • 多跳推理缺失:复杂问题需要多步检索和推理,单次检索无法满足
  • 实时性要求:企业知识库频繁更新,索引需要实时同步

1.2 生产级RAG架构

一个生产级RAG系统的架构远比简单的"查询→检索→生成"复杂:

┌──────────────────────────────────────────────────┐
│                  Query Understanding              │
│   意图识别 · 查询改写 · 实体抽取 · 多跳分解       │
├──────────────────────────────────────────────────┤
│                  Hybrid Retrieval                 │
│   向量检索 · BM25关键词 · 知识图谱 · SQL查询       │
├──────────────────────────────────────────────────┤
│                  Reranking & Fusion               │
│   交叉编码器重排 · 互信息最大化 · 结果去重         │
├──────────────────────────────────────────────────┤
│                  Context Assembly                 │
│   相关性过滤 · 上下文压缩 · 结构化组织             │
├──────────────────────────────────────────────────┤
│                  Generation & Verification        │
│   Chain-of-Thought · 事实校验 · 幻觉检测          │
└──────────────────────────────────────────────────┘

Query Understanding层负责理解用户查询的真实意图,包括查询改写、实体抽取和多跳问题分解。Hybrid Retrieval层使用多种检索策略的融合。Reranking层对初步检索结果进行精细排序。Context Assembly层组装最优上下文。Generation层生成最终答案并进行事实校验。

1.3 RAG vs 微调 vs 预训练

维度 RAG 微调 预训练
知识更新 实时 需重新训练 需重新训练
成本 极高
事实准确性
领域适应性 最强
部署复杂度
幻觉控制 一般

企业级场景中,RAG+微调的组合是最佳实践:RAG保证事实准确性,微调优化模型对特定领域的理解和输出风格。


二、文档处理与分块策略

2.1 文档解析流水线

企业知识库的文档格式多样(PDF、Word、Excel、PPT、HTML、Markdown),需要统一的解析流水线:

from dataclasses import dataclass
from pathlib import Path
from typing import Optional
import hashlib

@dataclass
class ParsedDocument:
    doc_id: str
    title: str
    content: str
    metadata: dict
    source_path: str
    checksum: str
    page_count: int
    language: str

class DocumentParser:
    def __init__(self, ocr_enabled: bool = True, table_enabled: bool = True):
        self.ocr_enabled = ocr_enabled
        self.table_enabled = table_enabled

    def parse(self, file_path: str) -> ParsedDocument:
        path = Path(file_path)
        suffix = path.suffix.lower()

        match suffix:
            case ".pdf":
                return self._parse_pdf(file_path)
            case ".docx" | ".doc":
                return self._parse_docx(file_path)
            case ".xlsx" | ".xls":
                return self._parse_excel(file_path)
            case ".pptx":
                return self._parse_pptx(file_path)
            case ".md":
                return self._parse_markdown(file_path)
            case ".html" | ".htm":
                return self._parse_html(file_path)
            case _:
                return self._parse_plain_text(file_path)

    def _parse_pdf(self, file_path: str) -> ParsedDocument:
        import fitz

        doc = fitz.open(file_path)
        content_parts = []
        page_count = len(doc)

        for page_num in range(page_count):
            page = doc[page_num]

            text = page.get_text()
            if text.strip():
                content_parts.append(text)

            if self.table_enabled:
                tables = page.find_tables()
                for table in tables:
                    table_text = table.to_pandas().to_markdown()
                    content_parts.append(f"\n[Table on page {page_num + 1}]\n{table_text}")

            if self.ocr_enabled:
                images = page.get_images()
                for img_idx, img in enumerate(images):
                    xref = img[0]
                    base_image = doc.extract_image(xref)
                    if base_image:
                        image_bytes = base_image["image"]
                        ocr_text = self._ocr_image(image_bytes)
                        if ocr_text:
                            content_parts.append(
                                f"\n[Image {img_idx + 1} on page {page_num + 1}]\n{ocr_text}"
                            )

        content = "\n\n".join(content_parts)
        checksum = hashlib.sha256(content.encode()).hexdigest()[:16]

        return ParsedDocument(
            doc_id=f"doc_{checksum}",
            title=Path(file_path).stem,
            content=content,
            metadata={"format": "pdf", "page_count": page_count},
            source_path=file_path,
            checksum=checksum,
            page_count=page_count,
            language=self._detect_language(content),
        )

    def _ocr_image(self, image_bytes: bytes) -> Optional[str]:
        try:
            import pytesseract
            from PIL import Image
            import io
            image = Image.open(io.BytesIO(image_bytes))
            return pytesseract.image_to_string(image, lang='chi_sim+eng')
        except Exception:
            return None

    def _detect_language(self, text: str) -> str:
        sample = text[:500]
        chinese_chars = sum(1 for c in sample if '\u4e00' <= c <= '\u9fff')
        if chinese_chars / max(len(sample), 1) > 0.3:
            return "zh-CN"
        return "en"

2.2 分块策略深度对比

文档分块是RAG系统中最关键的环节之一,分块策略直接影响检索质量:

固定长度分块(Fixed-Size Chunking):按固定Token数切分,实现简单但可能切断语义完整性。

递归字符分块(Recursive Character Chunking):按段落→句子→字符的优先级递归切分,保持语义完整性。LangChain的RecursiveCharacterTextSplitter即采用此策略。

语义分块(Semantic Chunking):使用Embedding模型计算相邻句子的语义相似度,在语义断点处切分。质量最高但计算成本大。

文档结构分块(Structure-Aware Chunking):利用文档的标题层级、章节结构进行分块,保持文档的逻辑结构。

from dataclasses import dataclass
from typing import Callable
import numpy as np

@dataclass
class Chunk:
    chunk_id: str
    content: str
    metadata: dict
    start_index: int
    end_index: int
    token_count: int
    parent_doc_id: str

class SemanticChunker:
    def __init__(
        self,
        embedding_fn: Callable[[str], list[float]],
        similarity_threshold: float = 0.5,
        min_chunk_size: int = 100,
        max_chunk_size: int = 1000,
    ):
        self.embedding_fn = embedding_fn
        self.similarity_threshold = similarity_threshold
        self.min_chunk_size = min_chunk_size
        self.max_chunk_size = max_chunk_size

    def chunk(self, text: str, doc_id: str) -> list[Chunk]:
        sentences = self._split_sentences(text)
        if len(sentences) <= 1:
            return [Chunk(
                chunk_id=f"{doc_id}_0",
                content=text,
                metadata={"chunk_type": "semantic"},
                start_index=0,
                end_index=len(text),
                token_count=len(text) // 4,
                parent_doc_id=doc_id,
            )]

        embeddings = [self.embedding_fn(s) for s in sentences]
        similarities = [
            self._cosine_similarity(embeddings[i], embeddings[i + 1])
            for i in range(len(embeddings) - 1)
        ]

        breakpoints = []
        for i, sim in enumerate(similarities):
            if sim < self.similarity_threshold:
                breakpoints.append(i + 1)

        chunks = []
        current_start = 0
        chunk_idx = 0

        for bp in breakpoints + [len(sentences)]:
            chunk_text = " ".join(sentences[current_start:bp])
            token_count = len(chunk_text) // 4

            if token_count >= self.min_chunk_size:
                chunks.append(Chunk(
                    chunk_id=f"{doc_id}_{chunk_idx}",
                    content=chunk_text,
                    metadata={
                        "chunk_type": "semantic",
                        "sentence_count": bp - current_start,
                    },
                    start_index=current_start,
                    end_index=bp,
                    token_count=token_count,
                    parent_doc_id=doc_id,
                ))
                chunk_idx += 1
            elif chunks:
                chunks[-1].content += " " + chunk_text
                chunks[-1].end_index = bp
                chunks[-1].token_count += token_count

            current_start = bp

        for chunk in chunks:
            if chunk.token_count > self.max_chunk_size:
                self._split_oversized(chunk, doc_id, chunks)

        return chunks

    def _split_sentences(self, text: str) -> list[str]:
        import re
        sentences = re.split(r'(?<=[。!?.!?])\s*', text)
        return [s.strip() for s in sentences if s.strip()]

    def _cosine_similarity(self, a: list[float], b: list[float]) -> float:
        a_arr = np.array(a)
        b_arr = np.array(b)
        return float(np.dot(a_arr, b_arr) / (np.linalg.norm(a_arr) * np.linalg.norm(b_arr) + 1e-8))

    def _split_oversized(self, chunk: Chunk, doc_id: str, chunks: list[Chunk]) -> None:
        pass

2.3 元数据增强分块

为每个分块添加丰富的元数据,支持检索时的精细过滤:

@dataclass
class EnhancedChunk(Chunk):
    heading_path: list[str]
    keywords: list[str]
    entities: list[dict]
    summary: str
    access_level: int
    department: str
    doc_type: str
    created_at: str
    updated_at: str

class MetadataEnricher:
    def __init__(self, llm_client):
        self.llm = llm_client

    async def enrich(self, chunk: Chunk, doc: ParsedDocument) -> EnhancedChunk:
        keywords = await self._extract_keywords(chunk.content)
        entities = await self._extract_entities(chunk.content)
        summary = await self._generate_summary(chunk.content)

        return EnhancedChunk(
            **chunk.__dict__,
            heading_path=self._extract_heading_path(doc, chunk),
            keywords=keywords,
            entities=entities,
            summary=summary,
            access_level=doc.metadata.get("access_level", 0),
            department=doc.metadata.get("department", ""),
            doc_type=doc.metadata.get("format", ""),
            created_at=doc.metadata.get("created_at", ""),
            updated_at=doc.metadata.get("updated_at", ""),
        )

    async def _extract_keywords(self, text: str) -> list[str]:
        prompt = f"从以下文本中提取5-10个关键术语,以JSON数组格式返回:\n\n{text[:1000]}"
        response = await self.llm.generate(prompt)
        import json
        try:
            return json.loads(response)
        except:
            return []

    async def _extract_entities(self, text: str) -> list[dict]:
        prompt = f"""从以下文本中提取命名实体,返回JSON数组,每个实体包含name、type、value字段:
实体类型包括:PERSON, ORGANIZATION, PRODUCT, DATE, LOCATION, TECHNOLOGY

文本:
{text[:2000]}"""
        response = await self.llm.generate(prompt)
        import json
        try:
            return json.loads(response)
        except:
            return []

    async def _generate_summary(self, text: str) -> str:
        prompt = f"用一句话总结以下文本的核心内容:\n\n{text[:500]}"
        return await self.llm.generate(prompt)

    def _extract_heading_path(self, doc: ParsedDocument, chunk: Chunk) -> list[str]:
        return doc.metadata.get("heading_path", [])

三、Embedding模型选型与优化

3.1 主流Embedding模型对比

模型 维度 MTEB得分 中文能力 推理速度 许可证
BGE-M3 1024 73.5 优秀 MIT
GTE-Qwen2-7B 3584 76.2 优秀 Apache 2.0
text-embedding-3-large 3072 74.5 良好 商业
Jina-Embeddings-v3 1024 72.8 良好 CC-BY-4.0
BCE-Embedding 768 71.2 优秀 MIT

选型建议

  • 中文场景首选BGE-M3,性价比最高
  • 追求极致效果选GTE-Qwen2-7B,但推理成本高
  • 需要多语言支持选Jina-Embeddings-v3
  • 使用OpenAI API的场景选text-embedding-3-large

3.2 Embedding服务部署

from fastapi import FastAPI
from pydantic import BaseModel
import numpy as np
from sentence_transformers import SentenceTransformer

app = FastAPI()

class EmbedRequest(BaseModel):
    texts: list[str]
    normalize: bool = True

class EmbedResponse(BaseModel):
    embeddings: list[list[float]]
    model: str
    dimension: int

model = SentenceTransformer("BAAI/bge-m3")

@app.post("/embed", response_model=EmbedResponse)
async def embed(request: EmbedRequest):
    embeddings = model.encode(
        request.texts,
        normalize_embeddings=request.normalize,
        show_progress_bar=False,
    )
    return EmbedResponse(
        embeddings=embeddings.tolist(),
        model="bge-m3",
        dimension=embeddings.shape[1],
    )

@app.post("/embed/batch", response_model=EmbedResponse)
async def embed_batch(request: EmbedRequest):
    batch_size = 64
    all_embeddings = []
    for i in range(0, len(request.texts), batch_size):
        batch = request.texts[i:i + batch_size]
        batch_embeddings = model.encode(
            batch,
            normalize_embeddings=request.normalize,
            batch_size=len(batch),
        )
        all_embeddings.append(batch_embeddings)

    embeddings = np.vstack(all_embeddings)
    return EmbedResponse(
        embeddings=embeddings.tolist(),
        model="bge-m3",
        dimension=embeddings.shape[1],
    )

3.3 查询侧Embedding优化

查询侧的Embedding优化是提升检索质量的关键技巧:

查询扩展(Query Expansion):使用LLM将用户查询扩展为多个相关查询,增加检索覆盖面。

假设性文档嵌入(HyDE):先让LLM生成一个假设性答案,再用假设答案的Embedding去检索,比直接用查询检索效果更好。

指令前缀(Instruction Prefix):在查询前添加任务指令,如"为以下查询检索相关文档:",对齐训练时的输入格式。

class QueryOptimizer:
    def __init__(self, llm_client, embedding_fn):
        self.llm = llm_client
        self.embedding_fn = embedding_fn

    async def expand_query(self, query: str, num_expansions: int = 3) -> list[str]:
        prompt = f"""将以下查询改写为{num_expansions}个不同角度的等价查询,以JSON数组格式返回。
原始查询:{query}

要求:
1. 保持原始查询的核心意图
2. 使用不同的表述方式和关键词
3. 覆盖不同的专业术语和通俗说法"""

        response = await self.llm.generate(prompt)
        import json
        try:
            expansions = json.loads(response)
            return [query] + expansions[:num_expansions]
        except:
            return [query]

    async def hyde_embed(self, query: str) -> list[float]:
        prompt = f"""请针对以下问题,写一段详细的回答(即使你不确定答案,也请给出合理的假设性回答):

问题:{query}"""

        hypothetical_answer = await self.llm.generate(prompt)
        return self.embedding_fn(hypothetical_answer)

    def instruction_embed(self, query: str, task: str = "search") -> list[float]:
        prefixes = {
            "search": "为以下查询检索相关文档:",
            "similarity": "查找与以下内容相似的文档:",
            "classification": "对以下内容进行分类:",
        }
        prefix = prefixes.get(task, "")
        return self.embedding_fn(f"{prefix}{query}")

四、混合检索与重排序

4.1 混合检索架构

单一向量检索无法覆盖所有场景。关键词检索(BM25)擅长精确匹配(产品型号、专有名词),向量检索擅长语义匹配(概念相似),知识图谱检索擅长关系推理。混合检索是生产级RAG的必选项。

from dataclasses import dataclass
from typing import Optional

@dataclass
class RetrievalResult:
    chunk_id: str
    content: str
    score: float
    source: str
    metadata: dict

class HybridRetriever:
    def __init__(
        self,
        vector_store,
        bm25_store,
        kg_store=None,
        vector_weight: float = 0.6,
        bm25_weight: float = 0.3,
        kg_weight: float = 0.1,
    ):
        self.vector_store = vector_store
        self.bm25_store = bm25_store
        self.kg_store = kg_store
        self.vector_weight = vector_weight
        self.bm25_weight = bm25_weight
        self.kg_weight = kg_weight

    async def retrieve(
        self,
        query: str,
        query_embedding: list[float],
        top_k: int = 20,
        filters: Optional[dict] = None,
    ) -> list[RetrievalResult]:
        vector_results = await self.vector_store.search(
            query_embedding, top_k=top_k * 2, filters=filters
        )
        bm25_results = await self.bm25_store.search(
            query, top_k=top_k * 2, filters=filters
        )

        kg_results = []
        if self.kg_store:
            kg_results = await self.kg_store.search(
                query, top_k=top_k
            )

        merged = self._reciprocal_rank_fusion(
            vector_results, bm25_results, kg_results
        )

        return merged[:top_k]

    def _reciprocal_rank_fusion(
        self,
        vector_results: list[RetrievalResult],
        bm25_results: list[RetrievalResult],
        kg_results: list[RetrievalResult],
        k: int = 60,
    ) -> list[RetrievalResult]:
        scores: dict[str, float] = {}
        result_map: dict[str, RetrievalResult] = {}

        for rank, result in enumerate(vector_results):
            rrf_score = self.vector_weight / (k + rank + 1)
            scores[result.chunk_id] = scores.get(result.chunk_id, 0.0) + rrf_score
            result_map[result.chunk_id] = result

        for rank, result in enumerate(bm25_results):
            rrf_score = self.bm25_weight / (k + rank + 1)
            scores[result.chunk_id] = scores.get(result.chunk_id, 0.0) + rrf_score
            if result.chunk_id not in result_map:
                result_map[result.chunk_id] = result

        for rank, result in enumerate(kg_results):
            rrf_score = self.kg_weight / (k + rank + 1)
            scores[result.chunk_id] = scores.get(result.chunk_id, 0.0) + rrf_score
            if result.chunk_id not in result_map:
                result_map[result.chunk_id] = result

        sorted_ids = sorted(scores.keys(), key=lambda x: scores[x], reverse=True)

        return [
            RetrievalResult(
                chunk_id=cid,
                content=result_map[cid].content,
                score=scores[cid],
                source=result_map[cid].source,
                metadata=result_map[cid].metadata,
            )
            for cid in sorted_ids
        ]

4.2 交叉编码器重排序

初步检索结果使用双编码器(Bi-Encoder)计算,速度快但精度有限。交叉编码器(Cross-Encoder)将查询和文档一起输入模型,精度更高但速度慢,适合对Top-K结果进行精排:

from sentence_transformers import CrossEncoder

class Reranker:
    def __init__(self, model_name: str = "BAAI/bge-reranker-v2-m3"):
        self.model = CrossEncoder(model_name, max_length=512)

    def rerank(
        self,
        query: str,
        results: list[RetrievalResult],
        top_k: int = 10,
    ) -> list[RetrievalResult]:
        pairs = [(query, result.content) for result in results]
        scores = self.model.predict(pairs)

        scored_results = list(zip(results, scores))
        scored_results.sort(key=lambda x: x[1], reverse=True)

        return [
            RetrievalResult(
                chunk_id=result.chunk_id,
                content=result.content,
                score=float(score),
                source=result.source,
                metadata={**result.metadata, "rerank_score": float(score)},
            )
            for result, score in scored_results[:top_k]
        ]

4.3 相关性过滤

重排序后仍需过滤低相关性结果,避免噪声进入上下文:

class RelevanceFilter:
    def __init__(self, min_score: float = 0.3, max_chunks: int = 8):
        self.min_score = min_score
        self.max_chunks = max_chunks

    def filter(self, results: list[RetrievalResult]) -> list[RetrievalResult]:
        filtered = [r for r in results if r.score >= self.min_score]
        return filtered[:self.max_chunks]

    def adaptive_filter(
        self,
        results: list[RetrievalResult],
        max_context_tokens: int = 4000,
    ) -> list[RetrievalResult]:
        selected = []
        total_tokens = 0

        for result in results:
            if result.score < self.min_score:
                continue
            chunk_tokens = len(result.content) // 4
            if total_tokens + chunk_tokens > max_context_tokens:
                break
            selected.append(result)
            total_tokens += chunk_tokens

        return selected

五、RAG+AI Agent深度融合

5.1 工具增强检索Agent

将RAG检索能力封装为AI Agent可调用的工具,实现更智能的检索策略:

from typing import Annotated

class RAGAgentTools:
    def __init__(self, retriever: HybridRetriever, reranker: Reranker):
        self.retriever = retriever
        self.reranker = reranker

    def search_knowledge_base(
        self,
        query: Annotated[str, "Search query for the knowledge base"],
        top_k: Annotated[int, "Number of results to return"] = 5,
        filters: Annotated[dict | None, "Metadata filters"] = None,
    ) -> str:
        """Search the enterprise knowledge base for relevant documents."""
        query_embedding = self.get_embedding(query)
        results = self.retriever.retrieve(query, query_embedding, top_k=top_k * 2, filters=filters)
        reranked = self.reranker.rerank(query, results, top_k=top_k)

        if not reranked:
            return "No relevant documents found."

        formatted = []
        for i, result in enumerate(reranked):
            formatted.append(
                f"[Document {i + 1}] (Score: {result.score:.3f})\n"
                f"Source: {result.metadata.get('source', 'Unknown')}\n"
                f"Content: {result.content}\n"
            )

        return "\n---\n".join(formatted)

    def search_by_entity(
        self,
        entity_name: Annotated[str, "Entity name to search for"],
        entity_type: Annotated[str, "Entity type: PERSON, ORGANIZATION, PRODUCT, etc."] = "",
    ) -> str:
        """Search documents mentioning a specific entity."""
        filters = {"entities": {"name": entity_name}}
        if entity_type:
            filters["entities"]["type"] = entity_type

        return self.search_knowledge_base(entity_name, filters=filters)

    def compare_documents(
        self,
        topic: Annotated[str, "Topic to compare across documents"],
        doc_ids: Annotated[list[str], "Document IDs to compare"] = None,
    ) -> str:
        """Compare information about a topic across multiple documents."""
        query_embedding = self.get_embedding(topic)
        filters = {"parent_doc_id": {"$in": doc_ids}} if doc_ids else None
        results = self.retriever.retrieve(topic, query_embedding, top_k=20, filters=filters)
        reranked = self.reranker.rerank(topic, results, top_k=10)

        grouped = {}
        for result in reranked:
            doc_id = result.metadata.get("parent_doc_id", "unknown")
            if doc_id not in grouped:
                grouped[doc_id] = []
            grouped[doc_id].append(result)

        output = []
        for doc_id, chunks in grouped.items():
            output.append(f"Document: {doc_id}")
            for chunk in chunks:
                output.append(f"  - {chunk.content[:200]}...")

        return "\n\n".join(output)

5.2 多轮对话RAG

企业级RAG系统需要支持多轮对话,维护对话上下文和检索历史:

class ConversationalRAG:
    def __init__(self, llm_client, retriever, reranker):
        self.llm = llm_client
        self.retriever = retriever
        self.reranker = reranker

    async def chat(
        self,
        query: str,
        conversation_history: list[dict],
        max_context_tokens: int = 4000,
    ) -> dict:
        rewritten_query = await self._rewrite_query(query, conversation_history)

        query_embedding = self.get_embedding(rewritten_query)
        results = await self.retriever.retrieve(
            rewritten_query, query_embedding, top_k=20
        )
        reranked = self.reranker.rerank(rewritten_query, results, top_k=10)

        context = self._assemble_context(reranked, max_context_tokens)

        prompt = self._build_prompt(query, context, conversation_history)

        answer = await self.llm.generate(prompt)

        return {
            "answer": answer,
            "sources": [
                {
                    "chunk_id": r.chunk_id,
                    "content": r.content[:200],
                    "score": r.score,
                    "source": r.metadata.get("source", ""),
                }
                for r in reranked[:5]
            ],
            "rewritten_query": rewritten_query,
        }

    async def _rewrite_query(self, query: str, history: list[dict]) -> str:
        if not history:
            return query

        history_text = "\n".join([
            f"{'User' if h['role'] == 'user' else 'Assistant'}: {h['content']}"
            for h in history[-6:]
        ])

        prompt = f"""基于以下对话历史,将用户的最新问题改写为独立的、完整的检索查询。
只返回改写后的查询,不要解释。

对话历史:
{history_text}

最新问题:{query}

改写后的查询:"""

        return await self.llm.generate(prompt)

    def _assemble_context(self, results: list[RetrievalResult], max_tokens: int) -> str:
        parts = []
        total = 0
        for result in results:
            tokens = len(result.content) // 4
            if total + tokens > max_tokens:
                break
            parts.append(f"[Source: {result.metadata.get('source', 'Unknown')}]\n{result.content}")
            total += tokens
        return "\n\n---\n\n".join(parts)

    def _build_prompt(self, query: str, context: str, history: list[dict]) -> str:
        return f"""你是一个专业的企业知识库助手。请基于以下检索到的文档内容回答用户的问题。

要求:
1. 只基于提供的文档内容回答,不要编造信息
2. 如果文档中没有相关信息,明确告知用户
3. 引用信息时标注来源文档
4. 使用清晰的结构化格式

检索到的文档:
{context}

用户问题:{query}

回答:"""

5.3 多跳推理RAG

复杂问题需要多步检索和推理,单次RAG无法满足。多跳RAG通过Agent的规划能力,将复杂问题分解为多步检索链:

class MultiHopRAG:
    def __init__(self, llm_client, retriever, reranker, max_hops: int = 3):
        self.llm = llm_client
        self.retriever = retriever
        self.reranker = reranker
        self.max_hops = max_hops

    async def answer(self, query: str) -> dict:
        hop_results = []
        current_query = query
        all_contexts = []

        for hop in range(self.max_hops):
            query_embedding = self.get_embedding(current_query)
            results = await self.retriever.retrieve(
                current_query, query_embedding, top_k=10
            )
            reranked = self.reranker.rerank(current_query, results, top_k=5)
            all_contexts.extend(reranked)

            hop_results.append({
                "hop": hop + 1,
                "query": current_query,
                "results_count": len(reranked),
            })

            next_action = await self._decide_next_hop(query, all_contexts, hop)

            if next_action["action"] == "answer":
                break
            elif next_action["action"] == "search":
                current_query = next_action["query"]

        context = self._assemble_context(all_contexts)
        answer = await self._generate_answer(query, context)

        return {
            "answer": answer,
            "hops": hop_results,
            "total_contexts": len(all_contexts),
        }

    async def _decide_next_hop(self, original_query: str, contexts: list, hop: int) -> dict:
        if hop >= self.max_hops - 1:
            return {"action": "answer"}

        context_summary = "\n".join([
            f"- {c.content[:200]}" for c in contexts[-5:]
        ])

        prompt = f"""基于原始问题和已检索到的信息,判断是否需要继续检索。

原始问题:{original_query}

已检索信息:
{context_summary}

请判断:
1. 如果已有足够信息回答问题,返回 {{"action": "answer"}}
2. 如果需要更多信息,返回 {{"action": "search", "query": "下一步检索查询"}}

以JSON格式返回:"""

        response = await self.llm.generate(prompt)
        import json
        try:
            return json.loads(response)
        except:
            return {"action": "answer"}

六、企业知识库权限与安全

6.1 文档级权限控制

企业知识库的文档通常有严格的访问权限控制,RAG检索必须遵守权限规则:

class PermissionAwareRetriever:
    def __init__(self, base_retriever, permission_service):
        self.base_retriever = base_retriever
        self.permission_service = permission_service

    async def retrieve(
        self,
        query: str,
        query_embedding: list[float],
        user_id: str,
        top_k: int = 20,
    ) -> list[RetrievalResult]:
        user_permissions = await self.permission_service.get_user_permissions(user_id)

        accessible_departments = user_permissions.get("departments", [])
        access_level = user_permissions.get("access_level", 0)

        filters = {
            "$or": [
                {"department": {"$in": accessible_departments}},
                {"access_level": {"$lte": access_level}},
            ]
        }

        results = await self.base_retriever.retrieve(
            query, query_embedding, top_k=top_k * 2, filters=filters
        )

        verified_results = []
        for result in results:
            if await self._verify_access(result, user_permissions):
                verified_results.append(result)

        return verified_results[:top_k]

    async def _verify_access(self, result: RetrievalResult, permissions: dict) -> bool:
        doc_department = result.metadata.get("department", "")
        doc_access_level = result.metadata.get("access_level", 99)

        if doc_access_level <= permissions.get("access_level", 0):
            return True

        if doc_department in permissions.get("departments", []):
            return True

        return False

6.2 数据脱敏

检索结果在送入大模型前,需要自动脱敏敏感信息:

import re

class DataMasker:
    PATTERNS = {
        "phone": (r'1[3-9]\d{9}', lambda m: m.group()[:3] + "****" + m.group()[-4:]),
        "email": (r'[\w.-]+@[\w.-]+\.\w+', lambda m: m.group()[0] + "***@" + m.group().split("@")[1]),
        "id_card": (r'\d{17}[\dXx]', lambda m: m.group()[:6] + "********" + m.group()[-4:]),
        "bank_card": (r'\d{16,19}', lambda m: m.group()[:4] + "****" + m.group()[-4:]),
    }

    def mask(self, text: str, enabled_types: list[str] | None = None) -> str:
        types = enabled_types or list(self.PATTERNS.keys())
        for type_name in types:
            if type_name in self.PATTERNS:
                pattern, replacer = self.PATTERNS[type_name]
                text = re.sub(pattern, replacer, text)
        return text

6.3 审计日志

所有RAG查询和检索操作需要记录审计日志:

from datetime import datetime

class RAGAuditLogger:
    def __init__(self, log_store):
        self.log_store = log_store

    async def log_query(
        self,
        user_id: str,
        query: str,
        rewritten_query: str | None,
        results_count: int,
        answer_preview: str,
        latency_ms: int,
    ):
        entry = {
            "timestamp": datetime.utcnow().isoformat(),
            "user_id": user_id,
            "original_query": query,
            "rewritten_query": rewritten_query,
            "results_count": results_count,
            "answer_preview": answer_preview[:200],
            "latency_ms": latency_ms,
        }
        await self.log_store.insert(entry)

    async def log_access_denied(self, user_id: str, query: str, denied_doc_ids: list[str]):
        entry = {
            "timestamp": datetime.utcnow().isoformat(),
            "event_type": "access_denied",
            "user_id": user_id,
            "query": query,
            "denied_documents": denied_doc_ids,
        }
        await self.log_store.insert(entry)

七、生产级部署与性能优化

7.1 缓存策略

RAG系统的多层缓存策略:

import hashlib
from functools import lru_cache

class RAGCache:
    def __init__(self, redis_client, ttl: int = 3600):
        self.redis = redis_client
        self.ttl = ttl

    def _cache_key(self, query: str, user_id: str, filters: dict | None = None) -> str:
        raw = f"{query}:{user_id}:{filters}"
        return f"rag:cache:{hashlib.md5(raw.encode()).hexdigest()}"

    async def get(self, query: str, user_id: str, filters: dict | None = None) -> dict | None:
        key = self._cache_key(query, user_id, filters)
        cached = await self.redis.get(key)
        if cached:
            import json
            return json.loads(cached)
        return None

    async def set(self, query: str, user_id: str, result: dict, filters: dict | None = None):
        key = self._cache_key(query, user_id, filters)
        import json
        await self.redis.setex(key, self.ttl, json.dumps(result, ensure_ascii=False))

    async def invalidate_doc(self, doc_id: str):
        pattern = f"rag:cache:*"
        async for key in self.redis.scan_iter(pattern):
            cached = await self.redis.get(key)
            if cached and doc_id in cached.decode():
                await self.redis.delete(key)

7.2 异步索引更新

企业知识库的文档频繁更新,需要异步索引更新机制:

import asyncio
from typing import Callable

class AsyncIndexUpdater:
    def __init__(
        self,
        embedding_fn: Callable,
        vector_store,
        chunker,
        batch_size: int = 100,
        flush_interval: int = 30,
    ):
        self.embedding_fn = embedding_fn
        self.vector_store = vector_store
        self.chunker = chunker
        self.batch_size = batch_size
        self.flush_interval = flush_interval
        self.pending_updates: list[dict] = []
        self._running = False

    async def start(self):
        self._running = True
        asyncio.create_task(self._flush_loop())

    async def stop(self):
        self._running = False
        if self.pending_updates:
            await self._flush()

    async def add_document(self, doc: ParsedDocument):
        self.pending_updates.append({
            "action": "add",
            "doc_id": doc.doc_id,
            "content": doc.content,
            "metadata": doc.metadata,
        })

        if len(self.pending_updates) >= self.batch_size:
            await self._flush()

    async def delete_document(self, doc_id: str):
        self.pending_updates.append({
            "action": "delete",
            "doc_id": doc_id,
        })

    async def _flush_loop(self):
        while self._running:
            await asyncio.sleep(self.flush_interval)
            if self.pending_updates:
                await self._flush()

    async def _flush(self):
        updates = self.pending_updates[:]
        self.pending_updates.clear()

        to_add = [u for u in updates if u["action"] == "add"]
        to_delete = [u for u in updates if u["action"] == "delete"]

        if to_add:
            all_chunks = []
            for update in to_add:
                chunks = self.chunker.chunk(update["content"], update["doc_id"])
                all_chunks.extend(chunks)

            texts = [c.content for c in all_chunks]
            embeddings = [self.embedding_fn(t) for t in texts]

            await self.vector_store.upsert(
                ids=[c.chunk_id for c in all_chunks],
                embeddings=embeddings,
                metadatas=[c.metadata for c in all_chunks],
                documents=texts,
            )

        if to_delete:
            doc_ids = [u["doc_id"] for u in to_delete]
            await self.vector_store.delete_by_doc_ids(doc_ids)

7.3 性能指标与SLA

指标 SLA目标 监控方式
端到端延迟P95 < 3s Prometheus histogram
检索延迟P95 < 500ms 自定义指标
检索准确率 > 95% 人工抽检+自动评估
索引更新延迟 < 60s 文档写入→可检索时间差
系统可用性 > 99.9% 健康检查+告警
并发QPS > 100 压测验证

八、总结与展望

大模型RAG+AI Agent的企业级落地是2026年AI应用最核心的技术方向之一。本文从RAG架构、文档处理、Embedding优化、混合检索、Agent融合、权限安全和生产部署七个维度,系统性地阐述了企业级RAG系统的构建方法。

关键要点回顾:

  1. 混合检索:向量+BM25+知识图谱的RRF融合,是生产级RAG检索的标配
  2. 重排序:交叉编码器精排+相关性过滤,将检索准确率从70%提升到95%+
  3. 查询优化:查询扩展+HyDE+指令前缀,显著提升检索召回率
  4. Agent融合:RAG能力封装为Agent工具,支持多轮对话和多跳推理
  5. 安全合规:文档级权限+数据脱敏+审计日志,是企业级部署的必要条件

未来,RAG技术将向更智能的方向演进:自适应检索策略(根据查询类型自动选择检索方式)、持续学习(从用户反馈中优化检索质量)、多模态RAG(支持图像、表格、代码等非文本内容的检索与生成)。AI Agent与RAG的深度融合将使知识库从被动检索工具,进化为主动知识助手。

相关阅读

权威参考

本站提供浏览器本地工具,免注册即可试用 →

#RAG#AI Agent#大模型#向量检索#企业级落地#2026