Skip to content

RAG 检索增强生成 ​

#RAG · #向量数据库 · #语义检索 · #Chunking · #Embedding · #Milvus · #Qdrant · #HybridSearch · #AgenticRAG · #GraphRAG

如何把一本几十万字的 HTML 课程书变成 AI 随时能检索的知识库?本专题覆盖文档解析、语义分块、向量数据库选型、多路召回与重排的全链路工程实践。


RAG 全景架构 ​

RAG(Retrieval-Augmented Generation)让 LLM 能够在生成回答前,先从外部知识库中检索相关信息,有效解决 LLM 的"幻觉"和知识更新滞后问题。

mermaid
graph TD
    subgraph "离线阶段(建库)"
        DOC["📄 原始文档<br/>HTML / PDF / Markdown"] --> PARSE["🔍 文档解析<br/>提取文本 + 结构"]
        PARSE --> CHUNK["✂️ 语义分块<br/>按 &lt;h1&gt;, &lt;section&gt; 切分"]
        CHUNK --> EMBED["🧮 向量化<br/>Embedding 模型编码"]
        EMBED --> STORE["💾 存入向量数据库<br/>Milvus / Qdrant / PGVector"]
    end

    subgraph "在线阶段(检索)"
        QUERY["❓ 用户查询"] --> QEMBED["🧮 查询向量化"]
        QEMBED --> SEARCH["🔍 混合检索<br/>向量 + BM25"]
        SEARCH --> RERANK["📊 重排序<br/>Rerank 模型精排"]
        RERANK --> CTX["📋 构建上下文"]
    end

    CTX --> LLM["🧠 LLM 生成回答<br/>基于检索到的上下文"]
    LLM --> ANS["✅ 最终答案"]

    style STORE fill:#3498db,color:#fff
    style SEARCH fill:#e74c3c,color:#fff
    style LLM fill:#9b59b6,color:#fff

文档解析与语义分块(Chunking) ​

为什么不能简单按字数切分? ​

❌ 机械按字数切分(每 512 字一刀):
  "...Transformer 架构的核心是自注意力机制。具体来说,"
  — 这里被切断了 —
  "每一个 Token 都会与序列中的其他所有 Token 计算..."

✅ 结构化按标签切分(基于 HTML 标签):
  <h2>自注意力机制</h2>
  <p>Transformer 架构的核心是自注意力机制。
  具体来说,每一个 Token 都会与序列中的其他所有 Token 计算注意力权重...</p>

结构化 Chunking 引擎 ​

python
"""
基于 HTML 标签结构的智能分块引擎

策略:
1. 识别 <h1>~<h6> 作为天然的分段边界
2. <section>, <article>, <div class="chapter"> 作为语义边界
3. 在每个分段内部,保持段落的完整性
4. 块与块之间有重叠(overlap),确保跨块信息不丢失
"""

import re
from dataclasses import dataclass, field
from typing import List, Optional
from html.parser import HTMLParser


@dataclass
class Chunk:
    """文档分块"""
    text: str                 # 块文本内容
    title: str = ""          # 所属的父标题(如 "2.1 自注意力")
    level: int = 0           # 标题层级(h1=1, h2=2, ...)
    start_pos: int = 0       # 在原文档中的起始位置
    metadata: dict = field(default_factory=dict)  # 额外元数据


class HTMLChunkParser(HTMLParser):
    """
    HTML 结构化分块解析器

    按 <h1>~<h6> 和 <section> 标签进行语义切分,
    而非机械地按字数切分。
    """

    def __init__(self, max_chunk_size: int = 1024, overlap: int = 128):
        super().__init__()
        self.max_chunk_size = max_chunk_size  # 每个块的最大 Token 数
        self.overlap = overlap                 # 块之间的重叠 Token 数
        self.chunks: List[Chunk] = []

        # 解析状态
        self._current_text: List[str] = []
        self._current_title = ""
        self._current_level = 0
        self._title_stack: List[tuple] = []  # [(title, level), ...]

    def handle_starttag(self, tag: str, attrs: list):
        # 标题标签 → 新的语义分段
        if tag in ('h1', 'h2', 'h3', 'h4', 'h5', 'h6'):
            # 先保存当前块
            self._maybe_flush()

            # 更新标题层级
            level = int(tag[1])
            # 弹出比当前层级更深的标题
            while self._title_stack and self._title_stack[-1][1] >= level:
                self._title_stack.pop()

        # section/article → 语义分段
        if tag in ('section', 'article'):
            self._maybe_flush()

    def handle_endtag(self, tag: str):
        if tag in ('h1', 'h2', 'h3', 'h4', 'h5', 'h6'):
            # 标题结束,记录标题文本
            title_text = ''.join(self._current_text).strip()
            level = int(tag[1])
            self._title_stack.append((title_text, level))
            self._current_title = title_text
            self._current_level = level
            self._current_text = []

        elif tag in ('p', 'div', 'li', 'td', 'th', 'section', 'article'):
            # 段落结束 → 可能的分块点
            self._maybe_flush()

    def handle_data(self, data: str):
        text = data.strip()
        if text:
            self._current_text.append(text + ' ')

    def _maybe_flush(self):
        """当前文本积累到一定量时,输出为一个 Chunk"""
        text = ''.join(self._current_text).strip()
        if not text:
            return

        # 如果文本太长,按句子进一步切分
        if len(text) > self.max_chunk_size:
            sub_chunks = self._split_by_sentence(text)
            for sc in sub_chunks:
                self._emit_chunk(sc)
        else:
            self._emit_chunk(text)

        self._current_text = []

    def _split_by_sentence(self, text: str) -> List[str]:
        """按句子切分,尽量保持语义完整"""
        sentences = re.split(r'(?<=[。!?.!?])\s*', text)
        chunks = []
        current = ""

        for sent in sentences:
            if len(current) + len(sent) <= self.max_chunk_size:
                current += sent
            else:
                if current:
                    chunks.append(current)
                current = sent

        if current:
            chunks.append(current)

        # 添加重叠
        if len(chunks) > 1 and self.overlap > 0:
            overlapped = [chunks[0]]
            for i in range(1, len(chunks)):
                prev = chunks[i-1]
                curr = chunks[i]
                if len(prev) > self.overlap:
                    curr = prev[-self.overlap:] + ' ' + curr
                overlapped.append(curr)
            return overlapped

        return chunks

    def _emit_chunk(self, text: str):
        """输出一个分块"""
        # 构建完整标题路径: "1. 深度学习 > 1.2 神经网络 > 自注意力"
        title_path = ' > '.join(t[0] for t in self._title_stack)
        if self._current_title and self._current_title not in title_path:
            if title_path:
                title_path += ' > ' + self._current_title
            else:
                title_path = self._current_title

        self.chunks.append(Chunk(
            text=text.strip(),
            title=title_path,
            level=self._current_level,
        ))

    def close(self):
        """解析结束,刷新最后的缓存"""
        self._maybe_flush()
        super().close()


