第二章:构建知识库与向量化

6 阅读14分钟

第二章:构建知识库与向量化

2.1 引言

在第一章中,我们介绍了 RAG 的基本概念、核心组件以及开发环境的配置。作为 RAG 系统的第一步,构建一个高效的知识库并对其内容进行向量化是至关重要的。知识库是 RAG 系统中存储所有可检索信息的基础,而向量化则是实现语义检索的关键。

本章的目标是帮助 Java 程序员理解和实现以下内容:

  1. 准备和管理知识库,包括处理多种格式的文档(如文本、PDF 和网页内容)。
  2. 使用嵌入模型将文档内容转换为向量表示。
  3. 将向量存储到向量数据库(如 FAISS)中,并实现基本的检索功能。
  4. 解决知识库构建和向量化中的常见问题。

我们将通过详细的代码示例和步骤,带你完成从文档收集到向量存储的完整流程。本章假设你已经按照第一章的说明配置好了 Python 环境和必要的库(如 sentence-transformersfaiss-cpu)。

2.2 知识库的准备

2.2.1 知识库的定义

知识库是 RAG 系统中存储所有可检索信息的集合,可以包含以下类型的数据:

  • 纯文本文件:如 .txt 文件,适合存储简单的文档或 FAQ。
  • 结构化文档:如 PDF、Word 或 Markdown 文件,常用于企业手册或技术文档。
  • 数据库内容:如 MySQL 或 MongoDB 中的记录,适合存储动态数据。
  • 网页内容:通过爬虫或 API 获取的在线文章或知识库页面。
  • 企业数据:如内部 Wiki、CRM 系统中的客户记录或产品描述。

在本教程中,我们将以纯文本文件和 PDF 文档为例,展示如何构建知识库。后续你可以根据实际需求扩展到其他数据源。

2.2.2 知识库的设计原则

为了确保知识库在 RAG 系统中高效工作,需要遵循以下设计原则:

  1. 内容相关性:知识库应包含与目标应用场景高度相关的内容。例如,智能客服系统需要产品手册和 FAQ,而法律咨询系统需要法规和案例。
  2. 内容结构化:文档内容应尽可能结构化(如分段、分标题),以便于检索和生成。
  3. 内容更新性:知识库应支持动态更新,以反映最新的信息。
  4. 内容粒度:文档应按适当的粒度分割(如按段落或句子),以提高检索的精确度。

2.2.3 收集示例数据

为了便于讲解,我们将使用一个简单的示例知识库,模拟一个“企业产品知识库”,包含以下内容:

  1. 产品手册:描述公司产品的功能、规格和使用方法(PDF 格式)。
  2. FAQ 文件:常见问题和答案(纯文本格式)。
  3. 技术文档:产品安装和维护指南(Markdown 格式)。

你可以从以下方式获取示例数据:

  • 手动创建:编写一些简单的文本文件或 PDF。
  • 公开数据集:使用开源文档或维基百科页面。
  • 企业数据:如果你有真实的业务数据,可以直接使用。

为了简化,我们将创建一个包含以下内容的目录结构:

rag_knowledge_base/
├── manuals/
│   ├── product_manual.pdf
│   ├── installation_guide.md
├── faqs/
│   ├── faq.txt

以下是示例文件的内容:

faq.txt

Q: 产品支持哪些操作系统?
A: 我们的产品支持 Windows 10/11、macOS 12+ 和 Ubuntu 20.04+。

Q: 如何联系技术支持?
A: 您可以通过邮箱 support@company.com 或电话 123-456-7890 联系我们。

product_manual.pdf(假设内容为):

# 产品手册
## 产品概述
本产品是一款高性能的智能设备,支持多种功能,包括实时数据处理和远程控制。

## 功能列表
- 数据分析:支持多种数据格式。
- 远程控制:通过移动应用操作。

installation_guide.md

# 安装指南
## 环境要求
- 操作系统:Windows 10+ 或 Linux
- 内存:至少 8GB

## 安装步骤
1. 下载安装包。
2. 运行安装程序并按照提示操作。

2.2.4 预处理文档

在将文档存储到知识库之前,需要进行预处理,包括:

  1. 格式转换:将不同格式的文档(PDF、Markdown 等)转换为纯文本。
  2. 文本分割:将长文档分割成较小的片段(如按段落或句子),以便于向量化。
  3. 清理数据:移除无关内容(如页眉、页脚或特殊字符)。

我们将使用 Python 的以下库来处理文档:

  • PyPDF2:解析 PDF 文件。
  • markdown:解析 Markdown 文件。
  • nltk:进行文本分割和清理。

