# RAG 与向量数据库

以下内容基于开源项目 All in Rag:https://github.com/datawhalechina/all-in-rag,如果感兴趣的话可以 star+fork 学习。

# 文本分块

question:为什么要文本分块?

  1. Embedding Model 有严格的输入长度上限,超出此限制的文本块在输入时都会被截断,导致信息丢失,生成的向量也无法完整代表原文的语义。
  2. LLM 同样有上下文窗口限制,如果单个块过大,可能会导致只能容纳少数几个相关的块,限制了 LLM 回答问题时可参考的信息广度

question:是不是越大越好?

块的大小并非越大越好,过大的块会严重影响 RAG 系统的性能。也就是说可选择的越多,大模型越眼花缭乱,少量且正确的信息反而是关键。ABCDFG 的语义信息明显比 ABC 的语义信息包含的多,因此对 ABC 进行语义匹配的时候 ABC 的排名通常会高于 ABCDFG。

# 固定大小分块 CharacterTextSplitter

from langchain_text_splitters import CharacterTextSplitter
from langchain_community.document_loaders import TextLoader
loader = TextLoader("../data/fy.txt", encoding="utf-8")
docs = loader.load()
text_splitter = CharacterTextSplitter(
    separator="\n\n",
    chunk_size=200,
    chunk_overlap=20
)
chunks  = text_splitter.split_documents(docs)

# 递归字符分块 RecursiveCharacterTextSplitter

from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import TextLoader
loader = TextLoader("../data/fy.txt", encoding="utf-8")
docs = loader.load()
text_splitter = RecursiveCharacterTextSplitter(
    separators=["\n\n", "\n", "。", ",", " ", ""],  # 分隔符优先级
    chunk_size=200,
    chunk_overlap=10,
)
chunks = text_splitter.split_documents(docs)

也可以支持针对不同语言进行分割,这个是对不同编程语言进行了优化:

from langchain_text_splitters import Language
# 针对代码文档的优化分隔符
splitter = RecursiveCharacterTextSplitter.from_language(
    language=Language.PYTHON,  # 支持 Python、Java、C++ 等
    chunk_size=500,
    chunk_overlap=50
)

递归字符分块的原理是采用一组有层次结构的分隔符(如段落、句子、单词)进行递归分割,旨在有效平衡语义完整性与块大小控制。在 RecursiveCharacterTextSplitter 的实现中,该分块器首先尝试使用最高优先级的分隔符(如段落标记)来切分文本。如果切分后的块仍然过大,会继续对这个大块应用下一优先级分隔符(如句号),如此循环往复,直到块满足大小限制。这种分层处理的机制,能够在尽可能保持高级语义结构完整性的同时,有效控制块大小。

# 语义分块 SemanticChunker

import os
from dotenv import load_dotenv
from langchain.embeddings import init_embeddings
load_dotenv()
EMBED_MODEL_NAME = "BAAI/bge-m3"  # 嵌入模型名称
embedding_model = init_embeddings(
    model="openai:" + EMBED_MODEL_NAME,
    api_key=os.getenv("SILICONFLOW_API_KEY"),
    base_url=os.getenv("SILICONFLOW_BASE_URL")
)
from langchain_experimental.text_splitter import SemanticChunker
text_splitter = SemanticChunker(
    buffer_size=4,
    embeddings=embedding_model,
    breakpoint_threshold_type="percentile",  # 断点识别方法
    sentence_split_regex=r'(?<=[。!?.!?])\s*',
    breakpoint_threshold_amount=50,          # 降低阈值,增加分块
)
from langchain_community.document_loaders import PDFMinerLoader,TextLoader
from tqdm import tqdm
loader = TextLoader("../data/1.txt", encoding="utf-8")
documents = loader.load()
docs = text_splitter.split_documents(documents)

断点识别方法 ( breakpoint_threshold_type ):

  1. percentile 百分位法:计算所有相邻句子的语义差异值,并将这些差异值进行排序。当一个差异值超过某个百分位阈值时,就认为该差异值是一个断点。breakpoint_threshold_amount (默认为 95),表示使用第 95 个百分位作为阈值。这意味着,只有最显著的 5% 的语义差异点会被选为切分点。
  2. standard_deviation 标准差法:计算所有差异值的平均值和标准差。当一个差异值超过 “平均值 + N * 标准差” 时,被视为异常高的跳跃,即断点。breakpoint_threshold_amount (默认为 3),表示使用 3 倍标准差作为阈值。
  3. interquartile 四分位距法:使用统计学中的四分位距(IQR)来识别异常值。当一个差异值超过 Q3 + N * IQR 时,被视为断点。breakpoint_threshold_amount (默认为 1.5),表示使用 1.5 倍的 IQR。
  4. gradient 梯度法:这是一种更复杂的方法。它首先计算差异值的变化率(梯度),然后对梯度应用百分位法。对于那些句子间语义联系紧密、差异值普遍较低的文本(如法律、医疗文档)特别有效,因为这种方法能更好地捕捉到语义变化的 “拐点”。breakpoint_threshold_amount (默认为 95)。