# ========== 使用示例 ==========

def chunk_html_document(html_content: str, max_chunk_size: int = 1024) -> List[Chunk]:
    """对 HTML 文档进行结构化分块"""
    parser = HTMLChunkParser(max_chunk_size=max_chunk_size, overlap=128)
    parser.feed(html_content)
    parser.close()
    return parser.chunks


# 测试
sample_html = """
<h1>Transformer 架构</h1>
<section>
  <h2>自注意力机制</h2>
  <p>Transformer 架构的核心是自注意力机制。具体来说,每一个 Token 都会与序列中的
  其他所有 Token 计算注意力权重,从而捕获全局依赖关系。</p>
  <p>自注意力的计算分为三个步骤:首先通过线性变换得到 Q、K、V 向量,然后计算
  Q 与 K 的点积,最后用 Softmax 归一化后的权重对 V 加权求和。</p>
</section>
<section>
  <h2>多头注意力</h2>
  <p>为了捕获不同类型的依赖关系,Transformer 引入了多头注意力机制。
  每个头独立进行注意力计算,最后将所有头的结果拼接起来。</p>
</section>
"""

# chunks = chunk_html_document(sample_html)
# for i, c in enumerate(chunks):
#     print(f"--- Chunk {i}: [{c.title}] ---\n{c.text}\n")

Chunking 策略对比 ​

策略方法优点缺点适用场景
固定大小每 N 字一刀实现简单语义割裂严重快速原型
固定大小+重叠每 N 字一刀 + M 字重叠减轻割裂仍可能跨段通用文档
按句子每个句号切分语义完整粒度不均匀短文本
按段落每个 <p> 切分自然语义边界段落可能太长HTML 文档
按标题层级<h1>~<h6> 切分最佳语义结构需 HTML 解析课程文档/书籍
递归分块先按大段切,过长则递归切灵活实现复杂大型文档
语义分块用模型判断语义边界最智能成本高高质量需求

推荐:对 HTML 课程文档使用"按标题层级 + 段落内的句子切分",既保持了语义结构,又控制了块大小。


向量数据库(Vector DB)选型与性能 ​

主流向量数据库对比 ​

mermaid
graph TD
    subgraph "专用向量数据库"
        MILVUS["Milvus<br/>⚡ 高性能分布式<br/>HNSW/IVF/DiskANN"]
        QDRANT["Qdrant<br/>🦀 Rust 实现<br/>量化索引 + 过滤"]
        WEAVIATE["Weaviate<br/>🔌 GraphQL 原生<br/>混合搜索内置"]
    end

    subgraph "数据库扩展"
        PGVECTOR["PGVector<br/>🐘 PostgreSQL 扩展<br/>IVFFlat / HNSW"]
        ELASTIC["Elasticsearch<br/>🔍 kNN + BM25<br/>8.x+ 原生支持"]
        REDIS["Redis Stack<br/>⚡ 极低延迟<br/>FLAT / HNSW"]
    end

    style MILVUS fill:#3498db,color:#fff
    style PGVECTOR fill:#2ecc71,color:#fff

关键性能指标对比 ​

数据库索引算法10万向量
建索引
100万向量
召回QPS
过滤支持分布式适合场景
MilvusHNSW / IVF / DiskANN~5s5000+标量+向量✅ 原生大规模生产
QdrantHNSW + 量化~3s3000+强过滤✅中大型项目
PGVectorIVFFlat / HNSW~30s500+SQL 原生❌ (PG 层面)已有 PG 的小项目
ElasticsearchHNSW~10s2000+全文+向量✅ 原生已有 ES 的团队
Redis StackFLAT / HNSW~2s5000+简单 Tag❌低延迟缓存层

召回延迟实测框架 ​

python
"""
向量数据库性能基准测试框架

测试维度:
1. 索引构建速度
2. 不同数据量下的召回延迟 (P50/P95/P99)
3. 召回率 (Recall@K)
"""

import time
import numpy as np
from dataclasses import dataclass
from typing import List, Tuple


@dataclass
class SearchResult:
    """单次检索结果"""
    ids: List[int]            # 召回 ID 列表
    scores: List[float]       # 相似度分数
    latency_ms: float         # 检索延迟


@dataclass
class BenchmarkReport:
    """基准测试报告"""
    db_name: str              # 数据库名称
    index_time_s: float       # 建索引耗时(秒)
    num_vectors: int          # 向量总数
    dim: int                  # 向量维度

    # 延迟指标
    p50_latency_ms: float     # P50 延迟
    p95_latency_ms: float     # P95 延迟
    p99_latency_ms: float     # P99 延迟

    # 吞吐指标
    qps: float                # 每秒查询数

    # 质量指标
    recall_at_10: float       # Recall@10
    recall_at_100: float      # Recall@100


