第四章:RAG知识库开发之【深入浅出 Naive RAG:从零构建你的第一个检索增强生成系统】


目录

前言

第一部分:知识库搭建

[1.1 文档加载](#1.1 文档加载)

[1.1.1 加载 PDF 文件](#1.1.1 加载 PDF 文件)

[1.1.2 加载 Markdown 与文本文件](#1.1.2 加载 Markdown 与文本文件)

[1.1.3 加载 Word 文档](#1.1.3 加载 Word 文档)

[1.2 文档分块](#1.2 文档分块)

[1.2.1 基础分块:按字符数分割](#1.2.1 基础分块:按字符数分割)

[1.2.2 高级分块:基于语义边界的分割](#1.2.2 高级分块:基于语义边界的分割)

[1.2.3 语义分块:基于嵌入相似度的高级分割](#1.2.3 语义分块:基于嵌入相似度的高级分割)

[1.2.4 分块策略总结](#1.2.4 分块策略总结)

第二部分:Embedding(嵌入)

[2.1 向量定义与向量化的意义](#2.1 向量定义与向量化的意义)

[2.2 向量相似算法](#2.2 向量相似算法)

[2.2.1 余弦相似度](#2.2.1 余弦相似度)

[2.2.2 欧氏距离](#2.2.2 欧氏距离)

[2.2.3 点积](#2.2.3 点积)

[2.3 向量化实践](#2.3 向量化实践)

[2.3.1 使用开源模型进行向量化](#2.3.1 使用开源模型进行向量化)

[2.3.2 批量处理分块后的文档](#2.3.2 批量处理分块后的文档)

第三部分:Indexing(索引)

[3.1 top-k 语义检索原理](#3.1 top-k 语义检索原理)

[3.1.1 暴力检索(Brute Force)](#3.1.1 暴力检索(Brute Force))

[3.1.2 使用 FAISS 建立高效索引](#3.1.2 使用 FAISS 建立高效索引)

[3.2 索引优化:从 Flat 到 IVF](#3.2 索引优化:从 Flat 到 IVF)

第四部分:向量存储

[4.1 向量数据库的介绍](#4.1 向量数据库的介绍)

[4.2 主流向量数据库与功能对比](#4.2 主流向量数据库与功能对比)

[4.3 使用 Chroma 作为向量存储](#4.3 使用 Chroma 作为向量存储)

[4.4 使用 FAISS 作为本地向量存储](#4.4 使用 FAISS 作为本地向量存储)

[第五部分:提示词增强 RAG 设计](#第五部分:提示词增强 RAG 设计)

[5.1 基础 RAG 提示词模板](#5.1 基础 RAG 提示词模板)

[5.2 高级提示词设计](#5.2 高级提示词设计)

[5.3 调用大模型生成答案](#5.3 调用大模型生成答案)

[5.4 完整 RAG 流程整合](#5.4 完整 RAG 流程整合)

第六部分:本章练习题与解答

[6.1 选择题](#6.1 选择题)

[6.2 填空题](#6.2 填空题)

[6.3 简答题](#6.3 简答题)

[6.4 实操题](#6.4 实操题)

总结


前言

在人工智能与大语言模型飞速发展的今天,我们经常会遇到这样的场景:向 ChatGPT 询问一个关于公司内部产品文档的问题,模型要么回答"我不知道",要么给出一个过时甚至错误的答案。这是因为通用大语言模型(LLM)的训练数据存在截止日期,且不包含企业的私有数据。

检索增强生成(Retrieval-Augmented Generation, RAG)技术应运而生。它就像为模型配备了一个可以实时查阅的"外挂知识库"。当用户提问时,RAG 系统首先从知识库中检索出最相关的文档片段,然后将这些片段连同原始问题一起提交给 LLM,让模型基于这些"参考资料"生成准确、可靠的答案。

本文将带领读者从零开始,深入浅出地构建一个基础的 RAG 系统------通常被称为"Naive RAG"。之所以称之为"Naive",是因为它遵循最经典、最直接的流程:索引 → 检索 → 生成。虽然目前业界已有高级 RAG(如 Agentic RAG、Graph RAG 等),但掌握 Naive RAG 是理解一切复杂变体的基石。

本文面向初级到中级的技术爱好者、后端/前端/运维工程师以及在校学生。无论你是想在自己的项目中落地 RAG,还是希望深入理解其原理,本文都将提供一份详尽、可运行的实践指南。我们将涵盖从知识库搭建、文本向量化、向量存储到最终的提示词增强生成的全流程,并提供完整的代码示例与课后练习。

让我们开始这段充满挑战与收获的旅程,亲手打造一个能够回答你私有文档问题的智能问答系统!


第一部分:知识库搭建

在 RAG 系统中,知识库是基石。如果知识库中的数据质量差、分块不合理,后续的检索和生成效果将大打折扣。本部分我们将重点解决两个核心问题:如何将各类文档加载进系统,以及如何将长文档切分为适合检索与生成的语义块。

1.1 文档加载

现实世界中的文档格式五花八门:PDF、Word、Markdown、纯文本、HTML 网页等。一个健壮的 RAG 系统需要支持多种格式的文档加载器。在 Python 生态中,LangChainLlamaIndex 是两个最流行的框架,它们封装了各类文档加载器,极大简化了开发工作。

我们将以 LangChain 为例,演示如何加载常见的文档格式。首先,安装必要的依赖库:

bash 复制代码
pip install langchain langchain-community pypdf python-docx unstructured[all] chromadb
1.1.1 加载 PDF 文件

PDF 是文档存储的常见格式,但提取其中的文本可能充满挑战(如表格、多列布局)。pypdf 是一个轻量级的 PDF 文本提取库。

python 复制代码
# pdf_loader.py
from langchain_community.document_loaders import PyPDFLoader
from langchain.schema import Document
import os
from typing import List

def load_pdf(file_path: str) -> List[Document]:
    """
    加载单个 PDF 文件,返回 LangChain Document 对象列表
    
    Args:
        file_path (str): PDF 文件的路径
        
    Returns:
        List[Document]: 文档对象列表,每个元素代表 PDF 中的一页
    """
    # 初始化 PyPDFLoader,它会自动按页解析 PDF
    loader = PyPDFLoader(file_path)
    # load() 方法返回一个 Document 列表,每个 Document 包含 page_content 和 metadata
    documents = loader.load()
    
    # 为每个文档添加元数据,便于后续追踪来源
    for doc in documents:
        doc.metadata.update({
            "source": file_path,
            "type": "pdf"
        })
    
    print(f"成功加载 PDF 文件: {file_path},共 {len(documents)} 页")
    return documents

# 示例用法
if __name__ == "__main__":
    # 假设我们有一个名为 "example.pdf" 的文件
    # docs = load_pdf("example.pdf")
    # print(f"第一页内容预览: {docs[0].page_content[:200]}")
    pass
1.1.2 加载 Markdown 与文本文件

Markdown 是技术文档的常用格式,而纯文本则是最简单的载体。LangChain 提供了 TextLoader 可以处理这类文件。

python 复制代码
# text_md_loader.py
from langchain_community.document_loaders import TextLoader
from langchain.schema import Document
from typing import List
import os

def load_text_or_md(file_path: str) -> List[Document]:
    """
    加载文本或 Markdown 文件
    
    Args:
        file_path (str): 文件路径,支持 .txt, .md 等
        
    Returns:
        List[Document]: 文档对象列表,通常包含一个元素(整个文件内容)
    """
    # 使用 TextLoader,它会读取整个文件内容作为一个 Document
    # 注意:TextLoader 默认使用 UTF-8 编码,若文件为其他编码需指定 encoding 参数
    loader = TextLoader(file_path, encoding='utf-8')
    documents = loader.load()
    
    # 添加元数据
    for doc in documents:
        doc.metadata.update({
            "source": file_path,
            "type": "text"
        })
    
    print(f"成功加载文本文件: {file_path}")
    return documents

# 如果需要加载目录下的所有 Markdown 文件,可以编写一个批量加载函数
def load_markdowns_from_directory(directory_path: str) -> List[Document]:
    """
    批量加载目录下的所有 .md 文件
    
    Args:
        directory_path (str): 目录路径
        
    Returns:
        List[Document]: 所有文档的合并列表
    """
    all_docs = []
    for root, dirs, files in os.walk(directory_path):
        for file in files:
            if file.endswith('.md') or file.endswith('.txt'):
                file_path = os.path.join(root, file)
                docs = load_text_or_md(file_path)
                all_docs.extend(docs)
    print(f"批量加载完成,共加载 {len(all_docs)} 个文档片段")
    return all_docs
1.1.3 加载 Word 文档

Word 文档(.docx)在办公场景中非常常见。我们可以使用 python-docx 库来解析。

python 复制代码
# word_loader.py
from langchain.schema import Document
from typing import List
import docx

def load_docx(file_path: str) -> List[Document]:
    """
    加载 .docx 文件,提取所有段落文本
    
    Args:
        file_path (str): Word 文档路径
        
    Returns:
        List[Document]: 包含整个文档内容的 Document 列表
    """
    try:
        # 打开 Word 文档
        doc = docx.Document(file_path)
        # 提取所有段落的文本,用换行符连接
        full_text = "\n".join([paragraph.text for paragraph in doc.paragraphs])
        
        # 封装为 LangChain Document
        document = Document(
            page_content=full_text,
            metadata={
                "source": file_path,
                "type": "docx"
            }
        )
        print(f"成功加载 Word 文档: {file_path}")
        return [document]
    except Exception as e:
        print(f"加载 Word 文档失败: {file_path}, 错误: {e}")
        return []

# 如果需要提取表格内容,可以扩展上述函数
def load_docx_with_tables(file_path: str) -> List[Document]:
    """
    加载 .docx 文件,包含段落和表格内容
    """
    doc = docx.Document(file_path)
    content_parts = []
    
    # 提取段落
    for para in doc.paragraphs:
        if para.text.strip():
            content_parts.append(para.text)
    
    # 提取表格
    for table in doc.tables:
        for row in table.rows:
            row_text = "\t".join([cell.text for cell in row.cells])
            if row_text.strip():
                content_parts.append(row_text)
    
    full_text = "\n".join(content_parts)
    return [Document(page_content=full_text, metadata={"source": file_path, "type": "docx"})]

1.2 文档分块

文档加载完成后,我们得到了一系列较长的文本(可能是整本书、整份报告)。如果直接将这些长文本嵌入并检索,会带来几个问题:

  1. 检索精度低:一个包含太多主题的块,当用户查询某个具体细节时,由于语义相似度可能不够聚焦,导致检索结果不准确。

  2. LLM 上下文限制:大模型都有输入 token 数限制(如 GPT-3.5 为 4K/16K tokens)。过长的块无法全部放入提示词中。

  3. 成本与性能:嵌入更长的文本意味着更高的计算成本,且向量维度不变的情况下,长文本的语义信息会被稀释。

因此,我们需要将文档切分成语义连贯的"块"(Chunks)。分块策略的选择直接影响 RAG 系统的最终效果。

1.2.1 基础分块:按字符数分割

最简单的分块方式是按固定长度切割。虽然粗暴,但在某些场景下足够用。

python 复制代码
# simple_chunking.py
from langchain.text_splitter import CharacterTextSplitter
from langchain.schema import Document
from typing import List

def simple_character_chunking(documents: List[Document], chunk_size: int = 500, chunk_overlap: int = 50) -> List[Document]:
    """
    使用 CharacterTextSplitter 进行简单分块
    
    Args:
        documents: 原始文档列表
        chunk_size: 每个块的最大字符数
        chunk_overlap: 块之间的重叠字符数,用于保持上下文连贯
        
    Returns:
        分块后的 Document 列表
    """
    # 初始化分割器
    text_splitter = CharacterTextSplitter(
        separator="\n",           # 优先按换行符分割
        chunk_size=chunk_size,
        chunk_overlap=chunk_overlap,
        length_function=len,      # 使用字符数计算长度
        is_separator_regex=False
    )
    
    # 执行分块
    chunks = text_splitter.split_documents(documents)
    
    # 为每个块添加块索引元数据
    for i, chunk in enumerate(chunks):
        chunk.metadata['chunk_index'] = i
        chunk.metadata['chunk_size'] = len(chunk.page_content)
    
    print(f"简单分块完成:{len(documents)} 个文档 -> {len(chunks)} 个块")
    return chunks
1.2.2 高级分块:基于语义边界的分割

按字符分割可能会在句子中间切断,破坏语义完整性。更优的做法是使用递归分割器,它尝试优先按段落、句子、单词等层次进行分割,直到块的大小满足要求。

python 复制代码
# recursive_chunking.py
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.schema import Document
from typing import List

def recursive_character_chunking(documents: List[Document], chunk_size: int = 500, chunk_overlap: int = 50) -> List[Document]:
    """
    使用 RecursiveCharacterTextSplitter 进行递归分割
    该分割器会尝试按照 ["\n\n", "\n", " ", ""] 的顺序逐步降级分割,
    尽可能保持段落、句子完整
    """
    text_splitter = RecursiveCharacterTextSplitter(
        chunk_size=chunk_size,
        chunk_overlap=chunk_overlap,
        length_function=len,
        separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""]  # 支持中英文分隔符
    )
    
    chunks = text_splitter.split_documents(documents)
    
    for i, chunk in enumerate(chunks):
        chunk.metadata['chunk_index'] = i
        chunk.metadata['chunk_size'] = len(chunk.page_content)
    
    print(f"递归分割完成:{len(documents)} 个文档 -> {len(chunks)} 个块")
    return chunks
1.2.3 语义分块:基于嵌入相似度的高级分割

在 Naive RAG 之上,还有更高级的语义分块方法:先按句子分割,然后计算相邻句子之间的嵌入相似度,当相似度发生显著变化时,在此处切分。这样可以保证每个块内部的语义高度相关。

python 复制代码
# semantic_chunking.py
from langchain.embeddings import OpenAIEmbeddings
from langchain.text_splitter import SentenceTransformersTokenTextSplitter
import numpy as np
from typing import List, Tuple
from langchain.schema import Document

def semantic_chunking(documents: List[Document], similarity_threshold: float = 0.7) -> List[Document]:
    """
    基于语义相似度的分块
    注意:此方法需要调用 Embedding 模型,开销较大,适合对质量要求极高的场景
    
    Args:
        documents: 原始文档列表
        similarity_threshold: 相似度阈值,低于此值则切分
        
    Returns:
        语义分块后的 Document 列表
    """
    # 这里假设我们已经有 OpenAI Embeddings,实际应用中可使用开源模型
    from langchain_openai import OpenAIEmbeddings
    embeddings = OpenAIEmbeddings()
    
    all_chunks = []
    
    for doc in documents:
        # 1. 按句子分割
        sentences = doc.page_content.split('。')  # 简单中文分句,生产环境请使用更精确的分句器
        sentences = [s.strip() for s in sentences if s.strip()]
        
        if len(sentences) <= 1:
            # 如果只有一个句子,直接作为一个块
            all_chunks.append(Document(page_content=doc.page_content, metadata=doc.metadata))
            continue
        
        # 2. 获取所有句子的 Embedding
        sentence_embeddings = embeddings.embed_documents(sentences)
        
        # 3. 计算相邻句子相似度
        similarities = []
        for i in range(len(sentence_embeddings) - 1):
            sim = np.dot(sentence_embeddings[i], sentence_embeddings[i+1]) / (
                np.linalg.norm(sentence_embeddings[i]) * np.linalg.norm(sentence_embeddings[i+1])
            )
            similarities.append(sim)
        
        # 4. 根据相似度决定切分点
        current_chunk = []
        current_chunk.append(sentences[0])
        
        for i in range(1, len(sentences)):
            # 如果当前句子与前一句的相似度低于阈值,则结束当前块
            if similarities[i-1] < similarity_threshold:
                # 完成当前块
                chunk_text = '。'.join(current_chunk) + '。'
                chunk_doc = Document(
                    page_content=chunk_text,
                    metadata={**doc.metadata, 'chunk_type': 'semantic'}
                )
                all_chunks.append(chunk_doc)
                # 开始新块
                current_chunk = [sentences[i]]
            else:
                current_chunk.append(sentences[i])
        
        # 添加最后一个块
        if current_chunk:
            chunk_text = '。'.join(current_chunk) + '。'
            all_chunks.append(Document(page_content=chunk_text, metadata={**doc.metadata, 'chunk_type': 'semantic'}))
    
    print(f"语义分块完成:原始 {len(documents)} 个文档 -> {len(all_chunks)} 个块")
    return all_chunks
1.2.4 分块策略总结
分块方法 优点 缺点 适用场景
固定字符分割 简单、快速、可控 可能切断语义单元 日志、代码等结构化文本
递归分割 保留段落/句子完整性 仍可能切断语义边界 通用文档,平衡质量与效率
语义分块 块内语义高度内聚 计算开销大,依赖 Embedding 高质量问答系统、学术论文

在实际项目中,通常需要根据文档类型和业务场景进行多次实验,找到最优的分块大小(通常 300-1000 tokens 之间)和重叠比例(一般 10%-20%)。


第二部分:Embedding(嵌入)

在 RAG 系统中,Embedding 是连接人类语言与机器计算的桥梁。它将文本片段转换为高维空间中的向量,使得我们可以通过数学运算(如余弦相似度)来度量文本之间的语义相似性。

2.1 向量定义与向量化的意义

什么是向量?

在数学上,向量是一组有序的数字。在自然语言处理中,一个词、一个句子甚至一篇文档都可以被表示为一个固定维度的向量。例如,OpenAI 的 text-embedding-ada-002 模型将文本映射到 1536 维的向量空间。

为什么需要向量化?

  1. 语义计算:两个文本的相似度可以通过计算它们向量之间的夹角(余弦相似度)来获得。向量越接近,文本语义越相似。

  2. 高效检索:向量数据库支持在高维空间中快速进行最近邻搜索(ANN),从而在海量文档中快速找到最相关的片段。

  3. 跨模态统一:理论上,图像、音频都可以映射到同一向量空间,实现多模态检索。

2.2 向量相似算法

在向量检索中,最常用的三种距离/相似度算法是:

2.2.1 余弦相似度

余弦相似度衡量两个向量方向上的相似性,忽略向量长度的影响。计算公式:

取值范围在 [-1, 1] 之间,1 表示方向完全相同,0 表示正交,-1 表示完全相反。在文本 Embedding 中,通常结果在 0~1 之间。

适用场景:文本 Embedding 通常被归一化,因此余弦相似度与点积等价,是语义检索最常用的指标。

2.2.2 欧氏距离

欧氏距离衡量向量在空间中的绝对距离。公式:

距离越小,表示向量越接近。如果 Embedding 经过了归一化,欧氏距离与余弦相似度存在转换关系。

适用场景:当向量长度本身具有意义时(如图像特征),欧氏距离更合适。

2.2.3 点积

点积直接计算两个向量的对应分量乘积之和。在向量归一化后,点积等价于余弦相似度。公式:

适用场景:许多向量数据库默认使用点积作为相似度度量,因为它计算速度快。

2.3 向量化实践

我们将使用开源 Embedding 模型 BAAI/bge-small-zh 进行向量化实践。该模型在中文任务上表现优秀,且对硬件要求较低。我们也可以使用 OpenAI 的 Embedding API,但需要 API Key。

首先安装必要的库:

python 复制代码
pip install sentence-transformers torch
2.3.1 使用开源模型进行向量化
python 复制代码
# embedding_demo.py
from sentence_transformers import SentenceTransformer
import numpy as np
from typing import List, Union
import time

class LocalEmbedding:
    """
    封装本地 Embedding 模型,用于将文本转换为向量
    """
    
    def __init__(self, model_name: str = "BAAI/bge-small-zh"):
        """
        初始化 Embedding 模型
        
        Args:
            model_name: HuggingFace 模型名称,可选:
                - BAAI/bge-small-zh (384维,快速)
                - BAAI/bge-base-zh (768维,平衡)
                - BAAI/bge-large-zh (1024维,高精度)
        """
        print(f"正在加载 Embedding 模型: {model_name}")
        self.model = SentenceTransformer(model_name)
        self.dimension = self.model.get_sentence_embedding_dimension()
        print(f"模型加载完成,向量维度: {self.dimension}")
    
    def embed_text(self, text: Union[str, List[str]]) -> np.ndarray:
        """
        将单个文本或文本列表转换为向量
        
        Args:
            text: 单个字符串或字符串列表
            
        Returns:
            numpy 数组,shape = (n, dimension)
        """
        if isinstance(text, str):
            text = [text]
        
        # 计算 Embedding
        embeddings = self.model.encode(text, normalize_embeddings=True)  # 归一化,便于余弦相似度计算
        return embeddings
    
    def compute_similarity(self, text1: str, text2: str) -> float:
        """
        计算两个文本的余弦相似度
        
        Returns:
            相似度得分,范围 0~1
        """
        vec1 = self.embed_text(text1)[0]
        vec2 = self.embed_text(text2)[0]
        # 因为已经归一化,点积即为余弦相似度
        similarity = np.dot(vec1, vec2)
        return float(similarity)

# 示例用法
if __name__ == "__main__":
    # 初始化模型
    embedder = LocalEmbedding("BAAI/bge-small-zh")
    
    # 测试文本
    texts = [
        "我喜欢吃苹果,苹果是一种水果。",
        "香蕉是黄色的,也是一种常见的水果。",
        "今天天气真好,适合出去跑步。",
        "深度学习是机器学习的一个分支。"
    ]
    
    # 批量获取向量
    start_time = time.time()
    vectors = embedder.embed_text(texts)
    elapsed = time.time() - start_time
    
    print(f"向量化完成,耗时 {elapsed:.2f} 秒")
    print(f"向量形状: {vectors.shape}")
    print(f"第一个向量前10个维度: {vectors[0][:10]}")
    
    # 计算相似度矩阵
    print("\n相似度矩阵:")
    for i in range(len(texts)):
        for j in range(i+1, len(texts)):
            sim = embedder.compute_similarity(texts[i], texts[j])
            print(f"'{texts[i][:20]}...' vs '{texts[j][:20]}...': {sim:.4f}")
2.3.2 批量处理分块后的文档

在实际 RAG 系统中,我们需要对知识库中的所有文档块进行向量化。

python 复制代码
# batch_embedding.py
from typing import List
from langchain.schema import Document
import numpy as np

def embed_documents(
    chunks: List[Document], 
    embedder: LocalEmbedding,
    batch_size: int = 32
) -> List[np.ndarray]:
    """
    批量对文档块进行向量化
    
    Args:
        chunks: 文档块列表
        embedder: 已初始化的 Embedding 实例
        batch_size: 批处理大小,避免内存溢出
        
    Returns:
        向量列表,每个元素对应一个块的向量
    """
    texts = [chunk.page_content for chunk in chunks]
    all_embeddings = []
    
    # 分批处理
    for i in range(0, len(texts), batch_size):
        batch_texts = texts[i:i+batch_size]
        batch_embeddings = embedder.embed_text(batch_texts)
        all_embeddings.extend(batch_embeddings)
        print(f"已处理 {min(i+batch_size, len(texts))}/{len(texts)} 个块")
    
    # 为每个块添加向量到元数据(可选,通常向量会单独存储)
    for chunk, embedding in zip(chunks, all_embeddings):
        chunk.metadata['embedding_dim'] = len(embedding)
        # 注意:通常不建议将向量存在 metadata 中,会占用大量内存
    
    print(f"向量化完成,共 {len(all_embeddings)} 个向量")
    return all_embeddings

第三部分:Indexing(索引)

当我们拥有了所有文档块的向量后,如何快速找到与用户问题最相关的几个块?这就涉及到索引(Indexing)和检索(Retrieval)技术。索引的目的是构建一种数据结构,使得我们能够在对数时间复杂度内完成近似最近邻(Approximate Nearest Neighbor, ANN)搜索。

3.1 top-k 语义检索原理

语义检索的流程如下:

  1. 用户输入查询:例如"什么是向量数据库?"

  2. 查询向量化 :使用与知识库相同的 Embedding 模型将查询转换为向量 q

  3. 相似度计算 :计算 q 与知识库中所有向量 v_i 的相似度(余弦、欧氏距离等)。

  4. Top-k 选取:选出相似度最高的 k 个文档块(k 通常为 3-10)。

  5. 返回结果:将这些块的文本内容作为上下文返回。

对于海量数据(如百万级文档),逐一遍历所有向量计算相似度是不可行的(时间复杂度 O(N))。因此,我们需要建立索引来加速检索。

3.1.1 暴力检索(Brute Force)

暴力检索是最简单直接的方法:计算查询向量与所有文档向量的相似度,然后排序。虽然准确率最高,但效率低下,仅适用于小规模数据集(< 1万条)。

python 复制代码
# brute_force_search.py
import numpy as np
from typing import List, Tuple
from langchain.schema import Document

def brute_force_search(
    query_vector: np.ndarray,
    embeddings: List[np.ndarray],
    chunks: List[Document],
    top_k: int = 5
) -> List[Tuple[Document, float]]:
    """
    暴力检索:计算查询向量与所有向量的余弦相似度,返回 top-k 结果
    
    Args:
        query_vector: 查询向量
        embeddings: 所有文档块向量列表
        chunks: 对应的文档块列表
        top_k: 返回结果数量
        
    Returns:
        List of (Document, similarity_score) 按相似度降序排列
    """
    similarities = []
    
    # 计算每个向量与查询向量的相似度(已归一化,直接点积)
    for emb, chunk in zip(embeddings, chunks):
        sim = np.dot(query_vector, emb)
        similarities.append((chunk, sim))
    
    # 按相似度降序排序
    similarities.sort(key=lambda x: x[1], reverse=True)
    
    return similarities[:top_k]
3.1.2 使用 FAISS 建立高效索引

FAISS(Facebook AI Similarity Search)是 Meta 开源的高效相似度搜索库,支持 GPU 加速,是工业界最常用的向量索引工具之一。

python 复制代码
# faiss_indexing.py
import faiss
import numpy as np
from typing import List, Tuple
from langchain.schema import Document

class FAISSIndex:
    """
    封装 FAISS 向量索引,支持高效的 top-k 检索
    """
    
    def __init__(self, dimension: int):
        self.dimension = dimension
        self.index = None
        self.chunks = []  # 存储对应的文档块
        self.embeddings = []  # 存储向量,便于重建索引
    
    def build_index(self, embeddings: List[np.ndarray], chunks: List[Document], use_gpu: bool = False):
        """
        构建 FAISS 索引
        
        Args:
            embeddings: 向量列表,每个元素为 numpy 数组
            chunks: 对应的文档块列表
            use_gpu: 是否使用 GPU(需要安装 faiss-gpu)
        """
        self.chunks = chunks
        self.embeddings = embeddings
        
        # 将向量列表转换为 float32 类型的 numpy 数组,shape = (n, dimension)
        vectors = np.array(embeddings).astype('float32')
        
        # 创建索引:这里使用 IndexFlatIP(内积索引,适用于归一化向量的余弦相似度)
        # 注意:由于我们的向量已经归一化,内积等价于余弦相似度
        self.index = faiss.IndexFlatIP(self.dimension)
        
        # 添加向量到索引
        self.index.add(vectors)
        
        # 可选:使用 GPU(需要 faiss-gpu)
        if use_gpu:
            try:
                res = faiss.StandardGpuResources()
                self.index = faiss.index_cpu_to_gpu(res, 0, self.index)
                print("已启用 GPU 加速")
            except Exception as e:
                print(f"GPU 启用失败,继续使用 CPU: {e}")
        
        print(f"索引构建完成,共 {self.index.ntotal} 个向量")
    
    def search(self, query_vector: np.ndarray, top_k: int = 5) -> List[Tuple[Document, float]]:
        """
        检索最相似的 top-k 个文档块
        
        Args:
            query_vector: 查询向量,shape = (dimension,)
            top_k: 返回结果数量
            
        Returns:
            List of (Document, similarity_score)
        """
        # 确保向量维度正确
        query = np.array([query_vector]).astype('float32')
        
        # 执行搜索
        # similarities: 相似度得分数组,shape = (1, top_k)
        # indices: 索引数组,shape = (1, top_k)
        similarities, indices = self.index.search(query, top_k)
        
        results = []
        for i in range(top_k):
            idx = indices[0][i]
            if idx != -1:  # -1 表示无效索引
                results.append((self.chunks[idx], float(similarities[0][i])))
        
        return results
    
    def add_documents(self, embeddings: List[np.ndarray], chunks: List[Document]):
        """
        动态添加新的文档块(适用于增量更新)
        """
        vectors = np.array(embeddings).astype('float32')
        self.index.add(vectors)
        self.chunks.extend(chunks)
        self.embeddings.extend(embeddings)
        print(f"已添加 {len(embeddings)} 个新文档,当前总数: {self.index.ntotal}")

# 示例:完整检索流程
def semantic_search_pipeline(query: str, faiss_index: FAISSIndex, embedder: LocalEmbedding, top_k: int = 5):
    """
    完整的语义检索流程
    """
    # 1. 查询向量化
    query_vector = embedder.embed_text(query)[0]
    
    # 2. 检索
    results = faiss_index.search(query_vector, top_k)
    
    # 3. 格式化输出
    print(f"查询: {query}\n")
    for i, (doc, score) in enumerate(results):
        print(f"结果 {i+1} (相似度: {score:.4f}):")
        print(f"来源: {doc.metadata.get('source', 'unknown')}")
        print(f"内容: {doc.page_content[:200]}...")
        print("-" * 50)
    
    return results

3.2 索引优化:从 Flat 到 IVF

IndexFlatIP 是精确检索,即暴力搜索。对于大规模数据,我们需要使用近似索引,如 IndexIVFFlat(倒排文件索引),它通过聚类将向量空间划分为多个区域,检索时只搜索与查询向量最接近的几个区域,大幅提升速度。

python 复制代码
# advanced_faiss_index.py
def build_ivf_index(embeddings: List[np.ndarray], dimension: int, nlist: int = 100) -> faiss.Index:
    """
    构建 IVF 索引(倒排文件索引)
    
    Args:
        embeddings: 向量列表
        dimension: 向量维度
        nlist: 聚类中心数量,通常设置为 sqrt(N) 或 4*sqrt(N)
        
    Returns:
        FAISS IVF 索引
    """
    vectors = np.array(embeddings).astype('float32')
    n = len(vectors)
    
    # 创建量化器(用于计算距离)
    quantizer = faiss.IndexFlatIP(dimension)
    
    # 创建 IVF 索引,nlist 为聚类数量,这里使用内积
    index = faiss.IndexIVFFlat(quantizer, dimension, nlist, faiss.METRIC_INNER_PRODUCT)
    
    # 训练索引(必须)
    print("正在训练 IVF 索引...")
    index.train(vectors)
    
    # 添加向量
    index.add(vectors)
    
    # 设置检索时搜索的聚类数量(nprobe),值越大越精确,但速度越慢
    index.nprobe = 10
    
    print(f"IVF 索引构建完成,共 {index.ntotal} 个向量,聚类中心数: {nlist}")
    return index

第四部分:向量存储

向量存储(Vector Store)是专门用于存储和检索向量数据的数据库系统。与传统数据库不同,向量数据库针对高维向量的相似度搜索进行了优化,支持高效的 ANN 算法。

4.1 向量数据库的介绍

向量数据库的核心功能包括:

  1. 存储向量:支持高维向量的持久化存储。

  2. 相似度检索:提供高效的 top-k 相似度搜索。

  3. 元数据过滤:支持根据来源、时间等元数据过滤后再检索。

  4. 混合检索:结合关键词检索(BM25)与向量检索,提升召回率。

  5. 可扩展性:支持分布式部署,处理亿级向量。

4.2 主流向量数据库与功能对比

目前市面上主流的向量数据库有:

数据库名称 类型 优势 缺点 适用场景
Chroma 嵌入式/客户端-服务器 轻量、易用、Python 原生、与 LangChain 集成好 不支持分布式 原型开发、小规模应用
Pinecone 云服务 全托管、高可用、功能强大 需要付费、数据隐私问题 企业级生产环境
Weaviate 开源/云 支持混合检索、GraphQL API、模块化 部署相对复杂 需要混合检索的场景
Qdrant 开源/云 高性能、支持过滤、Rust 编写 社区相对较小 高性能需求场景
Milvus 开源/云 分布式、功能最全、支持 GPU 运维成本高 超大规模数据集
Faiss 库(非数据库) 极致性能、灵活 需要自己管理持久化 需要嵌入到现有系统

4.3 使用 Chroma 作为向量存储

Chroma 是最适合入门学习的向量数据库,它提供简洁的 API,并完美集成 LangChain。

python 复制代码
# chroma_demo.py
from langchain_community.vectorstores import Chroma
from langchain.schema import Document
from typing import List
import numpy as np

class ChromaVectorStore:
    """
    封装 Chroma 向量数据库,实现持久化存储与检索
    """
    
    def __init__(self, embedding_model: LocalEmbedding, persist_directory: str = "./chroma_db"):
        """
        初始化 Chroma 向量存储
        
        Args:
            embedding_model: 自定义的 Embedding 模型(需要实现 embed_documents 和 embed_query 方法)
            persist_directory: 数据持久化目录
        """
        self.embedding_model = embedding_model
        self.persist_directory = persist_directory
        self.vectorstore = None
        
        # 将自定义 Embedding 模型适配为 LangChain 的 Embeddings 接口
        class EmbeddingAdapter:
            def __init__(self, model):
                self.model = model
            
            def embed_documents(self, texts: List[str]) -> List[List[float]]:
                embeddings = self.model.embed_text(texts)
                return embeddings.tolist()
            
            def embed_query(self, text: str) -> List[float]:
                embedding = self.model.embed_text(text)
                return embedding[0].tolist()
        
        self.adapter = EmbeddingAdapter(embedding_model)
    
    def create_from_documents(self, documents: List[Document]):
        """
        从文档列表创建向量数据库
        """
        self.vectorstore = Chroma.from_documents(
            documents=documents,
            embedding=self.adapter,
            persist_directory=self.persist_directory
        )
        # 持久化到磁盘
        self.vectorstore.persist()
        print(f"向量数据库创建成功,存储路径: {self.persist_directory}")
        print(f"共存储 {self.vectorstore._collection.count()} 个向量")
    
    def load_existing(self):
        """
        加载已有的向量数据库
        """
        self.vectorstore = Chroma(
            embedding_function=self.adapter,
            persist_directory=self.persist_directory
        )
        print(f"加载已有向量数据库,共 {self.vectorstore._collection.count()} 个向量")
    
    def similarity_search(self, query: str, k: int = 5) -> List[Document]:
        """
        相似度检索
        """
        results = self.vectorstore.similarity_search(query, k=k)
        return results
    
    def similarity_search_with_score(self, query: str, k: int = 5) -> List[tuple]:
        """
        带相似度得分的检索
        """
        results = self.vectorstore.similarity_search_with_score(query, k=k)
        return results
    
    def add_documents(self, documents: List[Document]):
        """
        增量添加文档
        """
        self.vectorstore.add_documents(documents)
        self.vectorstore.persist()
        print(f"已添加 {len(documents)} 个文档,当前总数: {self.vectorstore._collection.count()}")

4.4 使用 FAISS 作为本地向量存储

如果希望完全控制索引,且不需要持久化到数据库系统,FAISS + 文件存储是一个很好的选择。

python 复制代码
# faiss_store.py
import pickle
import os
import numpy as np
from typing import List, Tuple
from langchain.schema import Document

class FAISSVectorStore:
    """
    基于 FAISS 的本地向量存储,支持持久化
    """
    
    def __init__(self, dimension: int, use_gpu: bool = False):
        self.dimension = dimension
        self.use_gpu = use_gpu
        self.index = None
        self.chunks = []
        self.embeddings = []
    
    def add(self, embeddings: List[np.ndarray], chunks: List[Document]):
        """
        添加向量和文档块
        """
        if self.index is None:
            # 首次添加,创建索引
            self.index = faiss.IndexFlatIP(self.dimension)
            if self.use_gpu:
                res = faiss.StandardGpuResources()
                self.index = faiss.index_cpu_to_gpu(res, 0, self.index)
        
        vectors = np.array(embeddings).astype('float32')
        self.index.add(vectors)
        self.chunks.extend(chunks)
        self.embeddings.extend(embeddings)
    
    def search(self, query_vector: np.ndarray, k: int = 5) -> List[Tuple[Document, float]]:
        """
        检索
        """
        query = np.array([query_vector]).astype('float32')
        similarities, indices = self.index.search(query, k)
        
        results = []
        for i in range(k):
            idx = indices[0][i]
            if idx != -1:
                results.append((self.chunks[idx], float(similarities[0][i])))
        return results
    
    def save(self, path: str):
        """
        保存索引和元数据到磁盘
        """
        os.makedirs(path, exist_ok=True)
        
        # 保存 FAISS 索引
        faiss.write_index(self.index, os.path.join(path, "index.faiss"))
        
        # 保存文档块和向量(用于重建)
        with open(os.path.join(path, "chunks.pkl"), "wb") as f:
            pickle.dump(self.chunks, f)
        
        with open(os.path.join(path, "embeddings.pkl"), "wb") as f:
            pickle.dump(self.embeddings, f)
        
        print(f"FAISS 存储已保存到: {path}")
    
    def load(self, path: str):
        """
        从磁盘加载
        """
        # 加载 FAISS 索引
        self.index = faiss.read_index(os.path.join(path, "index.faiss"))
        
        # 加载文档块和向量
        with open(os.path.join(path, "chunks.pkl"), "rb") as f:
            self.chunks = pickle.load(f)
        
        with open(os.path.join(path, "embeddings.pkl"), "rb") as f:
            self.embeddings = pickle.load(f)
        
        print(f"FAISS 存储已加载,共 {self.index.ntotal} 个向量")

第五部分:提示词增强 RAG 设计

检索到相关文档块后,最后一步是构造提示词(Prompt)并调用大语言模型生成答案。提示词的设计直接决定了生成答案的质量。

5.1 基础 RAG 提示词模板

最简单的 RAG 提示词包含三个部分:系统指令、上下文、用户问题。

python 复制代码
# basic_prompt.py
def build_basic_rag_prompt(query: str, contexts: List[str]) -> str:
    """
    构建基础的 RAG 提示词
    
    Args:
        query: 用户问题
        contexts: 检索到的上下文列表
    
    Returns:
        构造好的提示词字符串
    """
    # 将多个上下文合并
    context_str = "\n\n".join([f"[片段{i+1}]: {ctx}" for i, ctx in enumerate(contexts)])
    
    prompt = f"""请基于以下提供的上下文信息回答用户的问题。如果上下文中没有相关信息,请直接说"根据现有资料无法回答该问题",不要编造信息。

### 上下文信息 ###
{context_str}

### 用户问题 ###
{query}

### 回答 ###
"""
    return prompt

5.2 高级提示词设计

为了提升回答质量,我们可以加入更多指令,如角色扮演、格式要求、引用来源等。

python 复制代码
# advanced_prompt.py
def build_advanced_rag_prompt(
    query: str, 
    contexts: List[tuple],  # (document, score)
    conversation_history: List[dict] = None,
    output_format: str = "markdown"
) -> str:
    """
    构建高级 RAG 提示词,包含角色设定、来源引用、历史对话等
    
    Args:
        query: 用户问题
        contexts: 上下文列表,每个元素为 (document, similarity_score)
        conversation_history: 历史对话,格式如 [{"role": "user", "content": "..."}, ...]
        output_format: 输出格式,支持 "markdown", "json", "plain"
    
    Returns:
        构造好的提示词
    """
    # 角色设定
    system_message = """你是一个专业的知识助手,名叫"智答"。你的职责是基于提供的参考资料,准确、清晰地回答用户的问题。
请遵循以下原则:
1. 只根据提供的上下文回答问题,不要使用外部知识。
2. 如果上下文不足以回答问题,明确告知用户信息不足。
3. 回答时尽量引用具体的上下文来源,让用户知道信息的出处。
4. 回答要简洁、有条理,使用清晰的段落。
"""
    
    # 构建上下文部分,包含相似度得分
    context_section = "### 参考资料 ###\n"
    for i, (doc, score) in enumerate(contexts):
        source = doc.metadata.get('source', '未知来源')
        # 相似度阈值过滤:低于 0.7 的标注为低置信度
        confidence = "高置信度" if score > 0.7 else "中等置信度"
        context_section += f"\n[来源{i+1}] 文件: {source} | 相关度: {score:.3f} ({confidence})\n"
        context_section += f"{doc.page_content}\n"
        context_section += "-" * 50 + "\n"
    
    # 历史对话部分(可选)
    history_section = ""
    if conversation_history:
        history_section = "### 对话历史 ###\n"
        for turn in conversation_history[-3:]:  # 只取最近3轮
            role = "用户" if turn["role"] == "user" else "助手"
            history_section += f"{role}: {turn['content']}\n"
        history_section += "\n"
    
    # 输出格式指令
    format_instruction = ""
    if output_format == "markdown":
        format_instruction = "请使用 Markdown 格式组织回答,适当使用标题、列表、加粗等元素增强可读性。"
    elif output_format == "json":
        format_instruction = "请以 JSON 格式返回答案,包含 'answer' 和 'sources' 两个字段。"
    
    # 最终提示词
    prompt = f"""{system_message}

{context_section}

{history_section}

### 用户当前问题 ###
{query}

{format_instruction}

### 回答 ###
"""
    return prompt

5.3 调用大模型生成答案

我们可以使用 OpenAI API 或本地模型(如 ChatGLM、Llama)来生成答案。这里以 OpenAI 为例。

python 复制代码
# llm_generation.py
from openai import OpenAI
from typing import List, Dict, Any
import os

class RAGGenerator:
    """
    RAG 生成器,负责调用 LLM 生成最终答案
    """
    
    def __init__(self, api_key: str = None, model: str = "gpt-3.5-turbo", base_url: str = None):
        """
        初始化生成器
        
        Args:
            api_key: OpenAI API Key,如果不提供则从环境变量 OPENAI_API_KEY 读取
            model: 模型名称
            base_url: API 基础 URL,可用于代理或本地模型
        """
        self.api_key = api_key or os.getenv("OPENAI_API_KEY")
        if not self.api_key:
            raise ValueError("请提供 OpenAI API Key")
        
        self.client = OpenAI(api_key=self.api_key, base_url=base_url)
        self.model = model
    
    def generate(self, prompt: str, temperature: float = 0.3, max_tokens: int = 1000) -> str:
        """
        生成回答
        
        Args:
            prompt: 提示词
            temperature: 温度参数,0-1之间,越低越确定
            max_tokens: 最大生成 token 数
            
        Returns:
            生成的回答文本
        """
        try:
            response = self.client.chat.completions.create(
                model=self.model,
                messages=[
                    {"role": "system", "content": "你是一个专业的智能助手。"},
                    {"role": "user", "content": prompt}
                ],
                temperature=temperature,
                max_tokens=max_tokens
            )
            return response.choices[0].message.content
        except Exception as e:
            print(f"生成失败: {e}")
            return "抱歉,生成回答时遇到错误,请稍后重试。"
    
    def generate_with_history(self, messages: List[Dict[str, str]], temperature: float = 0.3) -> str:
        """
        支持对话历史的生成
        """
        response = self.client.chat.completions.create(
            model=self.model,
            messages=messages,
            temperature=temperature
        )
        return response.choices[0].message.content

5.4 完整 RAG 流程整合

现在,我们将所有组件整合起来,形成一个完整的 Naive RAG 系统。

python 复制代码
# complete_rag.py
import os
from typing import List, Optional
from langchain.schema import Document

class NaiveRAG:
    """
    完整的 Naive RAG 系统,整合文档加载、分块、向量化、检索、生成
    """
    
    def __init__(self, 
                 embedding_model_name: str = "BAAI/bge-small-zh",
                 llm_model: str = "gpt-3.5-turbo",
                 chunk_size: int = 500,
                 chunk_overlap: int = 50,
                 top_k: int = 5):
        """
        初始化 RAG 系统
        """
        self.chunk_size = chunk_size
        self.chunk_overlap = chunk_overlap
        self.top_k = top_k
        
        # 初始化 Embedding 模型
        self.embedder = LocalEmbedding(embedding_model_name)
        
        # 初始化向量存储(使用 Chroma)
        self.vectorstore = ChromaVectorStore(self.embedder)
        
        # 初始化生成器
        self.generator = RAGGenerator(model=llm_model)
        
        # 文档存储(原始文档块)
        self.chunks = []
    
    def ingest_documents(self, file_paths: List[str]):
        """
        摄入文档:加载、分块、向量化、存储
        """
        all_docs = []
        
        # 1. 加载文档
        for file_path in file_paths:
            if file_path.endswith('.pdf'):
                docs = load_pdf(file_path)
            elif file_path.endswith('.docx'):
                docs = load_docx(file_path)
            elif file_path.endswith('.txt') or file_path.endswith('.md'):
                docs = load_text_or_md(file_path)
            else:
                print(f"不支持的文件类型: {file_path}")
                continue
            all_docs.extend(docs)
        
        print(f"文档加载完成,共 {len(all_docs)} 个原始文档片段")
        
        # 2. 分块
        self.chunks = recursive_character_chunking(
            all_docs, 
            chunk_size=self.chunk_size, 
            chunk_overlap=self.chunk_overlap
        )
        
        # 3. 创建向量数据库
        self.vectorstore.create_from_documents(self.chunks)
        
        print("文档摄入完成!")
    
    def query(self, question: str, return_context: bool = False) -> dict:
        """
        查询问答
        
        Args:
            question: 用户问题
            return_context: 是否返回检索到的上下文
            
        Returns:
            包含答案和可选上下文的字典
        """
        # 1. 检索相关上下文
        retrieved_docs = self.vectorstore.similarity_search_with_score(question, k=self.top_k)
        
        # 提取文本内容
        contexts = [(doc, score) for doc, score in retrieved_docs]
        context_texts = [doc.page_content for doc, _ in contexts]
        
        # 2. 构建提示词
        prompt = build_advanced_rag_prompt(question, contexts)
        
        # 3. 生成答案
        answer = self.generator.generate(prompt)
        
        result = {
            "answer": answer,
            "question": question
        }
        
        if return_context:
            result["contexts"] = [
                {
                    "content": doc.page_content,
                    "score": score,
                    "source": doc.metadata.get("source", "unknown")
                }
                for doc, score in contexts
            ]
        
        return result
    
    def chat_loop(self):
        """
        交互式命令行对话
        """
        print("RAG 问答系统已启动,输入 'exit' 退出")
        print("-" * 50)
        
        while True:
            question = input("\n请输入问题: ").strip()
            if question.lower() == 'exit':
                break
            
            if not question:
                continue
            
            print("正在思考...")
            result = self.query(question)
            print("\n回答:")
            print(result["answer"])
            print("\n" + "-" * 50)

# 示例用法
if __name__ == "__main__":
    # 初始化系统
    rag = NaiveRAG(
        embedding_model_name="BAAI/bge-small-zh",
        llm_model="gpt-3.5-turbo",
        chunk_size=500,
        top_k=5
    )
    
    # 摄入文档(假设有一批文档)
    # rag.ingest_documents(["docs/产品手册.pdf", "docs/FAQ.md"])
    
    # 启动对话
    # rag.chat_loop()
    
    # 单次查询
    # result = rag.query("如何配置环境变量?")
    # print(result["answer"])

第六部分:本章练习题与解答

为了帮助读者巩固所学知识,我们设计了以下练习题,涵盖选择题、填空题、简答题和实操题。

6.1 选择题

1. 在 RAG 系统中,对文档进行分块的主要目的是什么?

A. 减少存储空间

B. 提高检索精度和满足 LLM 上下文限制

C. 加快文档加载速度

D. 增加答案长度

答案:B

解析:分块的主要目的是让每个块包含语义完整的信息,便于检索时精准匹配,同时确保每个块的大小不超过 LLM 的上下文限制。


2. 以下哪种相似度计算方法在向量归一化后与点积等价?

A. 欧氏距离

B. 曼哈顿距离

C. 余弦相似度

D. 杰卡德相似度

答案:C

解析:当向量经过归一化(模长为1)后,点积结果等于余弦相似度。


3. 下列哪个向量数据库是嵌入式、轻量级且与 LangChain 集成最友好的?

A. Milvus

B. Pinecone

C. Chroma

D. Weaviate

答案:C

解析:Chroma 是轻量级的嵌入式向量数据库,API 简洁,适合原型开发和教学。


6.2 填空题

1. 在 FAISS 中,_________ 索引是精确检索,而 _________ 索引通过聚类实现近似检索,以提升速度。

答案:IndexFlatIP;IndexIVFFlat

解析:IndexFlatIP 暴力计算所有向量的相似度,结果精确但慢;IndexIVFFlat 使用倒排索引,牺牲少量精度换取速度。


2. 在 RAG 提示词设计中,通常包含三个核心部分:系统指令、_________ 和用户问题。

答案:上下文/参考资料

解析:RAG 的核心就是将检索到的上下文作为生成依据。


3. 常用的中文 Embedding 模型 BAAI/bge-small-zh 输出的向量维度是 _________。

答案:384

解析:bge-small-zh 输出 384 维向量,bge-base-zh 为 768 维,bge-large-zh 为 1024 维。


6.3 简答题

1. 请简述 Naive RAG 的基本工作流程,并说明每个步骤的作用。

参考答案

Naive RAG 的工作流程分为三个主要阶段:

  1. 索引阶段

    • 文档加载:从 PDF、Word 等格式中提取文本。

    • 文档分块:将长文档切分为语义完整的块,便于检索。

    • 向量化:使用 Embedding 模型将文本块转换为向量。

    • 存储:将向量存入向量数据库,建立索引。

  2. 检索阶段

    • 用户输入查询后,同样使用 Embedding 模型将查询向量化。

    • 在向量数据库中执行相似度搜索,找到最相关的 top-k 个文档块。

  3. 生成阶段

    • 将检索到的文档块作为上下文,结合用户问题构造提示词。

    • 调用大语言模型生成基于上下文的答案。


2. 什么是语义分块?相比固定长度分块,它有什么优势?

参考答案

语义分块是一种基于文本语义相似度进行切分的方法。首先将文本按句子分割,然后计算相邻句子之间的向量相似度,当相似度低于阈值时在此处切分,使得每个块内部的语义高度内聚。

优势:

  • 每个块内的信息主题一致,不会在不同主题间跳跃。

  • 检索时更容易命中完整的主题单元,提高答案连贯性。

  • 减少因切断关键信息导致的检索遗漏。


3. 在使用向量检索时,为什么通常要对向量进行归一化?

参考答案

归一化是将向量的模长变为1的过程。主要好处有:

  • 归一化后,向量点积等于余弦相似度,简化计算。

  • 避免向量长度对相似度度量的影响,只关注方向(语义)。

  • 许多近似最近邻算法(如 FAISS 的 IndexFlatIP)要求输入向量已归一化。

  • 提高数值稳定性。


6.4 实操题

题目:请基于本文提供的代码,搭建一个简单的 RAG 系统,回答以下问题:

  1. 使用 BAAI/bge-small-zh 作为 Embedding 模型,对一段你感兴趣的文本(不少于500字)进行分块和向量化。

  2. 构建 FAISS 索引,并实现一个查询函数,返回 top-3 最相似的块。

  3. 如果使用 OpenAI API(或本地模型),生成最终答案。

  4. 记录并分析:不同分块大小(如 200、500、1000)对检索结果的影响。

参考答案框架

python 复制代码
# 实操题答案示例
import numpy as np
from sentence_transformers import SentenceTransformer
import faiss

# 1. 准备文本
text = """
(请替换为你的文本)
"""

# 2. 分块函数
def chunk_text(text, chunk_size=500, overlap=50):
    words = text.split()
    chunks = []
    for i in range(0, len(words), chunk_size - overlap):
        chunk = ' '.join(words[i:i+chunk_size])
        chunks.append(chunk)
    return chunks

# 3. 向量化
model = SentenceTransformer('BAAI/bge-small-zh')
chunks = chunk_text(text, chunk_size=500)
embeddings = model.encode(chunks, normalize_embeddings=True)

# 4. 构建 FAISS 索引
dimension = embeddings.shape[1]
index = faiss.IndexFlatIP(dimension)
index.add(embeddings.astype('float32'))

# 5. 查询函数
def search(query, k=3):
    query_vec = model.encode([query], normalize_embeddings=True)
    scores, indices = index.search(query_vec.astype('float32'), k)
    results = [(chunks[idx], score) for idx, score in zip(indices[0], scores[0])]
    return results

# 6. 测试不同分块大小
query = "你的测试问题"
for size in [200, 500, 1000]:
    chunks_test = chunk_text(text, chunk_size=size)
    print(f"分块大小 {size}: 共 {len(chunks_test)} 个块")
    results = search(query)
    print(f"Top 相似度: {results[0][1]:.4f}")

分析要点

  • 分块过小(200):可能丢失上下文,但检索更精细。

  • 分块适中(500):平衡检索精度和上下文完整性。

  • 分块过大(1000):可能包含多个主题,降低检索精度,且可能超出 LLM 上下文限制。


总结

本文系统性地介绍了 Naive RAG 的核心技术栈与实现细节,从知识库搭建、Embedding、索引、向量存储到提示词增强生成,为读者呈现了一个完整可运行的 RAG 系统。我们不仅讲解了理论原理,更提供了大量带详细注释的代码示例,涵盖文档加载、分块策略、Embedding 模型使用、FAISS/Chroma 向量数据库集成以及提示词工程等关键环节。

回顾全文,有几个核心要点值得强调:

  1. 分块是艺术:没有万能的"最佳分块大小",需要根据文档类型、检索任务和 LLM 上下文长度进行实验调优。递归分割是平衡效率与效果的良好起点。

  2. Embedding 模型的选择至关重要:对于中文场景,BAAI 系列模型(bge-small/base/large)在准确性和资源消耗之间提供了灵活选择。向量归一化与相似度度量的配合需要仔细考量。

  3. 向量数据库的演进:从简单的 FAISS 库到功能完备的 Chroma/Pinecone/Milvus,选择合适的存储方案取决于数据规模、部署环境和团队维护能力。对于原型开发,Chroma 提供了最低的入门门槛。

  4. 提示词工程决定生成质量:优秀的提示词应该包含角色设定、明确的指令、上下文来源引用以及输出格式要求。合理的温度参数设置(0.1-0.3)有助于平衡确定性与创造性。

  5. 评测与迭代:Naive RAG 仅仅是起点。在实践中,我们需要通过构建评测数据集、计算 Hit Rate、MRR 等指标来持续优化系统。后续可探索高级 RAG 技术,如查询重写、上下文压缩、多路召回、自省机制等。

通过本文的学习,读者应该能够独立搭建一个基础的 RAG 问答系统,并具备进一步探索更复杂 RAG 架构的能力。RAG 技术仍在快速发展,结合 Agent 的自主决策能力、知识图谱的结构化知识、多模态数据的融合,未来将解锁更多激动人心的应用场景。

希望本文能成为你踏入 RAG 世界的坚实一步。现在,拿起代码,去构建属于你自己的智能问答系统吧!


🌟 感谢您耐心阅读到这里!

🚀 技术成长没有捷径,但每一次的阅读、思考和实践,都在默默缩短您与成功的距离。

💡 如果本文对您有所启发,欢迎点赞👍、收藏📌、分享📤给更多需要的伙伴!

🗣️ 期待在评论区看到您的想法、疑问或建议,我会认真回复,让我们共同探讨、一起进步~

🔔 关注我,持续获取更多干货内容!

🤗 我们下篇文章见!

相关推荐
久违 °2 小时前
【AI-Agent】OpenClaw 对接飞书应用机器人
ai·机器人·飞书
moongoblin2 小时前
《本地 Agent 实践》系列之一:MacOS+Ollama+OpenClaw+飞书
ai
VIP_CQCRE2 小时前
Claude Desktop + Midjourney MCP:对话式 AI 绘图教程
ai
Cha0DD10 小时前
【由浅入深探究langchain】第二十集-SQL Agent+Human-in-the-loop
人工智能·python·ai·langchain
Cha0DD10 小时前
【由浅入深探究langchain】第十九集-官方的SQL Agent示例
人工智能·python·ai·langchain
健了个平_2413 小时前
LottieConverter:一键生成 .lottie 文件
ios·chatgpt·动效
CoderJia程序员甲14 小时前
GitHub 热榜项目 - 日榜(2026-03-29)
人工智能·ai·大模型·github·ai教程
今天也要学习吖15 小时前
开源AI智能客服系统AI-CS
人工智能·ui·chatgpt·golang·开源·gemini·智能客服系统
Mac的实验室16 小时前
『保姆级教程』2026 最新 ChatGPT 注册教程来了!无需手机号,三分钟搞定!
chatgpt