# 基于文档结构的分块 MarkdownHeaderTextSplitter

适用于 Markdown、HTML、LaTeX 等极具结构化的内容。

单纯按标题分割可能会导致一个问题:某个章节下的内容可能非常长,远超模型能处理的上下文窗口。为了解决这个问题, MarkdownHeaderTextSplitter 可以与其它分块器(如 RecursiveCharacterTextSplitter )组合使用。具体流程是:

  1. 使用 MarkdownHeaderTextSplitter 将文档按标题分割成若干个大的、带有元数据的逻辑块。
  2. 对这些逻辑块再应用 RecursiveCharacterTextSplitter ,将其进一步切分为符合 chunk_size 要求的小块。

这个是一个可视化分词工具:https://chunkviz.up.railway.app/

# 向量嵌入

question:Embedding 在干什么?

一句话来讲就是,把文本、图像、音频、视频等转换为数学上易于处理的、低维、稠密的连续数值向量。

1

如果向量是杂乱的也无意义,经过 Embedding 之后数据是有语义的。通常使用以下数学方法来衡量向量间的 “距离” 或 “相似度”:

  1. 余弦相似度 Cosine Similarity(最常用)
  2. 点积 Dot Product
  3. 欧氏距离 Euclidean Distance

RAG 的语义检索以 Embedding 为核心,流程为:

  1. 先将文档切块并向量化存入向量数据库,查询时将问题也向量化
  2. 再计算问题向量与文档块向量的相似度
  3. 最后召回相似度最高的 Top-K 文档块,作为上下文与问题一起交给大模型生成答案。

Embedding 技术经历了三个阶段:

  1. 静态词嵌入:Word2Vec、GloVe 为每个词生成固定向量,但无法解决一词多义问题。
  2. 动态上下文嵌入:基于 Transformer 自注意力机制,同一个词在不同语境中生成不同向量,有效解决了多义性。
  3. RAG 驱动的新要求:
    • 领域自适应:需通过微调或指令适应专业领域
    • 多粒度与多模态:支持长文档、代码、图像、表格等多样输入
    • 检索效率与混合检索:平衡存储成本与检索速度,结合语义与关键词匹配,如 BGE-M3

# 多模态嵌入

OpenAI 的 CLIP (Contrastive Language-Image Pre-training) 是一个很有影响力的模型,它为多模态嵌入定义了一个有效的范式。

2

它采用双编码器架构 (Dual-Encoder Architecture),包含一个图像编码器和一个文本编码器,分别将图像和文本映射到同一个共享的向量空间中。

# 向量数据库 FAISS

FAISS 本质上是一个算法库,它将索引直接保存为本地文件(一个 .faiss 索引文件和一个 .pkl 映射文件),而非运行一个数据库服务。这种方式轻量且高效。

from langchain_openai import OpenAIEmbeddings
from langchain_core.documents import Document
from langchain_community.vectorstores import FAISS
import os
from dotenv import load_dotenv
load_dotenv()
embedding_model = OpenAIEmbeddings(
    model="BAAI/bge-large-zh-v1.5",
    api_key=os.getenv("SILICONFLOW_API_KEY"),
    base_url=os.getenv("SILICONFLOW_BASE_URL"),
    check_embedding_ctx_length=False,  # 不检查长度
)
texts = [
    "张三是法外狂徒",
    "FAISS是一个用于高效相似性搜索和密集向量聚类的库。",
    "LangChain是一个用于开发由语言模型驱动的应用程序的框架。"
]
docs = [Document(page_content=t) for t in texts]
vectorstore = FAISS.from_documents(docs, embedding_model)

指定存储路径:

local_faiss_path = "./faiss_index_store"
vectorstore.save_local(local_faiss_path)

以上称为为离线向量化,下面开始加载索引并执行查询。

# 3. 加载索引并执行查询
# 加载时需指定相同的嵌入模型,并允许反序列化
loaded_vectorstore = FAISS.load_local(
    local_faiss_path,
    embedding_model,
    allow_dangerous_deserialization=True
)
query = "FAISS是做什么的?"
results = loaded_vectorstore.similarity_search(query, k=1)
print(f"\n查询: '{query}'")
print("相似度最高的文档:")
for doc in results:
    print(f"- {doc.page_content}")