class VectorDBBenchmark:
    """
    向量数据库通用 Benchmark 框架

    使用方法:
    bench = VectorDBBenchmark()
    bench.setup(dim=768, num_vectors=100_000)
    bench.run_search_test(top_k=10, num_queries=1000)
    report = bench.report()
    """

    def __init__(self):
        self.vectors: np.ndarray = None       # 待索引向量 (N, dim)
        self.queries: np.ndarray = None       # 查询向量 (M, dim)
        self.ground_truth: np.ndarray = None  # 真实 Top-K (M, K)
        self.dim = 0

    def setup(self, dim: int = 768, num_vectors: int = 100_000,
              num_queries: int = 1000):
        """生成测试数据:随机向量 + 少量扰动作为 ground truth"""
        self.dim = dim

        # 生成归一化向量(模拟 Embedding 输出)
        self.vectors = np.random.randn(num_vectors, dim).astype(np.float32)
        self.vectors /= np.linalg.norm(self.vectors, axis=1, keepdims=True)

        # 查询向量:从已有向量中随机选取并加噪声(模拟真实查询)
        indices = np.random.choice(num_vectors, num_queries, replace=False)
        self.queries = self.vectors[indices] + np.random.randn(num_queries, dim).astype(np.float32) * 0.1
        self.queries /= np.linalg.norm(self.queries, axis=1, keepdims=True)

        # Ground truth: 暴力计算余弦相似度
        similarities = self.queries @ self.vectors.T  # (M, N)
        self.ground_truth = np.argsort(-similarities, axis=1)  # 降序

    def benchmark_index_build(self, build_fn) -> float:
        """测试索引构建速度"""
        start = time.perf_counter()
        build_fn(self.vectors)
        return time.perf_counter() - start

    def benchmark_search(self, search_fn, top_k: int = 10,
                         num_queries: int = 1000) -> Tuple[List[float], float]:
        """测试检索延迟和 QPS"""
        latencies = []
        for i in range(num_queries):
            start = time.perf_counter()
            _ = search_fn(self.queries[i], top_k)
            latencies.append((time.perf_counter() - start) * 1000)  # ms

        latencies = np.array(latencies)
        total_time = latencies.sum() / 1000  # 转为秒
        qps = num_queries / total_time if total_time > 0 else float('inf')

        return latencies, qps

    def calculate_recall(self, search_fn, top_k: int = 10,
                         num_queries: int = 100) -> float:
        """计算 Recall@K"""
        total_hits = 0
        for i in range(num_queries):
            result = search_fn(self.queries[i], top_k)
            retrieved_ids = set(result.ids)
            true_ids = set(self.ground_truth[i][:top_k])
            total_hits += len(retrieved_ids & true_ids)

        return total_hits / (num_queries * top_k)

    def report(self, db_name: str, build_fn, search_fn,
               top_k: int = 10, num_search_queries: int = 1000) -> BenchmarkReport:
        """生成完整的基准测试报告"""
        print(f"🔬 测试 {db_name} ...")

        # 建索引
        index_time = self.benchmark_index_build(build_fn)
        print(f"  索引构建: {index_time:.2f}s")

        # 检索性能
        latencies, qps = self.benchmark_search(search_fn, top_k, num_search_queries)
        print(f"  QPS: {qps:.0f}, P50: {np.percentile(latencies, 50):.1f}ms, "
              f"P99: {np.percentile(latencies, 99):.1f}ms")

        # 召回率
        recall = self.calculate_recall(search_fn, top_k, min(100, num_search_queries))
        print(f"  Recall@{top_k}: {recall:.3f}")

        return BenchmarkReport(
            db_name=db_name,
            index_time_s=index_time,
            num_vectors=len(self.vectors),
            dim=self.dim,
            p50_latency_ms=np.percentile(latencies, 50),
            p95_latency_ms=np.percentile(latencies, 95),
            p99_latency_ms=np.percentile(latencies, 99),
            qps=qps,
            recall_at_10=recall,
            recall_at_100=0.0  # 需要额外测试
        )

多路召回(Hybrid Search)与重排(Rerank) ​

为什么需要混合检索? ​

纯向量检索的局限性:

  • 语义漂移:用户问"苹果公司股价",可能召回关于"吃苹果"的内容
  • 精确匹配弱:无法高效处理"型号 A12345" 这类精确匹配查询
  • 关键词缺失:长尾关键词在向量空间中可能被稀释
mermaid
graph TD
    QUERY["用户查询<br/>'MacBook Pro M3 散热怎么样?'"] --> VEC["向量检索<br/>语义相似度"]
    QUERY --> BM25["BM25 全文检索<br/>关键词匹配"]

    VEC --> VEC_R["语义结果<br/>'笔记本散热设计'<br/>'Apple Silicon 功耗'"]
    BM25 --> BM25_R["关键词结果<br/>'MacBook Pro M3评测'<br/>'M3 散热测试'"]

    VEC_R --> FUSION["🔄 结果融合<br/>RRF (Reciprocal Rank Fusion)"]
    BM25_R --> FUSION

    FUSION --> MERGED["融合结果 Top-20<br/>向量 + 关键词互补"]
    MERGED --> RERANK["📊 重排序<br/>BGE-Reranker / Cohere"]
    RERANK --> FINAL["最终 Top-5<br/>精准上下文"]

    style FUSION fill:#f39c12,color:#fff
    style RERANK fill:#e74c3c,color:#fff
    style FINAL fill:#2ecc71,color:#fff

RRF(Reciprocal Rank Fusion)实现 ​

python
"""
混合检索:BM25 + 向量检索的融合与重排
"""

import numpy as np
from typing import List, Tuple


def reciprocal_rank_fusion(
    vector_results: List[Tuple[str, float]],   # [(doc_id, vector_score), ...]
    bm25_results: List[Tuple[str, float]],     # [(doc_id, bm25_score), ...]
    k: int = 60,                                # RRF 平滑参数
    top_k: int = 20,
) -> List[Tuple[str, float]]:
    """
    RRF 多路融合算法

    原理:
    对每个文档,累加其在各路召回中的"倒数排名":
        RRF_score(doc) = Σ 1/(k + rank_i(doc))

    其中 k 是平滑参数(典型值 60),rank_i 是文档在第 i 路召回中的排名。

    优点:
    - 不需要归一化各路分数(不同召回源的分数量级不同)
    - 简单高效,适合在线计算
    - 自动给予"多路都排名靠前"的文档更高分数
    """
    scores = {}

    # 向量检索结果:rank 越小越好
    for rank, (doc_id, _) in enumerate(vector_results):
        scores[doc_id] = scores.get(doc_id, 0) + 1.0 / (k + rank + 1)

    # BM25 结果
    for rank, (doc_id, _) in enumerate(bm25_results):
        scores[doc_id] = scores.get(doc_id, 0) + 1.0 / (k + rank + 1)

    # 按 RRF 分数排序
    ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
    return ranked[:top_k]


# ========== 加权融合(备选方案) ==========