安装依赖

pip install PyPDF2 markdown nltk

预处理代码
以下是一个完整的预处理脚本,用于加载和分割文档:

import os
import PyPDF2
import markdown
import nltk
from nltk.tokenize import sent_tokenize

# 下载 NLTK 数据
nltk.download('punkt')

def read_txt_file(file_path):
    """读取纯文本文件"""
    with open(file_path, 'r', encoding='utf-8') as f:
        return f.read()

def read_pdf_file(file_path):
    """读取 PDF 文件"""
    text = ""
    with open(file_path, 'rb') as f:
        pdf = PyPDF2.PdfReader(f)
        for page in pdf.pages:
            text += page.extract_text() + "\n"
    return text

def read_md_file(file_path):
    """读取 Markdown 文件并转换为纯文本"""
    with open(file_path, 'r', encoding='utf-8') as f:
        md_text = f.read()
        html = markdown.markdown(md_text)
        # 简单清理 HTML 标签(实际项目中可能需要更复杂的处理)
        return html.replace('<p>', '').replace('</p>', '\n').strip()

def split_text(text, max_length=500):
    """将文本分割成小片段"""
    sentences = sent_tokenize(text)
    chunks = []
    current_chunk = ""
    
    for sentence in sentences:
        if len(current_chunk) + len(sentence) < max_length:
            current_chunk += sentence + " "
        else:
            chunks.append(current_chunk.strip())
            current_chunk = sentence + " "
    
    if current_chunk:
        chunks.append(current_chunk.strip())
    
    return chunks

def process_knowledge_base(directory):
    """处理知识库中的所有文件"""
    documents = []
    
    for root, _, files in os.walk(directory):
        for file in files:
            file_path = os.path.join(root, file)
            if file.endswith('.txt'):
                content = read_txt_file(file_path)
            elif file.endswith('.pdf'):
                content = read_pdf_file(file_path)
            elif file.endswith('.md'):
                content = read_md_file(file_path)
            else:
                continue
            
            # 分割文本
            chunks = split_text(content)
            for i, chunk in enumerate(chunks):
                documents.append({
                    'file': file,
                    'chunk_id': f"{file}_{i}",
                    'content': chunk
                })
    
    return documents

# 处理知识库
knowledge_base_dir = "rag_knowledge_base"
documents = process_knowledge_base(knowledge_base_dir)

# 打印结果
for doc in documents:
    print(f"File: {doc['file']}, Chunk ID: {doc['chunk_id']}")
    print(f"Content: {doc['content'][:100]}...")
    print("-" * 50)

代码说明

  1. 文件读取

    • read_txt_file:直接读取纯文本文件。
    • read_pdf_file:使用 PyPDF2 提取 PDF 的文本内容。
    • read_md_file:使用 markdown 库将 Markdown 转换为 HTML,然后简单清理为纯文本。
  2. 文本分割

    • split_text:使用 NLTK 的 sent_tokenize 将文本按句子分割,并按最大长度(500 字符)组合成片段。
    • 每个片段是一个独立的检索单位,长度需要平衡检索精度和上下文完整性。
  3. 知识库处理

    • process_knowledge_base:遍历知识库目录,处理所有支持的文件格式。
    • 每个文档片段存储为一个字典,包含文件名、片段 ID 和内容。

运行以上代码后,你将得到一个 documents 列表,每个元素是一个文档片段,准备好进行向量化。

2.3 文档向量化

2.3.1 嵌入模型简介

向量化是将文本转换为高维向量表示的过程,这些向量捕捉了文本的语义信息。在 RAG 中,我们使用嵌入模型(Embedding Model)来完成这一任务。嵌入模型通常是基于 Transformer 的神经网络,训练后可以将相似的文本映射到向量空间中的靠近位置。

常用的嵌入模型包括:

  • Sentence-BERT:专门为句子级语义嵌入优化,适合 RAG。
  • Hugging Face 模型:如 all-MiniLM-L6-v2,轻量且高效。
  • OpenAI 嵌入:如 text-embedding-ada-002,需要 API 密钥。

在本教程中,我们将使用 Hugging Face 的 all-MiniLM-L6-v2 模型,因为它:

  • 体积小(约 80MB),适合本地部署。
  • 性能优秀,支持多语言。
  • 开源免费,无需 API 费用。

2.3.2 安装和配置嵌入模型

确保已安装 sentence-transformers

pip install sentence-transformers

以下是使用 sentence-transformers 加载嵌入模型的代码:

from sentence_transformers import SentenceTransformer

# 加载嵌入模型
embedding_model = SentenceTransformer('all-MiniLM-L6-v2')

# 示例:嵌入单个句子
sentence = "This is a test sentence."
embedding = embedding_model.encode(sentence)

print(f"Embedding shape: {embedding.shape}")
print(f"Embedding sample: {embedding[:5]}...")

输出

Embedding shape: (384,)
Embedding sample: [0.123, -0.456, 0.789, -0.234, 0.567]...

说明

  • all-MiniLM-L6-v2 模型将文本转换为 384 维向量。
  • 每个向量表示文本的语义特征,可用于相似度计算。

2.3.3 向量化知识库

现在,我们将对知识库中的所有文档片段进行向量化,并将结果存储到一个列表中。以下是完整代码:

def vectorize_documents(documents, embedding_model):
    """向量化文档"""
    embeddings = []
    for doc in documents:
        embedding = embedding_model.encode(doc['content'])
        embeddings.append({
            'file': doc['file'],
            'chunk_id': doc['chunk_id'],
            'content': doc['content'],
            'embedding': embedding
        })
    return embeddings

# 向量化知识库
vectorized_documents = vectorize_documents(documents, embedding_model)

# 打印结果
for vec_doc in vectorized_documents[:2]:
    print(f"File: {vec_doc['file']}, Chunk ID: {vec_doc['chunk_id']}")
    print(f"Content: {vec_doc['content'][:100]}...")
    print(f"Embedding shape: {vec_doc['embedding'].shape}")
    print("-" * 50)

代码说明

  • vectorize_documents:遍历文档列表,使用嵌入模型为每个片段生成向量。
  • 每个向量化后的文档包含原始信息(文件名、片段 ID、内容)和嵌入向量。

性能优化提示

  • 如果知识库很大,可以使用批量编码(embedding_model.encode 支持传入句子列表)来加速:

    contents = [doc['content'] for doc in documents]
    embeddings = embedding_model.encode(contents, batch_size=32)
    

2.4 存储到向量数据库

2.4.1 FAISS 简介

FAISS(Facebook AI Similarity Search)是一个高效的向量搜索库,广泛用于 RAG 系统。它支持快速的向量相似度搜索,适合存储和查询文档嵌入。FAISS 的主要特点包括:

  • 高效性:支持大规模向量集的快速搜索。
  • 灵活性:提供多种索引类型(如 Flat、IVF、HNSW)。
  • 易用性:与 Python 集成良好。

在本教程中,我们将使用 FAISS 的 IndexFlatL2 索引,它基于欧几里得距离(L2 距离)进行精确搜索,适合小型知识库。

2.4.2 初始化 FAISS 索引

以下是使用 FAISS 存储向量化的文档的代码:

import faiss
import numpy as np

def create_faiss_index(embeddings):
    """创建 FAISS 索引"""
    # 获取嵌入向量的维度
    dimension = embeddings[0]['embedding'].shape[0]
    
    # 创建 FAISS 索引(基于 L2 距离)
    index = faiss.IndexFlatL2(dimension)
    
    # 提取所有嵌入向量
    embedding_vectors = np.array([doc['embedding'] for doc in embeddings]).astype('float32')
    
    # 添加到索引
    index.add(embedding_vectors)
    
    return index, embeddings

# 创建 FAISS 索引
faiss_index, vectorized_documents = create_faiss_index(vectorized_documents)

# 打印索引信息
print(f"Total vectors in index: {faiss_index.ntotal}")

代码说明

  • create_faiss_index:初始化一个 IndexFlatL2 索引,并将所有嵌入向量添加到索引中。
  • FAISS 要求输入向量为 float32 类型,因此使用 np.array 进行转换。
  • faiss_index.ntotal 返回索引中的向量总数。

2.4.3 保存 FAISS 索引

为了在后续使用中加载索引,我们需要将其保存到磁盘:

def save_faiss_index(index, file_path):
    """保存 FAISS 索引"""
    faiss.write_index(index, file_path)

# 保存索引
save_faiss_index(faiss_index, "rag_knowledge_base_index.faiss")

加载索引(示例):

def load_faiss_index(file_path):
    """加载 FAISS 索引"""
    return faiss.read_index(file_path)

# 加载索引
loaded_index = load_faiss_index("rag_knowledge_base_index.faiss")

2.5 实现基本检索功能

2.5.1 检索流程