查询: ‘FAISS 是做什么的?’
相似度最高的文档:

  • FAISS 是一个用于高效相似性搜索和密集向量聚类的库。

# 使用 LlamaIndex 做数据存储与检索

自定义 SiliconFlowEmbedding 类

from llama_index.core.embeddings import BaseEmbedding
from llama_index.core import Settings
from openai import OpenAI
from typing import List, Any, Optional
import os
from dotenv import load_dotenv
from pydantic import Field
load_dotenv()
class SiliconFlowEmbedding(BaseEmbedding):
    """自定义硅基流动嵌入类(兼容 LlamaIndex)"""
    # 声明 Pydantic 字段
    model: str = Field(default="BAAI/bge-large-zh-v1.5", description="模型名称")
    api_key: Optional[str] = Field(default=None, description="API密钥")
    api_base: Optional[str] = Field(default=None, description="API基础URL")
    _client: Any = None  # 私有客户端
    def __init__(
        self,
        model: str = "BAAI/bge-large-zh-v1.5",
        api_key: Optional[str] = None,
        api_base: Optional[str] = None,
        **kwargs: Any,
    ):
        super().__init__(
            model=model,
            api_key=api_key or os.getenv("SILICONFLOW_API_KEY"),
            api_base=api_base or os.getenv("SILICONFLOW_BASE_URL"),
            **kwargs
        )
        # 初始化 OpenAI 客户端
        self._client = OpenAI(
            api_key=self.api_key,
            base_url=self.api_base
        )
    def _get_query_embedding(self, query: str) -> List[float]:
        """获取查询的嵌入"""
        response = self._client.embeddings.create(
            model=self.model,
            input=query
        )
        return response.data[0].embedding
    def _get_text_embedding(self, text: str) -> List[float]:
        """获取单个文本的嵌入"""
        response = self._client.embeddings.create(
            model=self.model,
            input=text
        )
        return response.data[0].embedding
    def _get_text_embeddings(self, texts: List[str]) -> List[List[float]]:
        """获取多个文本的嵌入"""
        response = self._client.embeddings.create(
            model=self.model,
            input=texts
        )
        return [item.embedding for item in response.data]
    async def _aget_query_embedding(self, query: str) -> List[float]:
        """异步获取查询的嵌入"""
        return self._get_query_embedding(query)
    async def _aget_text_embedding(self, text: str) -> List[float]:
        """异步获取文本的嵌入"""
        return self._get_text_embedding(text)
# 使用自定义嵌入类
embedding_model = SiliconFlowEmbedding(
    model="BAAI/bge-large-zh-v1.5",
    api_key=os.getenv("SILICONFLOW_API_KEY"),
    api_base=os.getenv("SILICONFLOW_BASE_URL")
)
# 设置全局嵌入模型
Settings.embed_model = embedding_model

创建文档:

from llama_index.core import VectorStoreIndex, Document, Settings, StorageContext
Settings.embed_model = embedding_model
# 2. 创建示例文档
texts = [
    "张三是法外狂徒",
    "LlamaIndex是一个用于构建和查询私有或领域特定数据的框架。",
    "它提供了数据连接、索引和查询接口等工具。"
]
docs = [Document(text=t) for t in texts]

检索:

# 创建索引并持久化到本地
index = VectorStoreIndex.from_documents(docs)
persist_path = "./llamaindex_index_store"
index.storage_context.persist(persist_dir=persist_path)
print(f"LlamaIndex 索引已保存至: {persist_path}")
from llama_index.core import StorageContext, load_index_from_storage
# 加载索引
storage_context = StorageContext.from_defaults(persist_dir=persist_path)
loaded_index = load_index_from_storage(storage_context)
# 使用检索器(不需要 LLM)
retriever = loaded_index.as_retriever()
# 检索相关文档
query = "LlamaIndex是做什么的?"
nodes = retriever.retrieve(query)
print(f"检索到 {len(nodes)} 个相关文档:")
for i, node in enumerate(nodes):
    print(f"\n文档 {i+1}:")
    print(node.text)
    print(f"相似度分数: {node.score}")

# 向量数据库 Milvus

这部分已在本站的 LangChain 文章中介绍过了,这里就不展开了。

更新于

请我喝[茶]~( ̄▽ ̄)~*

KarryLiu 微信支付

微信支付

KarryLiu 支付宝

支付宝