def weighted_score_fusion(
    vector_results: List[Tuple[str, float]],
    bm25_results: List[Tuple[str, float]],
    vector_weight: float = 0.7,   # 向量检索权重
    top_k: int = 20,
) -> List[Tuple[str, float]]:
    """
    加权分数融合

    需要先对各路分数做归一化(如 min-max 归一化)
    """
    # 归一化
    def normalize(results: List[Tuple[str, float]]) -> dict:
        if not results:
            return {}
        scores = np.array([s for _, s in results])
        min_s, max_s = scores.min(), scores.max()
        if max_s == min_s:
            return {doc_id: 0.5 for doc_id, _ in results}
        return {doc_id: (s - min_s) / (max_s - min_s) for doc_id, s in results}

    vec_norm = normalize(vector_results)
    bm25_norm = normalize(bm25_results)

    # 融合
    all_docs = set(vec_norm.keys()) | set(bm25_norm.keys())
    fused = {}
    for doc_id in all_docs:
        fused[doc_id] = (
            vector_weight * vec_norm.get(doc_id, 0) +
            (1 - vector_weight) * bm25_norm.get(doc_id, 0)
        )

    return sorted(fused.items(), key=lambda x: x[1], reverse=True)[:top_k]

Rerank 模型的作用 ​

重排序模型对融合后的候选文档进行精细打分,大幅提升最终召回精度:

python
"""
使用 BGE-Reranker 进行重排序

Rerank 模型与 Embedding 模型的区别:
- Embedding 模型:独立编码 query 和 doc 为向量,通过余弦距离计算相似度
- Rerank 模型:将 query 和 doc 拼接后共同编码,能捕获更深层的语义交互
"""

from typing import List

class Reranker:
    """
    Rerank 模型接口

    典型选择:
    - BGE-Reranker-v2-m3 (BAAI): 多语言,开源,效果好
    - Cohere Rerank API: 商业,API 调用
    - Jina Reranker: 支持更长文本
    """

    def __init__(self, model_name: str = "BAAI/bge-reranker-v2-m3"):
        self.model_name = model_name
        # 实际使用时需要加载模型:
        # from transformers import AutoModelForSequenceClassification, AutoTokenizer
        # self.model = AutoModelForSequenceClassification.from_pretrained(model_name)
        # self.tokenizer = AutoTokenizer.from_pretrained(model_name)

    def rerank(
        self,
        query: str,
        documents: List[str],
        top_k: int = 5,
    ) -> List[Tuple[int, float]]:
        """
        对候选文档进行重排序

        Args:
            query: 用户查询
            documents: 候选文档列表(已通过混合召回获取)
            top_k: 返回的文档数

        Returns:
            [(doc_index, relevance_score), ...]  按相关性降序排列
        """
        # 为每对 (query, doc) 计算相关性分数
        # 实际实现中调用模型:
        # inputs = self.tokenizer(
        #     [(query, doc) for doc in documents],
        #     padding=True, truncation=True, return_tensors="pt",
        #     max_length=512
        # )
        # scores = self.model(**inputs).logits.squeeze(-1)

        # 模拟实现
        scores = [
            0.98 - i * 0.03 + np.random.uniform(-0.02, 0.02)
            for i in range(len(documents))
        ]

        # 返回排序后的结果
        ranked = sorted(
            enumerate(scores),
            key=lambda x: x[1],
            reverse=True
        )
        return ranked[:top_k]


# ========== 完整检索流程 ==========

class HybridRAGRetriever:
    """混合检索 + 重排的完整 RAG 检索器"""

    def __init__(self, vector_db, bm25_index, reranker: Reranker = None):
        self.vector_db = vector_db       # 向量数据库
        self.bm25_index = bm25_index     # BM25 索引(如 Elasticsearch)
        self.reranker = reranker or Reranker()

    def retrieve(self, query: str, top_k: int = 5) -> List[str]:
        """
        完整检索流程:
        1. 向量检索 → Top-50
        2. BM25 检索 → Top-50
        3. RRF 融合 → Top-20
        4. Rerank 精排 → Top-5
        """
        # Step 1 & 2: 两路召回
        vec_results = self._vector_search(query, top_k=50)
        bm25_results = self._bm25_search(query, top_k=50)

        # Step 3: RRF 融合
        fused = reciprocal_rank_fusion(vec_results, bm25_results, top_k=20)

        # Step 4: Rerank 精排
        doc_texts = [self._fetch_document(doc_id) for doc_id, _ in fused]
        reranked = self.reranker.rerank(query, doc_texts, top_k=top_k)

        return [doc_texts[i] for i, _ in reranked]

    def _vector_search(self, query, top_k):
        # 获取查询的 Embedding 向量,然后检索
        # query_vec = self.embedding_model.encode(query)
        # return self.vector_db.search(query_vec, top_k)
        return []  # 模拟

    def _bm25_search(self, query, top_k):
        # return self.bm25_index.search(query, size=top_k)
        return []  # 模拟

    def _fetch_document(self, doc_id):
        # return self.document_store.get(doc_id)
        return ""  # 模拟

各检索方案准确率对比 ​

方案Recall@10Recall@50MRR延迟说明
纯 BM250.620.740.55<10ms关键词匹配
纯向量 (cosine)0.780.880.70<20ms语义匹配
BM25 + 向量 (加权)0.850.930.78<30ms互补
BM25 + 向量 (RRF)0.880.950.82<30ms推荐
以上 + Rerank0.940.980.91<200ms质量最高

结论:RRF 融合 + Rerank 重排是目前工程上性价比最高的方案。增量成本(~150ms)换来显著的准确率提升(+6-9% Recall@10)。


RAG 全链路工程架构 ​

mermaid
graph TD
    subgraph "数据管道 (离线)"
        DOCS["📚 文档源<br/>HTML/Markdown/PDF"] --> PARSE["解析器<br/>HTMLChunkParser"]
        PARSE --> CHUNKS["分块<br/>(语义边界)"]
        CHUNKS --> EMB["Embedding 模型<br/>text-embedding-3-large"]
        EMB --> VEC_DB["Vector DB<br/>Milvus/Qdrant"]
        CHUNKS --> INV_IDX["倒排索引<br/>Elasticsearch/Whoosh"]
    end

    subgraph "在线服务"
        API["用户 API"] --> QEMB["Query Embedding"]
        QEMB --> HYBRID["Hybrid Search<br/>向量 + BM25"]
        HYBRID --> RERANK["Reranker 精排"]
        RERANK --> PROMPT["构建 Prompt<br/>System + Context + Query"]
        PROMPT --> LLM["LLM 生成"]
        LLM --> API
    end

    style DOCS fill:#3498db,color:#fff
    style VEC_DB fill:#2ecc71,color:#fff
    style HYBRID fill:#f39c12,color:#fff
    style LLM fill:#9b59b6,color:#fff

