金融、法律和医疗领域的专业人士深知,从无尽的银行对账单、法律合同、医疗记录等海量非结构化数据中提炼出有意义的信息。
幸运的是,我们生活在一个大型语言模型(LLM)比以往任何时候都更容易获取的时代,如今构建一个由人工智能驱动的应用程序来识别模式、趋势和关联,以辅助(而非替代)人类团队做出数据驱动的决策,相对来说已经相当简单。
但有个前提?那就是你必须能够可靠地从非结构化的PDF中提取结构化数据作为起点。
瓶颈:简单的文本提取远远不够
虽然我们确实可以尝试使用任何基于PDF.js的库来从PDF中提取原始文本,但我们永远无法保留表格格式的数据——在处理银行对账单、财务报告、保险文件等时,这是一个大问题。
核心问题在于,PDF从未被设计为数据存储库。相反,它们是为了保持文档的一致性显示而设计的,旨在跨设备和操作系统保留文档的外观(字体、文本、光栅/矢量图像、表格、表单等)而不是被束缚在你可能习惯使用的结构化数据模型(如JSON或XML)上。
这种缺乏固有模式的情况使得数据提取变得棘手,因为PDF中的内容不是按逻辑组织的,而是按视觉组织的。
基于规则的模板可以在一定程度上解决这个问题,但这是一个脆弱的解决方案,在规模上会崩溃。例如,每家银行都有自己的对账单格式,甚至微小的设计调整都可能破坏基于模板的提取。为每家银行手动创建和维护模板是不切实际的——毕竟,模板本质上是静态的,需要不断维护——这是一个糟糕的长期选择。
如果不正确地完成这一关键的第一步,那么无法保留上下文的线性、无格式文本永远无法可靠地或大规模地为你提供非确定性法学硕士的结果。我永远不会把生意押在这上面。
有更好的选择吗?
一段时间以来,我一直在寻找一种替代方案,它能够将强大的文档处理能力与灵活、可扩展的API(因此不是独立的桌面应用程序)结合起来,无缝集成到我们的开发流程中——而无需强制定制或持续手动监督的麻烦。并且由于安全至关重要,我需要它能够在我们自己的基础设施上部署。
Apryse满足了所有要求。
Apryse是一个集文档管理功能于一体的原生工具包——它提供了适用于网络、移动、客户端和服务器使用的库——涵盖了PDF查看、注释、编辑、创建、生成等功能,并且最符合我需求的是:通过其服务器SDK进行数据提取,以JSON、XML甚至XLSX格式提供数据。
“智能数据处理”
Apryse的独到之处在于其底层复杂的神经网络,该网络利用深度学习模型智能地从PDF中提取结构化数据。本质上,Apryse库使用了一系列已经“学会”识别PDF中表格数据样式的模型——包括网格、列、行——以及它们之间的相对位置,以及它们与段落文本、光栅/矢量图像等的区别。
只需初始化库,将PDF作为输入,它就能以反映页面布局的方式提供解析后的结构化数据——识别表格、页眉、页脚、行和列,并提取段落/文本内容以及其阅读顺序和位置数据(边界框坐标和基线)。
// output.json
{
"pages": [
{
"elements": [
{
"columnWidths": [138, 154],
"groupId": 0,
"rect": [70, 697, 362, 524],
"trs": [
{
"rect": [70, 697, 362, 675],
"tds": [
{
"colStart": 0,
"contents": [
{
"baseline": 674.4262771606445,
"rect": [
71.999997, 698.7012722546347, 190.82616323890954,
665.7694694275744
],
"text": "ABC Bank",
"type": "textLine"
}
],
"rect": [70, 697, 208, 675],
"rowStart": 0,
"type": "td"
}
],
"type": "tr"
}
// more rows here
],
"type": "table"
}
// more elements here
]
}
// more pages here
]
}
你所获得的是高度结构化的输出,这使得你的下游流程能够分析或重新格式化数据以获取更深入的见解——非常适合我们的洞察流程下一阶段中被大型语言模型(LLM)所吸收。
构建我们的流程
前提条件
首先,确保你正在运行Node.js 18+版本,并初始化一个新项目。
我们将安装核心的Apryse库及其数据提取模块(包含我们之前提到的神经网络)。我们还将获取dotenv库来管理我们的环境变量。
你可以使用你选择的包管理器来安装这些依赖项。这里我们使用NPM,因为大多数Node.js用户默认都安装了它。
npm install @pdftron/pdfnet-node
npm install @pdftron/data-extraction
npm install dotenv
对于我的大型语言模型(LLM)需求,我将使用我已经订阅的OpenAI。但为了让这个教程尽可能开放,并确保任何阅读者都能跟随,我们将使用Vercel AI SDK。这是一个统一的接口,允许你使用(并轻松替换)OpenAI、Anthropic、Gemini以及你能够访问的任何其他模型——甚至是自定义模型。
npm install ai @ai-sdk/openai
最后,是关于API密钥。
请将这些密钥放在项目文件夹中的.env文件中。我的.env文件如下所示。
OPENAI_API_KEY = openai_api_key_here
APRYSE_API_KEY = apryse_trial_api_key_here
第一步:数据提取
我们脚本的入口其实非常简单。你需要先导入库,然后使用addResourceSearchPath指向数据提取附加组件(从技术上讲,它是一个外部资源),并等待从输入PDF(此处为同一目录下的bank-statement.pdf)中提取表格数据为JSON字符串。
require("dotenv").config();
const { PDFNet } = require('@pdftron/pdfnet-node')
async function main() {
await PDFNet.addResourceSearchPath("./node_modules/@pdftron/data-extraction/lib")
try {
const json = await PDFNet.DataExtractionModule.extractDataAsString('./bank-statement.pdf',
PDFNet.DataExtractionModule.DataExtractionEngine.e_Tabular);
console.log('-----Extracted Text------');
console.log(json);
} catch (error) {
console.error("Error :", error);
}
}
如果你的PDF受到密码保护,只需在DataExtractionOptions选项对象中设置密码,如下所示:
/* if password protected */
const options = new PDFNet.DataExtractionModule.DataExtractionOptions();
options.setPDFPassword("password")
const json = await PDFNet.DataExtractionModule.extractDataAsString('./bank-statement.pdf',
PDFNet.DataExtractionModule.DataExtractionEngine.e_Tabular, options);
// rest of code
哦,为了确保Apryse SDK在进程运行结束后清理所有内存中的对象,你应该使用PDFNet.runWithCleanup()初始化的main()函数来运行,这使得我们在提取阶段结束时的代码看起来像这样:
require("dotenv").config();
const { PDFNet } = require('@pdftron/pdfnet-node')
async function main() {
await PDFNet.addResourceSearchPath("./node_modules/@pdftron/data-extraction/lib")
try {
const json = await PDFNet.DataExtractionModule.extractDataAsString('./bank-statement.pdf',
PDFNet.DataExtractionModule.DataExtractionEngine.e_Tabular);
console.log('-----Extracted Text------');
console.log(json);
} catch (error) {
console.error("Error :", error);
}
}
PDFNet.runWithCleanup(main, process.env.APRYSE_API_KEY)
.catch(error => console.error("Apryse library failed to initialize:", error))
.then(function () {
PDFNet.shutdown();
});
再次提醒,请确保你的Apryse API密钥已设置在.env文件中,并在此处作为第二个参数传递给runWithCleanup函数。
当你运行此脚本时,它应该会打印出我们之前讨论过的提取的、深度结构化的JSON数据。
第二步:使LLM摄入结构化输出以生成见解
首先,集成Vercel AI SDK以处理对LLM提供商的请求。我们将编写一个简单的函数,该函数接收上一步中的JSON数据,并将其与特定的提示一起发送到LLM,以便获得可操作的见解。
我们的场景是分析银行对账单以供内部业务使用(即我们希望为利益相关者提供战略和财务见解),因此,这听起来像是一个不错的提示,对吧?
const prompt = `Analyze the following text, and generate a bullet-point list
of actionable financial and strategic insights for internal business use.`
差不多了,但还不完全对。大型语言模型(LLM)将提示解释为文本,而没有明确区分隐藏在底层文本中的用户指令和命令。想象一下,如果PDF中包含恶意文本,它可能会微妙地改变你的提示,从而过分强调某些数据,进而可能使你的公司得出带有偏见甚至有害的结论。这可不是什么好事。
如果这是一个传统应用程序,我们只需通过严格的清理/验证来处理用户输入即可。然而,对于LLM的提示来说,由于其缺乏正式的编码结构,因此更难确保其安全性。
但是,我们可以采取一些保障措施。既然我们已经有了结构化的JSON作为输入,并且已经准备好了,我们可以将其放在一个额外的JSON字段下,并告诉LLM只处理该特定键值对内的内容。
const prompt = `Analyze the transaction data provided in JSON format
under the 'text_to_analyze' field, and nothing else. Each transaction is structured with details
like transaction description, amount, date, and other metadata.
Generate a bullet-point list of actionable financial and strategic insights
for internal business use. Focus on identifying cash flow patterns,
high-spend categories, recurring payments, large or unusual transactions,
and any debt obligations. Provide insights into areas for cost savings,
credit risk, operational efficiency, and potential financial risks.
Each insight should suggest actions or strategic considerations to
improve cash flow stability, optimize resource allocation, or flag
potential financial risks. Expand technical terms as needed to clarify
for business stakeholders.`
LLM(大型语言模型)的非确定性意味着你可以无休止地根据你的需求微调这个提示,但这个应该可以作为一个基准。
之后,我们可以导入必要的库,并像这样简单地将必要的数据传递给LLM。
const { openai } = require('@ai-sdk/openai');
const { generateText } = require('ai')
async function analyze(input) {
try {
const { text } = await generateText({
model: openai("gpt-4o"),
/* structured inputs to safeguard against prompt injection */
prompt: `${prompt}\n{"text_to_analyze": ${input}}`
});
if (!text) {
throw new Error("No response text received from the generateText function.");
}
return text;
} catch (error) {
console.error("Error in analyze function:", error);
return null; // return null if there’s an error
}
}
如你所见,Vercel AI SDK使得更换你想要的模型变得非常容易。除了model属性的值之外,其他一切都将保持不变。
将所有这些整合在一起,并对代码进行一些清理,我们得到了以下内容:
const { PDFNet } = require("@pdftron/pdfnet-node");
const { openai } = require("@ai-sdk/openai");
const { generateText } = require("ai");
const fs = require("fs");
require("dotenv").config();
const prompt = `YOUR_PROMPT_HERE`; // remember to use structured inputs to safeguard against prompt injection
async function analyze(input) {
try {
const { text } = await generateText({
model: openai("gpt-4o"),
prompt: `${prompt}\n{"text_to_analyze": ${input}}`, // structured input
});
if (!text) {
throw new Error(
"No response text received from the generateText function.",
);
}
return text;
} catch (error) {
console.error("Error in analyze function:", error);
return null;
}
}
async function extractDataFromPDF() {
console.log("Extracting tabular data as a JSON string...");
/* if password protected */
// const options = new PDFNet.DataExtractionModule.DataExtractionOptions();
// options.setPDFPassword("password")
const json = await PDFNet.DataExtractionModule.extractDataAsString(
"./bank-statement.pdf",
PDFNet.DataExtractionModule.DataExtractionEngine.e_Tabular,
); // include options if you're using it
fs.writeFileSync("./output.json", json);
console.log("Result saved ");
return json;
}
async function main() {
await PDFNet.addResourceSearchPath(
"./node_modules/@pdftron/data-extraction/lib",
);
try {
const extractedData = await extractDataFromPDF();
const insights = await analyze(extractedData);
if (insights) {
// Save insights to a text file
fs.writeFileSync("insights.md", insights, "utf8");
console.log("Insights saved to insights.md");
} else {
console.error("No insights generated. Skipping file write.");
}
} catch (error) {
console.error("Error :", error);
}
}
PDFNet.runWithCleanup(main, process.env.APRYSE_API_KEY)
.catch((error) =>
console.error("Apryse library failed to initialize:", error),
)
.then(function () {
PDFNet.shutdown();
});
为了清晰起见,我选择将两个阶段(从PDF中提取数据以及由LLM生成的见解)的输出都写入磁盘上的文件中。如果出现问题,希望这能帮助你进行诊断并根据需要进行微调。
你的见解将会类似于这样。
// excerpt-of-insights.md
//...
### Significant or Unusual Transactions
1. **Equipment Purchase**: For the **$9,000.00** expense for computers on April 21, 2023,
I suggest verifying that the hardware specifications meet both current
operational demands and potential future needs to prevent frequent upgrades.
I also suggest considering bulk purchasing discounts or bundled warranties to
lower long-term maintenance costs. Additionally, consider leasing instead
of purchasing outright if you want to bring down upfront costs.
2. **Business Travel Expenses**: You spent **$36,500** on travel for the month
of April. This is a significant expense on travel, and I suggest double
checking to make sure the numbers are accurate, and shows clear alignment
between travel goals and expected returns. I also suggest a travel policy
that defines allowable expenses, reimbursement guidelines, and cost-saving
practices (e.g., advance bookings, lodging caps, per diem).
Regularly review this policy, and promote virtual meetings whenever possible.
//...(more)
最后,虽然这超出了本文的范围,但如果我不提其他内容,那我就太失职了:银行对账单通常是一个足够小的PDF,以至于Apryse提取的结构化数据不太可能超出你的LLM(大型语言模型)使用或上下文限制。例如,OpenAI的免费层级有一个8K令牌的上下文限制,这应该能够轻松处理大多数银行对账单。
然后,生成的 JSON 输出很容易超出许多 LLM 的标记限制,尤其是当它包含大量布局元数据时。对于这些,请考虑使用RAG(检索增强生成)方法将内容分解为更小、更相关的块。这样,你可以索引这些块并仅检索与每个查询最相关的部分,从而降低标记成本并保持在模型的上下文窗口内。