现在,我们已经将知识库向量化并存储到 FAISS 中,可以实现基本的检索功能。检索的步骤如下:

  1. 将用户查询(query)向量化。
  2. 使用 FAISS 索引搜索与查询向量最相似的文档向量。
  3. 返回对应的文档内容。

以下是实现检索功能的代码:

def retrieve_documents(query, embedding_model, faiss_index, vectorized_documents, top_k=3):
    """检索相关文档"""
    # 将查询向量化
    query_embedding = embedding_model.encode(query).astype('float32').reshape(1, -1)
    
    # 搜索最相似的 top_k 个文档
    distances, indices = faiss_index.search(query_embedding, top_k)
    
    # 获取相关文档
    results = []
    for idx, distance in zip(indices[0], distances[0]):
        doc = vectorized_documents[idx]
        results.append({
            'file': doc['file'],
            'chunk_id': doc['chunk_id'],
            'content': doc['content'],
            'distance': float(distance)
        })
    
    return results

# 示例查询
query = "Which operating systems are supported by the product?"
results = retrieve_documents(query, embedding_model, faiss_index, vectorized_documents)

# 打印结果
for result in results:
    print(f"File: {result['file']}")
    print(f"Chunk ID: {result['chunk_id']}")
    print(f"Content: {result['content'][:100]}...")
    print(f"Distance: {result['distance']:.4f}")
    print("-" * 50)

代码说明

  • retrieve_documents:将查询向量化,使用 FAISS 的 search 方法查找最相似的文档。
  • top_k:控制返回的文档数量(默认为 3)。
  • distances:表示查询向量与文档向量之间的 L2 距离(越小越相似)。

示例输出

File: faq.txt
Chunk ID: faq.txt_0
Content: Q: 产品支持哪些操作系统? A: 我们的产品支持 Windows 10/11、macOS 12+  Ubuntu 20.04+。...
Distance: 0.1234
--------------------------------------------------
File: product_manual.pdf
Chunk ID: product_manual.pdf_1
Content: 本产品是一款高性能的智能设备,支持多种功能,包括实时数据处理和远程控制。...
Distance: 0.5678
--------------------------------------------------
...

2.5.2 检索结果分析

从输出中可以看到,查询“Which operating systems are supported by the product?”返回了 FAQ 文件中的相关片段,因为它直接包含了操作系统信息。其他返回的文档可能语义相关但不直接回答问题,这表明检索器能够捕捉语义相似性。

优化提示

  • 如果返回的文档不够精确,可以调整 top_k 或尝试其他嵌入模型。
  • 对于大规模知识库,可以使用 FAISS 的 IndexIVFFlatIndexHNSW 索引来提高搜索效率。

2.6 常见问题与解决方案

在构建知识库和向量化过程中,Java 程序员可能遇到以下问题:

  1. PDF 解析不准确

    • 问题:PyPDF2 可能无法正确提取复杂 PDF 的文本(如包含表格或图像)。

    • 解决方案:使用更强大的库,如 pdfplumberpymupdf

    • 示例:

      pip install pdfplumber
      
      import pdfplumber
      def read_pdf_file(file_path):
          text = ""
          with pdfplumber.open(file_path) as pdf:
              for page in pdf.pages:
                  text += page.extract_text() + "\n"
          return text
      
  2. 嵌入模型性能不足

    • 问题:all-MiniLM-L6-v2 在某些领域(如法律或医疗)可能效果不佳。
    • 解决方案:尝试领域特定的嵌入模型(如 legal-bert)或更大的模型(如 all-mpnet-base-v2)。
  3. FAISS 索引过大

    • 问题:知识库过大时,FAISS 索引可能占用大量内存。
    • 解决方案:使用压缩索引(如 IndexIVFPQ)或分布式向量数据库(如 Milvus)。
  4. 文本分割粒度不合适

    • 问题:片段过长可能导致检索不精确,过短可能丢失上下文。
    • 解决方案:调整 max_length 参数,或使用语义分割算法(如基于主题的分割)。

2.7 整合代码

为了方便使用,我们将所有功能整合到一个完整的脚本中,包含文档处理、向量化、存储和检索:

import os
import PyPDF2
import markdown
import nltk
import faiss
import numpy as np
from sentence_transformers import SentenceTransformer
from nltk.tokenize import sent_tokenize

nltk.download('punkt')

