LLM 本地知识库构建:文档分割与向量化方案探讨
最近在研究如何将大语言模型结合本地知识库进行问答。虽然网上有很多基于 LangChain 的教程,通过文本分割调用模型向量化的 API,这种方式简单但存在前提限制:
- 若不使用 ChatGPT 等强效模型的 Embedding API,自建模型效果可能较差。
- 尽管有多种切分方式,仍容易撕裂文档中的语义连贯性。
由于使用外部 Embedding API 的限制,搭建本地知识库时往往需要自行处理文档预处理步骤。本文总结了一些关于文档预处理、分词及向量化的技术方案,供参考。
构建本地知识库的前提
在构建本地知识库问答系统时,第一步是对本地知识文档进行处理。为了降低使用门槛,通常不希望人工参与分段或摘要。但直接使用全文喂给大模型会超出 Token 限制,因此需要将文档知识转化为向量存储到向量数据库中。问答时,先在向量数据库匹配问题,将结果提供给 LLM 整理回答。
将文档转为向量数据通常分为两个步骤:Tokenizer 和 Embedding。
- Tokenizer:负责将文本拆分成词元(Token)。它将字符序列转换为词元序列。常见的有基于空格、标点的简单 Tokenizer,以及基于字典的复杂 Tokenizer。
- Embedding:将词元转换成稠密向量表示。它为每个词元映射到向量空间,使语义相关的词元向量更相近。
我们最终通过 Embedding 得到词汇或语句的向量。由于文档长度通常超过模型 Token 限制,且搜索目标是相关联的知识而非整篇文档,因此第一步是将文档拆分成合适的片段。
LangChain 文档拆分示例
使用 LangChain 进行文档拆分较为简单。以下是一个基础 Demo:
from langchain.document_loaders import UnstructuredFileLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
loader = UnstructuredFileLoader(file_path)
document = loader.load()
text_splitter = RecursiveCharacterTextSplitter(chunk_size=512, chunk_overlap=20)
split_documents = text_splitter.split_documents(document)
LangChain 提供了多种分割器,如字符文本分割器、Markdown 文本分割器等。切分后的文本送入 Tokenizer 处理,再进行 Embedding,即可构建简单的本地知识库。
现有方式的局限性
- 上下文撕裂:分割器难以理解文档整体上下文,若切分点破坏了段落关系,会导致向量搜索效果差,进而影响 LLM 回答质量。
- 垂直领域表现:通用分割器在专业文档中容易拆分专业术语或完整语句。
文本分割对知识库至关重要。LLM 无法直接理解自然语言,有效的分词确保细微差别被准确捕捉。
进阶处理方案
目前主要考虑纯文本类型文档(Word, PDF, TXT, Markdown),暂不考虑图表穿插场景。
1. 层级摘要
大部分文本文档具有段落层级关系。最简单的方式是按段落分割,但长段落可能超过 Token 限制。针对此情况,可使用摘要模型对长段落生成包含主要信息的短版本,再使用支持长序列的模型(如 Longformer 或 BigBird)进行向量化。
伪代码示例
from transformers import BartForConditionalGeneration, BartTokenizer
from transformers import LongformerModel, LongformerTokenizer
import torch
document_paragraphs = [
"First long paragraph ...",
"Second long paragraph ...",
]
summary_model_name = 'facebook/bart-large-cnn'
summary_tokenizer = BartTokenizer.from_pretrained(summary_model_name)
summary_model = BartForConditionalGeneration.from_pretrained(summary_model_name)
longformer_tokenizer = LongformerTokenizer.from_pretrained('allenai/longformer-base-4096')
longformer_model = LongformerModel.from_pretrained('allenai/longformer-base-4096')
def summarize_paragraphs(paragraphs):
summarized_paragraphs = []
for paragraph in paragraphs:
inputs = summary_tokenizer(paragraph, return_tensors="pt", max_length=1024, truncation=True)
summary_ids = summary_model.generate(inputs['input_ids'], num_beams=4, max_length=200, early_stopping=True)
summary = summary_tokenizer.decode(summary_ids[0], skip_special_tokens=True)
summarized_paragraphs.append(summary)
return summarized_paragraphs
def encode_paragraphs_with_longformer(paragraphs):
inputs = longformer_tokenizer(paragraphs, return_tensors="pt", padding=True, truncation=True)
outputs = longformer_model(**inputs)
return outputs.last_hidden_state
summarized_paragraphs = summarize_paragraphs(document_paragraphs)
document_encoding = encode_paragraphs_with_longformer(summarized_paragraphs)
优缺点分析:
- 优点:高效处理长文档,保持关键信息,利用稀疏注意力机制维持上下文联系。
- 缺点:摘要准确性依赖模型能力,资源消耗较大,自动摘要可能丢失细节。
2. 滑动窗口
滑动窗口是最简单且易于实现的方式。对长段落使用滑动窗口分块,确保窗口间有重叠部分(Overlap),使每个窗口包含相邻窗口的上下文信息。
核心逻辑
- 分块:设置窗口大小(Window Size)和步长(Step)。步长小于窗口大小可产生重叠。
- 向量化:对每个窗口进行向量化。
- 聚合:将窗口向量合成为文档向量,常用方法包括平均池化、最大池化或加权平均。
代码示例
import torch
from transformers import BertTokenizer, BertModel
import numpy as np
import os
def sliding_window_split(text, window_size, step):
chunks = []
start = 0
text = text.strip()
while start < len(text):
end = start + window_size
chunks.append(text[start:end])
start += step
if end >= len(text) and start < len(text):
chunks.append(text[start:])
break
return chunks
tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
model = BertModel.from_pretrained('bert-base-uncased')
def vectorize(chunk):
inputs = tokenizer(chunk, return_tensors='pt', max_length=512, truncation=True, padding='max_length')
outputs = model(**inputs)
chunk_vector = outputs.last_hidden_state.mean(dim=1)
return chunk_vector
def aggregate_vectors(vectors):
return torch.mean(torch.stack(vectors), dim=0)
dir_path = './data'
file_vectors_aggr = {}
for file in os.listdir(dir_path):
file_path = os.path.join(dir_path, file)
with open(file_path, 'r') as f:
text = f.read()
chunks = sliding_window_split(text, , )
vectors = [vectorize(c) c chunks]
aggregated_vector = aggregate_vectors(vectors)
file_vectors_aggr[file] = aggregated_vector
question =
q_vec = vectorize(question).detach()
file, vector file_vectors_aggr.items():
vector_np = vector.detach().numpy().flatten()
q_vec_np = q_vec.numpy().flatten()
similarity = np.dot(vector_np, q_vec_np) / (np.linalg.norm(vector_np) * np.linalg.norm(q_vec_np))
similarity > :
()
聚合的好处:
- 保持文档完整性,捕捉综合信息。
- 提高检索效率,避免与每个片段逐一比较。
- 减少信息丢失,增强上下文理解。
3. 自定义稀疏注意力与 LoRA
对于超长文档,传统全连接注意力计算量过大。稀疏注意力模式仅计算相关 token 间的注意力。此外,可通过微调(Fine-tuning)适配特定领域。
LoRA 微调示例
LoRA(Low-Rank Adaptation)是一种高效的微调技术,通过低秩矩阵更新权重,减少显存占用。
import torch
import torch.nn as nn
from transformers import BertModel, BertConfig
class LoRA_BERT(nn.Module):
def __init__(self, bert_model_name, rank):
super(LoRA_BERT, self).__init__()
self.bert = BertModel.from_pretrained(bert_model_name)
self.rank = rank
self.lora_adjustments = nn.ModuleDict()
for i, layer in enumerate(self.bert.encoder.layer):
self.lora_adjustments[f"layer_{i}"] = LoRAAdjustment(layer.attention.self.query, rank)
def forward(self, input_ids, attention_mask):
outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask)
sequence_output = outputs.last_hidden_state
return sequence_output
class LoRAAdjustment(nn.Module):
def __init__(self, query_layer, rank):
super(LoRAAdjustment, self).__init__()
self.query_layer = query_layer
self.rank = rank
hidden_size = query_layer.out_features
self.delta_W = nn.Parameter(torch.randn(hidden_size, rank))
self.delta_b = nn.Parameter(torch.randn(hidden_size))
def ():
adjusted_weight = .query_layer.weight + .delta_W @ .delta_W.t()
adjusted_bias = .query_layer.bias + .delta_b
torch.nn.functional.linear(x, adjusted_weight, adjusted_bias)
bert_model_name =
model = LoRA_BERT(bert_model_name, rank=)
实施建议:
- 需深入研究 Transformer 内部结构。
- 适合研究人员或对模型定制要求高的场景。
- 对于一般应用,LoRA 微调比从头训练更高效。
向量数据库与检索策略
构建知识库不仅涉及文档处理,还需选择合适的向量数据库进行存储与检索。
常见选型
- FAISS:Facebook 开源的高效相似度搜索库,适合单机部署,支持多种索引类型(IVF, HNSW)。
- Milvus:云原生向量数据库,支持分布式部署,适合大规模数据场景。
- ChromaDB:轻量级,易于集成,适合开发测试环境。
检索优化
- 混合检索:结合关键词检索(BM25)与向量检索,提升召回率。
- 重排序(Rerank):初步检索后使用 Cross-Encoder 模型对 Top-K 结果进行精排,提高准确率。
- 多路召回:针对不同字段或内容类型建立多个索引,合并结果。
总结
通过几种方法的尝试,除非对大模型本身能力进行优化,否则每一种方式都存在限制。如果不考虑硬件成本和技术开发成本,训练或调优出特定的领域模型才是最优解。如果基座模型足够强大,直接使用 LangChain 进行文档分割也足以应对多数场景。
不同的分割器和参数组合能找到相对较好的方案。如果有条件(数据量充足且有硬件资源),建议对开源模型进行调优,使其更好地适配本地文库知识。例如构建公司内部文档知识库时,新增公司文档后,调优后的模型会有更好的表现。
在实际落地中,应权衡精度、延迟与成本。对于初创项目,滑动窗口 + 现成 Embedding 模型是性价比最高的选择;对于高并发、高精度要求的场景,建议引入 Rerank 机制并考虑模型微调。