实战:连接主流向量数据库 ​

Milvus — 高性能分布式向量数据库 ​

python
"""Milvus 实战:建库、插入、检索完整流程"""

from pymilvus import (
    connections, Collection, CollectionSchema,
    FieldSchema, DataType, utility,
)
import numpy as np

# ===== 1. 连接 Milvus =====
# 本地模式(开发测试)
connections.connect(host="localhost", port="19530")

# 云端模式(Zilliz Cloud)
# connections.connect(
#     uri="https://xxx.zillizcloud.com",
#     token="your_api_key"
# )

# ===== 2. 定义 Collection Schema =====
fields = [
    FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True),
    FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=65535),
    FieldSchema(name="title", dtype=DataType.VARCHAR, max_length=512),
    FieldSchema(name="section", dtype=DataType.VARCHAR, max_length=256),
    FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=768),  # Embedding 维度
]

schema = CollectionSchema(
    fields=fields,
    description="Wiki 知识库向量索引",
    enable_dynamic_field=False,
)

# ===== 3. 创建 Collection =====
collection_name = "wiki_docs"
if utility.has_collection(collection_name):
    utility.drop_collection(collection_name)

collection = Collection(name=collection_name, schema=schema)

# ===== 4. 创建索引 =====
index_params = {
    "index_type": "HNSW",       # 或 "IVF_FLAT", "IVF_SQ8", "DISKANN"
    "metric_type": "COSINE",    # 余弦相似度
    "params": {
        "M": 16,                # HNSW 每层最大连接数(越大召回越高,建索引越慢)
        "efConstruction": 200,  # 构建时搜索宽度
    },
}
collection.create_index(field_name="embedding", index_params=index_params)

# ===== 5. 批量插入数据 =====
def insert_chunks(chunks, embedding_model, batch_size=100):
    """批量插入文档块"""
    for i in range(0, len(chunks), batch_size):
        batch = chunks[i:i + batch_size]

        # 向量化
        texts = [c.text for c in batch]
        embeddings = embedding_model.encode(texts)

        # 准备数据
        data = [
            [c.text for c in batch],
            [c.title for c in batch],
            [c.section for c in batch],
            embeddings.tolist(),
        ]

        # 插入
        mr = collection.insert(data)
        print(f"  插入 {min(i + batch_size, len(chunks))}/{len(chunks)} 条, "
              f"ID: {mr.primary_keys[0]} ~ {mr.primary_keys[-1]}")

    collection.flush()
    print(f"Collection 总数: {collection.num_entities}")

# ===== 6. 加载到内存(搜索前必须执行)=====
collection.load()

# ===== 7. 向量检索 =====
def search(query_text: str, embedding_model, top_k: int = 5):
    """检索相关文档"""
    query_vec = embedding_model.encode_query(query_text)

    search_params = {
        "metric_type": "COSINE",
        "params": {"ef": 64},  # 搜索时宽度(越大召回越高,速度越慢)
    }

    results = collection.search(
        data=[query_vec.tolist()],
        anns_field="embedding",
        param=search_params,
        limit=top_k,
        output_fields=["text", "title", "section"],  # 返回这些字段
    )

    docs = []
    for hits in results:
        for hit in hits:
            docs.append({
                "id": hit.id,
                "score": hit.score,
                "text": hit.entity.get("text", ""),
                "title": hit.entity.get("title", ""),
                "section": hit.entity.get("section", ""),
            })
    return docs

# ===== 8. 混合检索:向量 + 标量过滤 =====
def search_with_filter(query_text, embedding_model, title_filter: str = None):
    """带标量过滤的向量检索"""
    query_vec = embedding_model.encode_query(query_text)

    expr = None
    if title_filter:
        expr = f'title like "%{title_filter}%"'

    results = collection.search(
        data=[query_vec.tolist()],
        anns_field="embedding",
        param={"metric_type": "COSINE", "params": {"ef": 64}},
        limit=10,
        expr=expr,  # 标量过滤表达式
        output_fields=["text", "title"],
    )

    return [
        {"id": hit.id, "score": hit.score, "text": hit.entity.get("text", "")}
        for hits in results for hit in hits
    ]

# ===== 9. 管理操作 =====
# 查看 collection 信息
print(f"索引状态: {utility.index_building_progress(collection_name)}")
print(f"已加载: {utility.load_state(collection_name)}")
print(f"加载进度: {utility.loading_progress(collection_name)}")

# 释放内存
# collection.release()

# 删除 collection
# utility.drop_collection(collection_name)

Qdrant — Rust 实现的高性能向量数据库 ​

python
"""Qdrant 实战:建库、插入、检索完整流程"""

from qdrant_client import QdrantClient
from qdrant_client.models import (
    Distance, VectorParams, PointStruct,
    Filter, FieldCondition, MatchText,
)
import numpy as np

# ===== 1. 连接 Qdrant =====
# 本地模式
client = QdrantClient(host="localhost", port=6333)

# 内存模式(测试用)
# client = QdrantClient(":memory:")

# 云端模式
# client = QdrantClient(
#     url="https://xxx.cloud.qdrant.io",
#     api_key="your_api_key",
# )

# ===== 2. 创建 Collection =====
collection_name = "wiki_docs"
vector_size = 768  # Embedding 维度

if client.collection_exists(collection_name):
    client.delete_collection(collection_name)

client.create_collection(
    collection_name=collection_name,
    vectors_config=VectorParams(
        size=vector_size,
        distance=Distance.COSINE,  # 余弦距离
        on_disk=True,              # 向量存磁盘(大规模数据时)
    ),
    # HNSW 索引参数
    hnsw_config={
        "m": 16,
        "ef_construct": 200,
    },
    # 量化(减少内存占用)
    quantization_config={
        "scalar": {
            "type": "int8",
            "quantile": 0.99,
            "always_ram": True,
        }
    },
)