class RAGKnowledgeBase:
    def __init__(self, knowledge_base_dir, embedding_model_name='all-MiniLM-L6-v2'):
        self.knowledge_base_dir = knowledge_base_dir
        self.embedding_model = SentenceTransformer(embedding_model_name)
        self.documents = []
        self.vectorized_documents = []
        self.faiss_index = None

    def read_txt_file(self, file_path):
        with open(file_path, 'r', encoding='utf-8') as f:
            return f.read()

    def read_pdf_file(self, file_path):
        text = ""
        with open(file_path, 'rb') as f:
            pdf = PyPDF2.PdfReader(f)
            for page in pdf.pages:
                text += page.extract_text() + "\n"
        return text

    def read_md_file(self, file_path):
        with open(file_path, 'r', encoding='utf-8') as f:
            md_text = f.read()
            html = markdown.markdown(md_text)
            return html.replace('<p>', '').replace('</p>', '\n').strip()

    def split_text(self, text, max_length=500):
        sentences = sent_tokenize(text)
        chunks = []
        current_chunk = ""
        for sentence in sentences:
            if len(current_chunk) + len(sentence) < max_length:
                current_chunk += sentence + " "
            else:
                chunks.append(current_chunk.strip())
                current_chunk = sentence + " "
        if current_chunk:
            chunks.append(current_chunk.strip())
        return chunks

    def process_knowledge_base(self):
        for root, _, files in os.walk(self.knowledge_base_dir):
            for file in files:
                file_path = os.path.join(root, file)
                if file.endswith('.txt'):
                    content = self.read_txt_file(file_path)
                elif file.endswith('.pdf'):
                    content = self.read_pdf_file(file_path)
                elif file.endswith('.md'):
                    content = self.read_md_file(file_path)
                else:
                    continue
                chunks = self.split_text(content)
                for i, chunk in enumerate(chunks):
                    self.documents.append({
                        'file': file,
                        'chunk_id': f"{file}_{i}",
                        'content': chunk
                    })

    def vectorize_documents(self):
        for doc in self.documents:
            embedding = self.embedding_model.encode(doc['content'])
            self.vectorized_documents.append({
                'file': doc['file'],
                'chunk_id': doc['chunk_id'],
                'content': doc['content'],
                'embedding': embedding
            })

    def create_faiss_index(self):
        dimension = self.vectorized_documents[0]['embedding'].shape[0]
        self.faiss_index = faiss.IndexFlatL2(dimension)
        embedding_vectors = np.array([doc['embedding'] for doc in self.vectorized_documents]).astype('float32')
        self.faiss_index.add(embedding_vectors)

    def save_faiss_index(self, file_path):
        faiss.write_index(self.faiss_index, file_path)

    def retrieve_documents(self, query, top_k=3):
        query_embedding = self.embedding_model.encode(query).astype('float32').reshape(1, -1)
        distances, indices = self.faiss_index.search(query_embedding, top_k)
        results = []
        for idx, distance in zip(indices[0], distances[0]):
            doc = self.vectorized_documents[idx]
            results.append({
                'file': doc['file'],
                'chunk_id': doc['chunk_id'],
                'content': doc['content'],
                'distance': float(distance)
            })
        return results

# 使用示例
if __name__ == "__main__":
    kb = RAGKnowledgeBase("rag_knowledge_base")
    kb.process_knowledge_base()
    kb.vectorize_documents()
    kb.create_faiss_index()
    kb.save_faiss_index("rag_knowledge_base_index.faiss")
    
    query = "Which operating systems are supported by the product?"
    results = kb.retrieve_documents(query)
    
    for result in results:
        print(f"File: {result['file']}")
        print(f"Chunk ID: {result['chunk_id']}")
        print(f"Content: {result['content'][:100]}...")
        print(f"Distance: {result['distance']:.4f}")
        print("-" * 50)

代码说明

  • RAGKnowledgeBase 类封装了所有功能,包括文档处理、向量化、索引创建和检索。
  • 通过面向对象的方式组织代码,便于后续扩展和维护。
  • 主程序展示了从知识库处理到检索的完整流程。

2.8 本章总结

本章详细讲解了如何构建知识库并实现文档向量化,为 RAG 系统的检索功能奠定了基础。你现在应该掌握了以下内容:

  • 知识库的准备,包括处理多种格式的文档(文本、PDF、Markdown)。
  • 使用 sentence-transformers 将文档向量化,生成语义嵌入。
  • 使用 FAISS 存储和查询向量,实现基本的检索功能。
  • 常见问题的解决方案,如 PDF 解析、嵌入模型选择和索引优化。

在下一章,我们将深入实现 RAG 的核心逻辑,包括查询处理、上下文增强和生成。我们将使用 langchain 框架整合嵌入模型、向量数据库和生成模型,构建一个完整的 RAG 流程。