如何使用OpenAI GPT-4胜过OpenAI的ChatGPT:构建一个优秀的文档聊天系统,超越文字
在不久之前,我写了一篇标题为“以思想的速度来尝试”(Tinkering at the Speed of Thought)的博客,探讨生成式人工智能(Generative AI)的出现如何使我们在实验和尝试新旧技术时发生了革命性的变化。 在尝试时有一种神奇的感觉 - 当你专注于流程中,用新的方式组合现有的元素,突然一切都恰到好处。 那一刻如悟时,你会想,“啊哈!我知道怎么做了!”
这个博客记录了其中的一个时刻。在尝试使用大型语言模型(LLMs)时,我发现自己对一个特定挑战着迷,这导致了一种突破性的感觉——即使只是持续了几分钟。你知道那种感觉:当你对某件事感到兴奋,想和懂得的人分享。
文档智能的演进
关于大型语言模型(LLMs)的讨论几乎总是会指向我认为的它们的杀手应用:“与您的数据或文档聊天”。这种能力,基本上由检索增强生成(RAG)驱动,已成为理解大型文档集合的首选解决方案。基本概念似乎很简单:对数据进行索引,将其向量化,执行相似性搜索,让LLM基于检索到的上下文生成响应。
对于玩具应用和简单用例,这是非常有效的。但是,和技术中的许多事情一样,魔鬼在细节中 — 在这种情况下,细节是视觉。
隐藏的挑战:当文件不仅仅是文本时
当前RAG系统的根本限制在您超越简单文本文档时变得显而易见。现实世界的商务文件是丰富的、多模态的工件,包含:
- 数据密集的表格显示季度业绩
- 趋势可视化和关键绩效指标图表
- 复杂系统架构图
- 技术插图和屏幕截图
- 流程图
- 数学方程和公式
即使是 ChatGPT,尽管具有所有能力,但在处理上传的 PDF 时完全忽略了这些视觉元素。那些花费数小时完美打磨的精心制作的图表呢?那个解释整个系统的详细架构图呢?对于当前的 RAG 系统来说,它们可能根本不存在。
突破时刻
这个限制困扰了我几周,直到我有了经典的调整顿悟之一。解决方案并不是强迫将所有内容转换为文本 - 而是拥抱文档的多模态性质。通过利用GPT-4的视觉功能以及传统的RAG方法,我们可以构建出更好地反映人类实际处理文档方式的东西。
构建更好的解决方案
关键的洞察是将文档视为各种信息类型的集合,每种类型都需要专门的处理流程。以下是我们如何构建一个比标准的RAG实现表现更好的系统:
它是如何运行的(如果您不是技术人员):
想象一下你的PDF文档就像一本充满文字和图片的剪贴簿。这种方式就像处理这样:
- 文档拆解:首先,我们将PDF拆分为基本组成部分 — 将纯文本放在一堆,将图片、图表和图表放在另一堆。这就好像拆开一个拼图,理解每个部分。
- 视觉理解:对于我们发现的每个视觉元素(图表、图解、表格、图片),我们展示给GPT-4 Vision并询问,“你在这里看到什么?” GPT-4 Vision然后提供了对每个视觉元素的详细描述 - 将图片转化为捕捉其含义和上下文的文字。
- 知识汇聚:我们将文档中的原始文本和GPT-4对视觉内容的描述整合在一起,形成一个单一的可搜索的知识库。这就像创建了一个超级详细的索引,既理解了文字的含义,也理解了显示的内容。
- 增强的RAG:当有人提问时,我们的系统现在可以搜索文档文本和人工智能生成的视觉元素描述。这就是RAG+,它能够回答关于所写内容和所描述内容的问题。
这种方法意味着当你询问“上季度的销售趋势是什么?”时,系统可以找到并理解这些信息,无论是书面文字还是图表展示 — 传统的红黄绿(RAG)系统无法做到这点。
如果你对技术内容感兴趣,我们来看看技术实现方面的内容。
1. 智能文档分割
首先,我们需要识别和分类文档中不同类型的内容:
def detect_visual_elements(image):
"""Detect and classify visual elements in the document."""
elements = []
# Convert to grayscale and apply adaptive thresholding
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
binary = cv2.adaptiveThreshold(
gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY_INV, 11, 2
)
# Find contours and classify elements
contours, _ = cv2.findContours(
binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
)
for contour in contours:
x, y, w, h = cv2.boundingRect(contour)
area = w * h
aspect_ratio = w / float(h)
if area > 1000: # Minimum size threshold
element_type = classify_element(aspect_ratio, area)
elements.append((element_type, (x, y, x + w, y + h)))
return elements
2. 专业视觉处理
每种视觉元素都使用GPT-4 Vision获得自己的处理管线。
def process_visual_element(image, element_type):
"""Process visual elements using GPT-4 Vision API."""
prompts = {
"table": "Describe this table's contents, including headers and key data points.",
"diagram": "Explain this diagram, including its components and relationships.",
"chart": "Analyze this chart, describing trends and key insights."
}
response = openai.ChatCompletion.create(
model="gpt-4-vision-preview",
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": prompts[element_type]},
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{encode_image(image)}",
"detail": "high"
}
}
]
}
]
)
return response.choices[0].message.content
3. 统一知识表示
魔法发生在当我们将所有东西结合成一个统一的知识库时:
class DocumentProcessor:
def __init__(self):
self.segments = []
self.index = None
def process_document(self, pdf_path):
pages = convert_from_path(pdf_path)
for page_num, page in enumerate(pages):
# Process text content
text_content = extract_text(page)
text_embedding = get_embedding(text_content)
# Process visual elements
visual_elements = detect_visual_elements(page)
for element_type, bbox in visual_elements:
description = process_visual_element(
extract_region(page, bbox),
element_type
)
visual_embedding = get_embedding(description)
self.segments.append({
'content': description,
'embedding': visual_embedding,
'type': element_type,
'page': page_num
})
self.build_index()
4. 智能查询处理
该系统通过同时考虑文本和视觉内容来处理查询:
def query_document(self, question):
# Get question embedding
query_embedding = get_embedding(question)
# Find relevant segments
relevant_segments = self.search_segments(query_embedding)
# Construct context from both text and visual elements
context = self.build_context(relevant_segments)
# Generate response using GPT-4
response = openai.ChatCompletion.create(
model="gpt-4o",
messages=[
{
"role": "system",
"content": "You are an assistant that helps understand documents, including both text and visual elements."
},
{
"role": "user",
"content": f"Context:\n{context}\n\nQuestion: {question}"
}
]
)
return response.choices[0].message.content
结果和影响
能力上的差异十分明显。当被问及“Q3的收入趋势是什么?”传统的红黄绿系统可能会很难找到这个隐藏在图表中的信息。我的增强系统可以:
- 定位相关的视觉元素
- 通过GPT-4视觉理解它们的内容
- 结合文本和图像的见解
- 提供全面、具上下文意识的回复
超越基本文档聊天
这种方法打开了令人兴奋的可能性:
- 视觉推理:该系统可以回答需要理解图表的问题
- 多模态理解:结合文本和视觉洞察,为更丰富的反应效果
- 更好的数据提取:准确地从表格和结构化内容中提取信息
- 上下文保持:保持视觉和文本元素之间的关系
回顾与展望
这种对RAG系统的调整之旅让我想起了我为什么喜欢与新兴技术一起工作。这不只是使用新工具,而是看到它们的局限性作为机会。每个限制都成为一个实验的邀请,用新颖的方式结合技术,有时候,体验到当一切都顺利时那激动人心的时刻。
尽管我在这里构建的东西可能不会彻底改革行业,但它代表了一种小而令人满意的突破,使技术变得如此令人兴奋。这是一个提醒,即使人工智能系统变得更加复杂,创造性的调试和现有工具的实验组合仍有大量空间。
构建这个增强型的文档聊天系统不仅仅是为了解决一个技术问题 - 它更重要的是抓住那些技术组件融合在一起创造出新事物时的神奇时刻。这不就是折腾的意义所在吗?
反思: 数据准备的艺术
在我多年来构建和设计AI启用的业务系统的经验中,一个真理始终如一: 您的系统的质量取决于您的数据准备。虽然这听起来像是一个陈词滥调,但在大语言模型时代这一点变得更加相关。
数据准备的演变
传统上,数据准备是一个耗时费工的过程,涉及到:
- 编写复杂的正则表达式模式以提取结构化数据
- 为不同文档格式创建自定义解析器
- 手动定义数据提取规则
- 为不同数据类型构建专门的管道
- 无尽的清洁和标准化数据的时间
通常有人引用数据科学家花费80%的时间准备数据。根据我的经验,当处理类似混合内容类型的文档之类的非结构化数据时,这个比例甚至可能更高。
数据准备的革命
这个项目令人着迷的地方在于它代表了数据准备中的一种范式转变。我们不再编写复杂的规则或构建专门的解析器,而是使用LLMs(特别是GPT-4 Vision)作为智能预处理器。这种方法:
- 将非结构化转化为结构化:我们不再尝试通过传统的计算机视觉技术解析图像和图表,而是使用GPT-4 Vision将视觉信息转化为丰富的、有上下文的描述。
- 标准化不同的数据类型:无论是复杂的建筑图,财务图表还是流程图,GPT-4 Vision将每种类型转换为统一的格式 - 详细的文本描述,保持原始含义。
- 丰富的上下文:与传统的数据提取方法不同,GPT-4 Vision 不仅仅识别元素,而且理解并描述它们之间的关系和重要性。图表不只是数字;它是趋势、模式和见解。
一个新的数据准备范式
这种方法代表了我认为是未来复杂、多模态文档数据准备的方向。
- 不要写规则,我们提示
- 不要解析,我们要求理解
- 不要提取,我们转换并保留
这是从机械加工到智能转型的转变。LLM不仅在帮助我们回答问题 - 它从根本上改变了我们准备数据以回答这些问题的方式。
这是非常强大的原因是:
- 它可在不需要专门解析器的情况下跨越不同类型的视觉内容进行缩放。
- 它可以在无需更新规则的情况下适应新格式和样式。
- 它保留了在传统提取方法中可能会丢失的上下文和关系。
- 它将各种不同的数据类型标准化为一致的、可搜索的格式。
在许多方面,这个项目不仅仅是关于构建一个更好的文档聊天系统 - 它是关于重新构想我们如何为AI系统准备和构造复杂的、非结构化数据。在我的经验中,这通常是AI技术驱动的商业系统中真正的突破所在。
如果您愿意进行一些实验,请随意调整或改进下面的代码。设置应该可以直接使用,只需进行一些微小调整即可。您需要几个Python库,以及安装了Tesseract进行OCR。此外,还需要访问OpenAI API,并且需要一些用于标记化和LLM推断的积分。请记住用您自己的文件名替换硬编码的PDF文件名。一旦设置好了您的OpenAI密钥并指定了到tesseract.exe的路径,您就可以开始了!编码愉快!
import openai
import pytesseract
from PIL import Image, ImageEnhance
import cv2
import numpy as np
import json
import faiss
import os
import base64
import fitz # PyMuPDF
from io import BytesIO
import logging
# Set up OpenAI API key
openai.api_key = "sk-----"
# Paths and settings
pytesseract.pytesseract.tesseract_cmd = r"C:\Programs\Tesseract-OCR\tesseract.exe"
vector_dim = 1536 # The output dimension of 'text-embedding-ada-002'
index = faiss.IndexFlatL2(vector_dim)
metadata_store = [] # To store metadata alongside embeddings
index_path = "faiss_index.bin"
metadata_path = "metadata.json"
min_contour_area = 10000 # Set higher to avoid small irrelevant images
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
# Function to save the Faiss index and metadata
def save_index_and_metadata():
faiss.write_index(index, index_path)
with open(metadata_path, 'w') as f:
json.dump(metadata_store, f)
logging.info("Index and metadata saved.")
# Function to load the Faiss index and metadata
def load_index_and_metadata():
global index, metadata_store
if os.path.exists(index_path) and os.path.exists(metadata_path):
index = faiss.read_index(index_path)
with open(metadata_path, 'r') as f:
metadata_store = json.load(f)
logging.info("Index and metadata loaded.")
else:
logging.info("No existing index or metadata found. Starting fresh.")
# Function to get embeddings using OpenAI's text-embedding-ada-002
def get_embedding(text):
try:
response = openai.Embedding.create(input=text, model="text-embedding-ada-002")
return np.array(response['data'][0]['embedding'])
except Exception as e:
logging.error(f"Error generating embedding: {e}")
return None
# Function to encode image as base64 directly from an image object
def encode_image_from_object(image):
with BytesIO() as buffer:
image.save(buffer, format="JPEG")
return base64.b64encode(buffer.getvalue()).decode('utf-8')
def classify_and_process_visual_segment(image, segment_type, detail="auto"):
"""Processes visual segments using base64 image encoding and GPT-4 vision capabilities."""
base64_image = encode_image_from_object(image)
# Generate a prompt based on the segment type
if segment_type == "table":
prompt = "Please provide a detailed description of this table, including any headers, columns, rows, and notable information be as details as possible."
elif segment_type == "diagram":
prompt = "Describe this diagram in a structured and in details, including labeled elements, relationships, and any flow of information. be as details as possible"
else:
prompt = "Provide a detailed description of this image capture all the information, including objects, scene details, and any significant information be as details as possible."
try:
# Send the request with the correctly formatted image
response = openai.ChatCompletion.create(
model="gpt-4o",
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{base64_image}",
"detail": detail # Use detail setting: low, high, or auto
},
},
],
}
],
max_tokens=1300
)
# Retrieve the description from GPT-4 response
description = response.choices[0].message.content
return description
except Exception as e:
logging.error(f"Error during OpenAI API call for {segment_type}: {e}")
return "Error generating description"
def enhance_image(image):
"""Enhance image quality by increasing contrast and sharpness."""
enhancer = ImageEnhance.Contrast(image)
image = enhancer.enhance(2.0) # Moderate contrast increase
enhancer = ImageEnhance.Sharpness(image)
image = enhancer.enhance(2.0) # Moderate sharpness increase
return image
def segment_and_process_pdf(pdf_path, output_folder, min_contour_area=10000, detail="auto"):
pdf_document = fitz.open(pdf_path)
metadata = {}
# Process each page
for page_number in range(pdf_document.page_count):
page = pdf_document.load_page(page_number)
logging.info(f"Processing page {page_number + 1}")
# Step 1: Extract text content
text = page.get_text("text")
if text.strip(): # Only proceed if there's text
embedding = get_embedding(text)
if embedding is not None:
index.add(np.expand_dims(embedding, axis=0))
metadata_store.append({
"page": page_number + 1,
"segment_id": "text",
"type": "text",
"details": text,
})
logging.info(f"Processed text content on page {page_number + 1}")
# Step 2: Render page for image processing
zoom = 2.5
mat = fitz.Matrix(zoom, zoom)
pix = page.get_pixmap(matrix=mat)
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
img = enhance_image(img)
# Convert to OpenCV format for contour detection
img_cv = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
gray = cv2.cvtColor(img_cv, cv2.COLOR_BGR2GRAY)
thresh = cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 11, 2)
contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# Step 3: Process each visual segment
for i, cnt in enumerate(contours):
x, y, w, h = cv2.boundingRect(cnt)
if w * h > min_contour_area:
segment_img = img_cv[y:y+h, x:x+w]
segment_pil = Image.fromarray(cv2.cvtColor(segment_img, cv2.COLOR_BGR2RGB))
segment_pil = enhance_image(segment_pil)
# Classify segment type and get detailed description
segment_type = "table" if "table" in pytesseract.image_to_string(segment_pil, config='--psm 6').lower() else "diagram"
description = classify_and_process_visual_segment(segment_pil, segment_type, detail)
# Save the image segment to disk
segment_path = f"{output_folder}/page_{page_number + 1}_segment_{i + 1}_{segment_type}.png"
segment_pil.save(segment_path)
# Create an embedding for the retrieved description
embedding = get_embedding(description)
if embedding is not None:
index.add(np.expand_dims(embedding, axis=0))
metadata_store.append({
"page": page_number + 1,
"segment_id": i + 1,
"type": segment_type,
"position": {"x": x, "y": y, "width": w, "height": h},
"details": description,
"image_path": segment_path
})
logging.info(f"Saved segment {i + 1} on page {page_number + 1} as {segment_type}")
pdf_document.close()
save_index_and_metadata()
return metadata
def retrieve_and_generate_answer(query, k_text=3, k_image=2):
# Step 1: Embed the query
query_embedding = get_embedding(query).astype("float32")
# Step 2: Separate text and image-based content
text_indices = [i for i, meta in enumerate(metadata_store) if meta["type"] == "text"]
image_indices = [i for i, meta in enumerate(metadata_store) if meta["type"] in ["table", "diagram"]]
# Retrieve top-k text and image-based content separately
top_text_segments = [metadata_store[i] for i in text_indices[:k_text]]
top_image_segments = [metadata_store[i] for i in image_indices[:k_image]]
# Log retrieved segments for debugging
logging.info("Retrieved Text Segments:")
for segment in top_text_segments:
logging.info(segment)
logging.info("Retrieved Image Segments:")
for segment in top_image_segments:
logging.info(segment)
# Combine text and image-based context in a balanced way
retrieved_context = "\n\n".join(
json.dumps(context) for context in top_text_segments + top_image_segments
)
# Step 4: Send the combined context and question to GPT-4 for a response
response = openai.ChatCompletion.create(
model="gpt-4o",
messages=[
{"role": "user", "content": f"Context:\n{retrieved_context}\n\nQuestion: {query}\n\nAnswer:"}
],
max_tokens=1300
)
return response.choices[0].message.content
def chat_with_document():
print("Chat with your document! Type 'exit' to end the chat.")
while True:
question = input("You: ")
if question.lower() == 'exit':
print("Ending chat.")
break
answer = retrieve_and_generate_answer(question)
print("Document Assistant:", answer)
# Initialize and load the index and metadata if available
load_index_and_metadata()
# Run the process if the index is empty (i.e., first time)
pdf_path = "Test.pdf" # Replace with your PDF path
output_folder = "output_non_text_elements"
os.makedirs(output_folder, exist_ok=True)
if index.ntotal == 0:
metadata = segment_and_process_pdf(pdf_path, output_folder, detail="auto")
# Start the chat
chat_with_document()