# ===== 3. 批量插入数据 =====
def insert_chunks_qdrant(chunks, embedding_model, batch_size=100):
    """批量插入文档块到 Qdrant"""
    offset = 0
    for i in range(0, len(chunks), batch_size):
        batch = chunks[i:i + batch_size]

        # 向量化
        texts = [c.text for c in batch]
        embeddings = embedding_model.encode(texts)

        # 构建 points
        points = [
            PointStruct(
                id=offset + j,
                vector=emb.tolist(),
                payload={
                    "text": batch[j].text,
                    "title": batch[j].title,
                    "section": batch[j].section,
                    "char_count": len(batch[j].text),
                },
            )
            for j, emb in enumerate(embeddings)
        ]

        client.upsert(collection_name=collection_name, points=points)
        offset += len(batch)
        print(f"  插入 {offset}/{len(chunks)} 条")

    print(f"Collection 总数: {client.count(collection_name).count}")

# ===== 4. 向量检索 =====
def search_qdrant(query_text: str, embedding_model, top_k: int = 5):
    """检索相关文档"""
    query_vec = embedding_model.encode_query(query_text)

    results = client.search(
        collection_name=collection_name,
        query_vector=query_vec.tolist(),
        limit=top_k,
        with_payload=True,  # 返回 payload 中的字段
    )

    docs = []
    for hit in results:
        docs.append({
            "id": hit.id,
            "score": hit.score,
            "text": hit.payload.get("text", ""),
            "title": hit.payload.get("title", ""),
            "section": hit.payload.get("section", ""),
        })
    return docs

# ===== 5. 带过滤的检索 =====
def search_qdrant_with_filter(
    query_text: str,
    embedding_model,
    title_keyword: str = None,
    top_k: int = 5,
):
    """带 payload 过滤的检索"""
    query_vec = embedding_model.encode_query(query_text)

    # 构建过滤器
    query_filter = None
    if title_keyword:
        query_filter = Filter(
            must=[
                FieldCondition(
                    key="title",
                    match=MatchText(text=title_keyword),
                )
            ]
        )

    results = client.search(
        collection_name=collection_name,
        query_vector=query_vec.tolist(),
        limit=top_k,
        query_filter=query_filter,
        with_payload=True,
    )

    return [
        {"id": hit.id, "score": hit.score, "text": hit.payload.get("text", "")}
        for hit in results
    ]

# ===== 6. 管理操作 =====
# 查看 collection 信息
info = client.get_collection(collection_name)
print(f"向量数: {info.vectors_count}")
print(f"索引向量数: {info.indexed_vectors_count}")

# 更新 payload(不重新向量化)
# client.set_payload(
#     collection_name=collection_name,
#     payload={"updated_at": "2024-01-01"},
#     points=[1, 2, 3],
# )

# 删除指定 point
# client.delete(collection_name=collection_name, points_selector=[1, 2])

# 清空但不删除 collection
# client.delete(collection_name=collection_name, points_selector=None)

Chroma — 轻量级嵌入式向量数据库(适合原型) ​

python
"""Chroma 实战:零配置的向量数据库"""

import chromadb
from chromadb.utils import embedding_functions

# ===== 1. 初始化 =====
# 持久化模式
client = chromadb.PersistentClient(path="./data/chroma_db")

# 内存模式(临时)
# client = chromadb.Client()

# ===== 2. 创建 Collection =====
# 使用内置 Embedding 函数(可自动向量化)
embedding_fn = embedding_functions.SentenceTransformerEmbeddingFunction(
    model_name="BAAI/bge-small-zh-v1.5",
    device="cpu",
)

collection = client.get_or_create_collection(
    name="wiki_docs",
    metadata={"hnsw:space": "cosine"},
    embedding_function=embedding_fn,  # 可选:自动向量化
)

# ===== 3. 批量插入(Chroma 可自动向量化) =====
def insert_chunks_chroma(chunks, batch_size=100):
    """插入文档块"""
    for i in range(0, len(chunks), batch_size):
        batch = chunks[i:i + batch_size]
        collection.add(
            ids=[c.chunk_id for c in batch],
            documents=[c.text for c in batch],  # Chroma 自动向量化
            metadatas=[c.metadata for c in batch],
        )
        print(f"  插入 {min(i + batch_size, len(chunks))}/{len(chunks)}")

# ===== 4. 检索 =====
def search_chroma(query_text: str, top_k: int = 5):
    """检索相关文档"""
    results = collection.query(
        query_texts=[query_text],  # Chroma 自动向量化查询
        n_results=top_k,
        include=["documents", "metadatas", "distances"],
    )

    docs = []
    for i in range(len(results['ids'][0])):
        docs.append({
            "id": results['ids'][0][i],
            "score": 1.0 - results['distances'][0][i],  # 距离 → 相似度
            "text": results['documents'][0][i],
            "metadata": results['metadatas'][0][i],
        })
    return docs

Milvus vs Qdrant vs Chroma 选型决策树 ​

mermaid
graph TD
    START["选择向量数据库"] --> Q1{"数据量?"}
    Q1 -->|"< 10万 向量"| CHROMA["Chroma<br/>零配置,原型开发"]
    Q1 -->|"10万 - 100万"| Q2{"需要独立部署?"}
    Q1 -->|"> 100万"| MILVUS["Milvus<br/>分布式,磁盘索引"]

    Q2 -->|是| QDRANT["Qdrant<br/>Rust 高性能,过滤强"]
    Q2 -->|否| Q3{"已有 PostgreSQL?"}
    Q3 -->|是| PGVEC["PGVector<br/>SQL 原生,运维简单"]
    Q3 -->|否| QDRANT

    style CHROMA fill:#2ecc71,color:#fff
    style QDRANT fill:#f39c12,color:#fff
    style MILVUS fill:#e74c3c,color:#fff
    style PGVEC fill:#3498db,color:#fff

RAG Pipeline 性能优化清单 ​

优化项方法效果
Embedding 批量化对文档批量编码而非逐条吞吐提升 10-50x
索引预热服务启动时加载热门向量到缓存P99 延迟降低 50%
Chunk 预取检索时顺带拉取相邻 ChunkRecall +5%
查询重写LLM 先改写/扩展用户查询Recall +10-15%
结果去重基于 Chunk hash 去重节省 Token,提升质量
动态 Top-K根据查询复杂度动态调整 K平衡延迟与质量
缓存热门查询Redis 缓存查询→结果映射命中情况下延迟 <5ms

Agentic RAG — Agent 驱动的智能检索 ​

核心思想 ​

