# ChatPDF-Faiss智能文档问答
## 概述
ChatPDF-Faiss是一个基于大语言模型的PDF智能问答系统,能够对PDF文档内容进行语义理解和问答。系统使用FAISS向量数据库技术实现高效的相似度检索,并结合大语言模型提供准确的问答服务。
## 核心功能
### 1. PDF文本提取与页码关联
- 从PDF中提取文本内容
- 记录每段文本对应的原始页码信息
- 便于回溯答案的具体出处
### 2. 文本分块与向量化处理
- 使用RecursiveCharacterTextSplitter将长文本分割成适合向量化的小块
- 通过DashScope的文本嵌入模型将文本块转化为语义向量
- 构建FAISS向量索引,实现高效的相似度检索
### 3. 知识库持久化存储
- 将向量数据库保存到本地磁盘
- 存储文本块与对应页码的映射关系
- 支持后续快速加载,无需重复处理文档
### 4. 语义检索与问答
- 基于用户查询进行语义相似度匹配
- 检索与问题最相关的文本块
- 使用大语言模型(通义千问/DeepSeek)生成准确的答案
- 提供答案来源页码,增强可信度与可追溯性
## 技术栈
-**PDF处理**:PyPDF2
-**文本分割**:LangChain RecursiveCharacterTextSplitter
-**向量嵌入**:DashScopeEmbeddings (阿里云通义千亿级开源向量模型)
-**向量数据库**:FAISS (Facebook AI Similarity Search)
-**大语言模型**:通义/DeepSeek系列模型
-**问答链**:LangChain QA Chain
## 工作流程
1.**文档处理阶段**:
- 提取PDF文本内容与页码信息
- 文本分割为适当大小的块
- 向量化处理并构建FAISS索引
- 存储向量数据库和页码映射信息
2.**问答查询阶段**:
- 接收用户问题
- 问题向量化并在FAISS中检索相似文本块
- 将相关文本块和问题一起传入大语言模型
- 生成答案并提供来源页码
## 系统优势
-**高性能检索**:FAISS向量库支持亿级向量的快速相似度搜索
-**精准溯源**:回答问题时提供具体页码来源
-**灵活扩展**:支持不同的嵌入模型和大语言模型
-**本地存储**:向量数据库可持久化存储,支持增量更新
## 使用方法
1. 设置环境变量`DASHSCOPE_API_KEY`
2. 准备PDF文件,命名为"file.pdf"放在当前目录
3. 运行脚本,系统会自动处理文档并创建向量数据库
4. 设置查询问题,获取智能回答及来源页码
1 from PyPDF2 import PdfReader
2 from langchain.chains.question_answering import load_qa_chain
3 from langchain_community.callbacks.manager import get_openai_callback
4 from langchain.text_splitter import RecursiveCharacterTextSplitter
5 from langchain_community.embeddings import DashScopeEmbeddings
6 from langchain_community.vectorstores import FAISS
7 from typing import List, Tuple
8 import os
9 import pickle
10
11 DASHSCOPE_API_KEY = os.getenv('DASHSCOPE_API_KEY')
12 if not DASHSCOPE_API_KEY:
13 raise ValueError("请设置环境变量 DASHSCOPE_API_KEY")
14
15 def extract_text_with_page_numbers(pdf) -> Tuple[str, List[int]]:
16 """
17 从PDF中提取文本并记录每行文本对应的页码
18
19 参数:
20 pdf: PDF文件对象
21
22 返回:
23 text: 提取的文本内容
24 page_numbers: 每行文本对应的页码列表
25 """
26 text = ""
27 page_numbers = []
28
29 for page_number, page in enumerate(pdf.pages, start=1):
30 extracted_text = page.extract_text()
31 if extracted_text:
32 text += extracted_text
33 page_numbers.extend([page_number] * len(extracted_text.split("\n")))
34
35 return text, page_numbers
36
37 def process_text_with_splitter(text: str, page_numbers: List[int], save_path: str = None) -> FAISS:
38 """
39 处理文本并创建向量存储
40
41 参数:
42 text: 提取的文本内容
43 page_numbers: 每行文本对应的页码列表
44 save_path: 可选,保存向量数据库的路径
45
46 返回:
47 knowledgeBase: 基于FAISS的向量存储对象
48 """
49 # 创建文本分割器,用于将长文本分割成小块
50 text_splitter = RecursiveCharacterTextSplitter(
51 separators=["\n\n", "\n", ".", " ", ""],
52 chunk_size=1000,
53 chunk_overlap=200,
54 length_function=len,
55 )
56
57 # 分割文本
58 chunks = text_splitter.split_text(text)
59 print(f"文本被分割成 {len(chunks)} 个块。")
60
61 # 创建嵌入模型
62 embeddings = DashScopeEmbeddings(
63 model="text-embedding-v1",
64 dashscope_api_key=DASHSCOPE_API_KEY,
65 )
66
67 # 从文本块创建知识库
68 knowledgeBase = FAISS.from_texts(chunks, embeddings)
69 print("已从文本块创建知识库。")
70
71 # 改进:存储每个文本块对应的页码信息
72 # 创建原始文本的行列表和对应的页码列表
73 lines = text.split("\n")
74
75 # 为每个chunk找到最匹配的页码
76 page_info = {}
77 for chunk in chunks:
78 # 查找chunk在原始文本中的开始位置
79 start_idx = text.find(chunk[:100]) # 使用chunk的前100个字符作为定位点
80 if start_idx == -1:
81 # 如果找不到精确匹配,则使用模糊匹配
82 for i, line in enumerate(lines):
83 if chunk.startswith(line[:min(50, len(line))]):
84 start_idx = i
85 break
86
87 # 如果仍然找不到,尝试另一种匹配方式
88 if start_idx == -1:
89 for i, line in enumerate(lines):
90 if line and line in chunk:
91 start_idx = text.find(line)
92 break
93
94 # 如果找到了起始位置,确定对应的页码
95 if start_idx != -1:
96 # 计算这个位置对应原文中的哪一行
97 line_count = text[:start_idx].count("\n")
98 # 确保不超出页码列表长度
99 if line_count < len(page_numbers):
100 page_info[chunk] = page_numbers[line_count]
101 else:
102 # 如果超出范围,使用最后一个页码
103 page_info[chunk] = page_numbers[-1] if page_numbers else 1
104 else:
105 # 如果无法匹配,使用默认页码-1(这里应该根据实际情况设置一个合理的默认值)
106 page_info[chunk] = -1
107
108 knowledgeBase.page_info = page_info
109
110 # 如果提供了保存路径,则保存向量数据库和页码信息
111 if save_path:
112 # 确保目录存在
113 os.makedirs(save_path, exist_ok=True)
114
115 # 保存FAISS向量数据库
116 knowledgeBase.save_local(save_path)
117 print(f"向量数据库已保存到: {save_path}")
118
119 # 保存页码信息到同一目录
120 with open(os.path.join(save_path, "page_info.pkl"), "wb") as f:
121 pickle.dump(page_info, f)
122 print(f"页码信息已保存到: {os.path.join(save_path, 'page_info.pkl')}")
123
124 return knowledgeBase
125
126 def load_knowledge_base(load_path: str, embeddings = None) -> FAISS:
127 """
128 从磁盘加载向量数据库和页码信息
129
130 参数:
131 load_path: 向量数据库的保存路径
132 embeddings: 可选,嵌入模型。如果为None,将创建一个新的DashScopeEmbeddings实例
133
134 返回:
135 knowledgeBase: 加载的FAISS向量数据库对象
136 """
137 # 如果没有提供嵌入模型,则创建一个新的
138 if embeddings is None:
139 embeddings = DashScopeEmbeddings(
140 model="text-embedding-v1",
141 dashscope_api_key=DASHSCOPE_API_KEY,
142 )
143
144 # 加载FAISS向量数据库,添加allow_dangerous_deserialization=True参数以允许反序列化
145 knowledgeBase = FAISS.load_local(load_path, embeddings, allow_dangerous_deserialization=True)
146 print(f"向量数据库已从 {load_path} 加载。")
147
148 # 加载页码信息
149 page_info_path = os.path.join(load_path, "page_info.pkl")
150 if os.path.exists(page_info_path):
151 with open(page_info_path, "rb") as f:
152 page_info = pickle.load(f)
153 knowledgeBase.page_info = page_info
154 print("页码信息已加载。")
155 else:
156 print("警告: 未找到页码信息文件。")
157
158 return knowledgeBase
159
160 def answer_question(knowledgeBase, llm, query, k=2):
161 """
162 回答用户问题
163
164 参数:
165 knowledgeBase: FAISS向量数据库
166 llm: 大语言模型
167 query: 用户问题
168 k: 检索的文档数量
169
170 返回:
171 answer: 回答内容
172 sources: 来源页码列表
173 """
174 # 执行相似度搜索,找到与查询相关的文档
175 docs = knowledgeBase.similarity_search(query, k=k)
176
177 # 加载问答链
178 chain = load_qa_chain(llm, chain_type="stuff")
179
180 # 准备输入数据
181 input_data = {"input_documents": docs, "question": query}
182
183 # 使用回调函数跟踪API调用成本
184 with get_openai_callback() as cost:
185 # 执行问答链
186 response = chain.invoke(input=input_data)
187 print(f"查询已处理。成本: {cost}")
188
189 # 记录唯一的页码
190 unique_pages = set()
191
192 # 收集每个文档块的来源页码
193 for doc in docs:
194 text_content = getattr(doc, "page_content", "")
195 source_page = knowledgeBase.page_info.get(
196 text_content.strip(), "未知"
197 )
198
199 if source_page not in unique_pages:
200 unique_pages.add(source_page)
201
202 return response["output_text"], list(unique_pages)
203
204 def initialize_system():
205 """初始化系统,加载或创建知识库"""
206 save_dir = "./vector_db"
207
208 # 检查是否已经存在向量数据库
209 if os.path.exists(save_dir) and os.path.isdir(save_dir):
210 print("检测到已有向量数据库,正在加载...")
211 try:
212 # 创建嵌入模型
213 embeddings = DashScopeEmbeddings(
214 model="text-embedding-v1",
215 dashscope_api_key=DASHSCOPE_API_KEY,
216 )
217 # 从磁盘加载向量数据库
218 knowledgeBase = load_knowledge_base(save_dir, embeddings)
219 print("成功加载已有知识库!")
220 except Exception as e:
221 print(f"加载失败: {e}")
222 print("将创建新的知识库...")
223 knowledgeBase = create_new_knowledge_base(save_dir)
224 else:
225 print("未检测到向量数据库,将创建新的知识库...")
226 knowledgeBase = create_new_knowledge_base(save_dir)
227
228 # 初始化大语言模型
229 from langchain_community.llms import Tongyi
230 llm = Tongyi(model_name="deepseek-v3", dashscope_api_key=DASHSCOPE_API_KEY)
231
232 return knowledgeBase, llm
233
234 def create_new_knowledge_base(save_dir):
235 """创建新的知识库"""
236 # 检查PDF文件是否存在
237 pdf_path = './file.pdf'
238 if not os.path.exists(pdf_path):
239 raise FileNotFoundError(f"未找到PDF文件: {pdf_path},请确保文件存在!")
240
241 # 读取PDF文件
242 pdf_reader = PdfReader(pdf_path)
243
244 # 提取文本和页码信息
245 text, page_numbers = extract_text_with_page_numbers(pdf_reader)
246 print(f"提取的文本长度: {len(text)} 个字符。")
247
248 # 处理文本并创建知识库,同时保存到磁盘
249 knowledgeBase = process_text_with_splitter(text, page_numbers, save_path=save_dir)
250 return knowledgeBase
251
252 def chat_interface():
253 """交互式聊天界面"""
254 print("\n" + "="*50)
255 print("欢迎使用 ChatPDF-Faiss 智能文档问答系统")
256 print("="*50)
257
258 # 初始化系统
259 try:
260 knowledgeBase, llm = initialize_system()
261 except Exception as e:
262 print(f"系统初始化失败: {e}")
263 return
264
265 print("\n系统已准备就绪!您可以开始提问了。")
266 print("输入 'exit' 或 'quit' 退出对话。\n")
267
268 # 开始对话循环
269 while True:
270 # 获取用户输入
271 query = input("\n请输入您的问题: ")
272
273 # 检查是否退出
274 if query.lower() in ['exit', 'quit', '退出', '结束']:
275 print("感谢使用,再见!")
276 break
277
278 # 如果输入为空,继续循环
279 if not query.strip():
280 continue
281
282 try:
283 # 回答问题
284 answer, sources = answer_question(knowledgeBase, llm, query)
285
286 # 打印回答
287 print("\n回答:")
288 print(answer)
289
290 # 打印来源
291 if sources:
292 print("\n来源页码:", end=" ")
293 print(", ".join(str(page) for page in sources if page != -1))
294 except Exception as e:
295 print(f"\n处理问题时出错: {e}")
296 print("请尝试重新提问或检查系统配置。")
297
298 if __name__ == "__main__":
299 chat_interface()