目录
一、数据下载
二、技术栈选择
三、程序逻辑
Step1:文档预处理
Step2:知识库构建
Step3:问答查询
概念区分:
今天我们要基于DeepSeek和Faiss搭建一个本地知识库检索。我们前面也做过一个基于 LangChain 的多文件RAG应用 。可以看看区别。
一、数据下载
从百度文库里面下载这样一个PDF(或者其他的内容也行)。使用向量相似度搜索从PDF文档中检索相关内容(检索Retrieval), 将检索到的文档片段作为上下文(增强Augmentation) ,基于上下文和用户问题生成答案(生成Generation)。
二、技术栈选择
• 向量数据库:Faiss作为高效的向量检索
• 嵌入模型:阿里云DashScope的text-embedding-v1
• 大语言模型:deepseek-v4
• 文档处理:PyPDF2用于PDF文本提取(复杂的PDF解析可以用MinerU PDF解析工具-MinerU)
三、程序逻辑
Step1:文档预处理
1) PDF文本提取
from PyPDF2 import PdfReader ..... PDF_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), '浦发上海浦东发展银行西安分行个金客户经理考核办法.pdf') # 读取PDF文件 pdf_reader = PdfReader(PDF_PATH) # 返回内容 # text: 提取的文本内容 # char_page_mapping: 每个字符对应的页码列表 text, char_page_mapping = extract_text_with_page_numbers(pdf_reader)2)提取文本和页码信息
def extract_text_with_page_numbers(pdf) -> Tuple[str, List[Tuple[str, int]]]: text = "" char_page_mapping = [] for page_number, page in enumerate(pdf.pages, start=1): extracted_text = page.extract_text() if extracted_text: text += extracted_text # 为当前页面的每个字符记录页码 char_page_mapping.extend([page_number] * len(extracted_text)) else: print(f"No text found on page {page_number}.") return text, char_page_mappingStep2:知识库构建
1) 文本分割并创建知识库knowledgeBase
# 创建文本分割器,用于将长文本分割成小块 text_splitter = RecursiveCharacterTextSplitter( separators=["\n\n", "\n", ".", " ", ""], chunk_size=1000, #1000个token chunk_overlap=200, length_function=len, ) # 分割文本 chunks = text_splitter.split_text(text) print(f"文本被分割成 {len(chunks)} 个块。") # 创建嵌入模型 embeddings = DashScopeEmbeddings( model="text-embedding-v1", dashscope_api_key=DASHSCOPE_API_KEY, ) # 从文本块创建知识库 knowledgeBase = FAISS.from_texts(chunks, embeddings) print("已从文本块创建知识库。")RecursiveCharacterTextSplitter是 LangChain 提供的文本切分工具,切分过程本身不会消耗 Token。
chunk_size:代表单个文本块允许的最大 Token 数量。separators:配置切分的分隔符。 该工具不会粗暴地把单词、句子从中间截断;达到最大长度限制时,会向前寻找分隔符完成切割。因此切分出来的文本块实际大小不一定恰好等于chunk_size,只是保证不超过该上限。
chunk_size需要根据业务问题的颗粒度来调整: 如果业务问题偏向细节查询,chunk_size就要设置得更小;如果设置得过大,文本向量化压缩的时候容易丢失大量细节信息。
chunk_overlap代表文本块之间的重叠 Token 数量。 例如设置chunk_overlap=200,就是前一个 chunk 的末尾 200 个 Token,会和下一个 chunk 的开头 200 个 Token 内容完全重复。它的核心作用是制造内容冗余: 防止完整的语义、关键句子刚好被切割在两个 chunk 的边界上,避免因为切分,把一整段完整语义拆开到两个不同文本块,导致检索的时候丢失上下文信息。
大模型也可以通过提示词的方式做文本切分的,但是成本会比较高(消耗token)。
3)页码映射处理
遍历切分的chunks,基于字符位置计算每个chunks的页码 ,如果chunks跨页了,使用众数统计确定文本块的主要来源页码,建立文本块与页码的映射关系。然后保存向量库
page_info = {} current_pos = 0 for chunk in chunks: chunk_start = current_pos chunk_end = current_pos + len(chunk) # 找到这个文本块中字符对应的页码 chunk_pages = char_page_mapping[chunk_start:chunk_end] # 取页码的众数(出现最多的页码)作为该块的页码 if chunk_pages: # 统计每个页码出现的次数 page_counts = {} for page in chunk_pages: page_counts[page] = page_counts.get(page, 0) + 1 # 找到出现次数最多的页码 most_common_page = max(page_counts, key=page_counts.get) page_info[chunk] = most_common_page else: page_info[chunk] = 1 # 默认页码 current_pos = chunk_end knowledgeBase.page_info = page_info print(f'页码映射完成,共 {len(page_info)} 个文本块') # 如果提供了保存路径,则保存向量数据库和页码信息 if save_path: # 确保目录存在 os.makedirs(save_path, exist_ok=True) # 保存FAISS向量数据库 knowledgeBase.save_local(save_path) print(f"向量数据库已保存到: {save_path}") # 保存页码信息到同一目录 with open(os.path.join(save_path, "page_info.pkl"), "wb") as f: pickle.dump(page_info, f) print(f"页码信息已保存到: {os.path.join(save_path, 'page_info.pkl')}")保存下来的文件如下:
• Faiss索引文件(.faiss) • 元数据信息(.pkl) • 页码映射关系(page_info.pkl)
Step3:问答查询
相似度检索,在Faiss中搜索最相似的文档块,similarity_search返回Top-K相关文档,构建提示词prompt,调用LLM(llm.invoke(prompt))获得结果
from langchain_community.llms import Tongyi llm = Tongyi(model_name="deepseek-v4", dashscope_api_key=DASHSCOPE_API_KEY) # qwen-turbo # 设置查询问题 query = "客户经理被投诉了,投诉一次扣多少分" #query = "客户经理每年评聘申报时间是怎样的?" if query: # 执行相似度搜索,找到与查询相关的文档 docs = knowledgeBase.similarity_search(query,k=10) # 构建上下文 context = "\n\n".join([doc.page_content for doc in docs]) # 构建提示 prompt = f"""根据以下上下文回答问题: {context} 问题: {query}""" # 直接调用 LLM response = llm.invoke(prompt) print(response) print("来源:")输出结果:
以上代码可以替换为下面的代码:
# 替换前 docs = knowledgeBase.similarity_search(query, k=10) context = "\n\n".join([doc.page_content for doc in docs]) # 手动拼 prompt = f"""根据以下上下文回答问题:\n\n{context}\n\n问题: {query}""" # 手动拼 prompt response = llm.invoke(prompt) # 手动调 #---------------------------------------------------------------------- # 替换后 from langchain_classic.chains.question_answering import load_qa_chain docs = knowledgeBase.similarity_search(query, k=10) qa_chain = load_qa_chain(llm, chain_type="stuff") response = qa_chain.invoke({"input_documents": docs, "question": query})["output_text"]chain_type有如下四种(一般默认第一种就好了):
本篇内容和基于 LangChain 的多文件RAG应用 逻辑一致,代码略有不同。大家可以对比看一下
概念区分:
Jieba:句子里面加空格(分词),word2vec训练Embedding时用:Embedding和Word2Vec
Chunk:知识的最小颗粒度,用Embedding模型转成向量后放入向量数据库中FAISS.from_texts(chunks, embeddings)
Embedding模型和向量数据库介绍:Embedding模型和向量数据库的选择
Tokenizer:LLM如何理解文字的,文字和token的map映射
Q:如果LLM可以处理无限上下文了,RAG还有意义吗?
效率与成本:LLM处理长上下文时计算资源消耗大,响应时间增加。RAG通过检索相关片段,减少输入长度。
知识更新:LLM的知识截止于训练数据,无法实时更新。RAG可以连接外部知识库,增强时效性。
可解释性:RAG的检索过程透明,用户可查看来源,增强信任。LLM的生成过程则较难追溯。
定制化:RAG可针对特定领域定制检索系统,提供更精准的结果,而LLM的通用性可能无法满足特定需求。
数据隐私:RAG允许在本地或私有数据源上检索,避免敏感数据上传云端,适合隐私要求高的场景。
=> 结合LLM的生成能力和RAG的检索能力,可以提升整体性能,提供更全面、准确的回答。