传统 RAG 是"检索→生成"的单向流水线,检索逻辑固定。Agentic RAG 将 Agent 的推理能力注入检索过程,让检索策略根据问题动态调整:

mermaid
graph LR
    subgraph "传统 RAG"
        Q1["用户问题"] --> R1["固定检索<br/>Top-K = 5"] --> G1["LLM 生成"]
    end

    subgraph "Agentic RAG"
        Q2["用户问题"] --> AGENT["Agent 决策<br/>分析问题类型"]
        AGENT -->|"需要多步推理"| MR["多轮检索<br/>先查问题定义<br/>再查相关代码"]
        AGENT -->|"简单事实"| SR["单次检索<br/>Top-K = 3"]
        AGENT -->|"需要对比"| CR["并行检索<br/>各自查询对比源"]
        MR --> GEN["LLM 综合生成"]
        SR --> GEN
        CR --> GEN
    end

    style AGENT fill:#e74c3c,color:#fff
    style GEN fill:#3498db,color:#fff

实现方案 ​

python
"""
Agentic RAG — Agent 动态决策检索策略
"""

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

class RetrievalStrategy(Enum):
    SINGLE = "single"           # 单次检索
    MULTI_HOP = "multi_hop"     # 多跳检索(上一步结果驱动下一步)
    PARALLEL = "parallel"       # 并行多路检索
    ITERATIVE = "iterative"     # 迭代检索(逐步细化)

@dataclass
class RetrievalPlan:
    """检索计划 — Agent 决策输出"""
    strategy: RetrievalStrategy
    queries: List[str]              # 单次/并行检索的查询列表
    top_k: int = 5
    reasoning: str = ""             # Agent 的决策理由
    # 多跳专用
    follow_up_queries: List[str] = None  # 多跳的后续查询模板

class AgenticRAG:
    """Agentic RAG 引擎"""

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

    async def plan_retrieval(self, question: str, history: List[Dict]) -> RetrievalPlan:
        """Agent 分析问题,输出检索计划"""
        prompt = f"""分析以下问题,制定检索计划。

问题: {question}

请按 JSON 格式输出检索计划:
{{
    "strategy": "single|multi_hop|parallel|iterative",
    "queries": ["检索查询1", "检索查询2"],
    "top_k": 5,
    "reasoning": "决策理由",
    "follow_up_queries": ["多跳后续查询模板", ...]
}}
"""
        response = await self.llm.chat(prompt, response_format="json")
        plan = json.loads(response)
        return RetrievalPlan(**plan)

    async def execute_retrieval(self, plan: RetrievalPlan) -> List[Document]:
        """执行检索计划"""
        if plan.strategy == RetrievalStrategy.SINGLE:
            return await self.retriever.search(plan.queries[0], top_k=plan.top_k)

        elif plan.strategy == RetrievalStrategy.PARALLEL:
            # 并行检索多路查询
            results = await asyncio.gather(*[
                self.retriever.search(q, top_k=plan.top_k)
                for q in plan.queries
            ])
            return self._merge_and_rerank(results)

        elif plan.strategy == RetrievalStrategy.MULTI_HOP:
            # 第一跳检索
            docs = await self.retriever.search(plan.queries[0], top_k=plan.top_k)
            # 用第一跳结果生成第二跳查询
            context = "\n".join([d.content[:200] for d in docs])
            second_query = await self._generate_followup(
                plan.follow_up_queries[0], context
            )
            # 第二跳检索
            more_docs = await self.retriever.search(second_query, top_k=plan.top_k)
            return docs + more_docs

        elif plan.strategy == RetrievalStrategy.ITERATIVE:
            all_docs = []
            for i, query in enumerate(plan.queries):
                docs = await self.retriever.search(query, top_k=plan.top_k)
                all_docs.extend(docs)
                if i < len(plan.queries) - 1:
                    # 用当前结果优化下一个查询
                    context = "\n".join([d.content[:100] for d in docs])
                    plan.queries[i + 1] = await self._refine_query(
                        plan.queries[i + 1], context
                    )
            return self._merge_and_rerank(all_docs)

    async def answer(self, question: str) -> Dict:
        """完整的 Agentic RAG 流程"""
        plan = await self.plan_retrieval(question, [])
        docs = await self.execute_retrieval(plan)
        context = "\n\n".join([d.content for d in docs])

        answer = await self.llm.chat(f"""根据以下检索到的资料回答问题。

资料:
{context}

问题: {question}

如果资料不足以回答问题,请明确说明。""")

        return {
            "answer": answer,
            "plan": plan,
            "retrieved_docs": len(docs),
        }

    def _merge_and_rerank(self, results: List[List[Document]]) -> List[Document]:
        """合并多路结果并去重排序"""
        seen = set()
        merged = []
        for docs in results:
            for d in docs:
                if d.id not in seen:
                    seen.add(d.id)
                    merged.append(d)
        return sorted(merged, key=lambda d: d.score, reverse=True)

    async def _generate_followup(self, template: str, context: str) -> str:
        """根据第一跳结果生成第二跳查询"""
        response = await self.llm.chat(
            f"根据以下上下文,将查询模板具体化: {template}\n上下文: {context}"
        )
        return response

    async def _refine_query(self, query: str, context: str) -> str:
        """根据已有结果优化查询"""
        response = await self.llm.chat(
            f"根据以下已有信息,优化搜索查询: {query}\n已有信息: {context}"
        )
        return response

Agentic RAG 的典型模式 ​

模式触发条件示例
单次检索简单事实查询"Python 的 GIL 是什么?"
多跳检索需要关联多步信息"这个 bug 是哪个 commit 引入的?先查 bug 再查 git log"
并行检索需要对比/多视角"对比 Milvus 和 Qdrant 的性能"(同时查两个)
迭代检索初始检索不充分第一次 top-5 无结果 → 改写查询 → 再检索
自适应检索问题复杂度不确定Agent 先评估问题,再决定检索深度

Graph RAG — 知识图谱增强检索 ​

问题背景 ​

传统 RAG 基于向量相似度检索,擅长语义匹配,但无法理解实体间的关系:

  • "张三的上级是谁?" → 向量检索无法关联实体关系
  • "哪个部门负责这个项目?" → 需要图谱的 BELONGS_TO 关系
  • "受影响的所有服务有哪些?" → 需要图遍历发现依赖链

Graph RAG 结合知识图谱和向量检索,补全实体关系推理能力。

架构设计 ​

mermaid
graph TD
    subgraph "Graph RAG 架构"
        INPUT["用户问题"] --> EXTRACT["实体抽取<br/>LLM 识别实体+关系"]
        EXTRACT --> VEC["向量检索<br/>语义相似文档"]
        EXTRACT --> GRAPH["图谱检索<br/>Neo4j / NebulaGraph"]

        VEC --> FUSE["结果融合<br/>去重 + 补全 + 排序"]
        GRAPH --> FUSE
        FUSE --> GEN["LLM 生成<br/>结合结构化+非结构化知识"]
    end

    style EXTRACT fill:#f39c12,color:#fff
    style GEN fill:#2ecc71,color:#fff

实现示例 ​

python
"""
Graph RAG — 结合向量检索与知识图谱
"""

from typing import List, Dict, Set, Tuple


class GraphRAG:
    """图谱增强的 RAG 引擎"""

    def __init__(self, vector_store, graph_db, llm_client):
        """
        Args:
            vector_store: 向量数据库(Milvus/Qdrant/Chroma)
            graph_db: 图数据库连接(Neo4j/NebulaGraph)
            llm_client: LLM 客户端
        """
        self.vector_store = vector_store
        self.graph = graph_db
        self.llm = llm_client

    async def retrieve(self, question: str, top_k: int = 5) -> Dict:
        """双路检索:向量 + 图谱"""
        # 1. 实体抽取
        entities = await self._extract_entities(question)
        relations = await self._extract_relations(question)

        # 2. 向量检索
        vector_docs = await self.vector_store.search(question, top_k=top_k)

        # 3. 图谱检索
        graph_results = await self._graph_retrieval(entities, relations)

        # 4. 结果融合
        fused = self._fuse_results(vector_docs, graph_results)

        return {
            "vector_docs": vector_docs,
            "graph_results": graph_results,
            "fused_docs": fused,
            "entities": entities,
            "relations": relations,
        }

    async def _extract_entities(self, question: str) -> List[Dict]:
        """LLM 提取问题中的实体"""
        prompt = f"""从以下问题中提取所有实体(人名、项目名、服务名、部门名等)。
返回 JSON 数组格式:
[{{"name": "实体名", "type": "person|project|service|department|...", "aliases": ["别名1"]}}]

问题: {question}"""
        response = await self.llm.chat(prompt, response_format="json")
        return json.loads(response)

    async def _extract_relations(self, question: str) -> List[str]:
        """LLM 提取问题中隐含的关系类型"""
        prompt = f"""分析以下问题需要查询的关系类型。
可选关系: BELONGS_TO, MANAGES, DEPENDS_ON, OWNS, PARTICIPATES_IN, REPORTS_TO
返回 JSON 数组: ["关系名1", "关系名2"]

问题: {question}"""
        response = await self.llm.chat(prompt, response_format="json")
        return json.loads(response)

    async def _graph_retrieval(
        self, entities: List[Dict], relations: List[str]
    ) -> List[Dict]:
        """在图数据库中检索实体及其关系"""
        results = []

        for entity in entities:
            name = entity["name"]
            etype = entity["type"]

            # Cypher 查询:获取实体及其 1-hop 邻居
            query = """
            MATCH (n {name: $name})
            OPTIONAL MATCH (n)-[r]->(m)
            WHERE $relations IS NULL OR type(r) IN $relations
            RETURN n, r, m
            LIMIT 20
            """
            records = await self.graph.run(
                query,
                name=name,
                relations=relations if relations else None
            )
            for record in records:
                results.append({
                    "entity": name,
                    "entity_type": etype,
                    "relation": record.get("r"),
                    "neighbor": record.get("m"),
                })

        # 去重
        seen = set()
        unique = []
        for r in results:
            key = f"{r['entity']}-{r.get('relation')}-{r.get('neighbor')}"
            if key not in seen:
                seen.add(key)
                unique.append(r)

        return unique

    def _fuse_results(
        self, vector_docs: List[Document], graph_results: List[Dict]
    ) -> List[Document]:
        """融合向量检索和图谱检索结果"""
        # 将图谱关系转为文本块
        graph_chunks = []
        for item in graph_results:
            text = f"实体 {item['entity']} ({item['entity_type']})"
            if item.get("relation"):
                text += f" → [{item['relation']}] → {item.get('neighbor')}"
            graph_chunks.append(Document(
                id=f"graph_{item['entity']}",
                content=text,
                source="knowledge_graph",
                score=0.9,  # 图谱结果置信度高
            ))

        # 合并、去重、按分数排序
        merged = vector_docs + graph_chunks
        seen_ids = set()
        fused = []
        for doc in sorted(merged, key=lambda d: d.score, reverse=True):
            if doc.id not in seen_ids:
                seen_ids.add(doc.id)
                fused.append(doc)

        return fused

    async def answer(self, question: str) -> str:
        """完整的 Graph RAG 问答"""
        retrieval_result = await self.retrieve(question)

        # 构建上下文
        context_parts = []

        # 图谱信息
        if retrieval_result["graph_results"]:
            context_parts.append("## 知识图谱关系")
            for item in retrieval_result["graph_results"]:
                context_parts.append(
                    f"- {item['entity']} [{item.get('relation', '相关')}] "
                    f"{item.get('neighbor', '')}"
                )

        # 向量检索的文档
        context_parts.append("## 相关文档")
        for doc in retrieval_result["fused_docs"][:5]:
            context_parts.append(doc.content)

        context = "\n\n".join(context_parts)

        answer = await self.llm.chat(
            f"根据以下信息回答问题,如果涉及实体关系请明确指出:\n\n{context}\n\n问题: {question}"
        )
        return answer

Graph RAG vs Agentic RAG 对比 ​

维度Graph RAGAgentic RAG
核心能力实体关系推理动态检索策略
适合场景组织架构、依赖链、权限查询复杂多步问题、需探索性检索
数据要求需要构建知识图谱向量数据库即可
实现复杂度高(需维护图数据库)中
典型应用企业知识库、运维故障定位客服问答、代码审查、研究助手

最佳实践:两者可以结合使用 — Agent 动态决策何时用图谱检索、何时用向量检索,实现自适应混合 RAG。

批注模式

💬 文章评论

暂无评论,来说点什么吧 👇

编程学习笔记