用Gemma 4构建自托管OCR

张开发
2026/4/12 21:07:21 15 分钟阅读

分享文章

用Gemma 4构建自托管OCR
过去三年里许多人认为 AI 越大越聪明。他们觉得参数越多性能越好GPU 越多AI 就越智能。然而这一普遍认知本周被谷歌的开放模型Gemma 4彻底颠覆。Gemma 是谷歌发布的一系列开放权重模型。开放权重意味着模型的权重数据可以自由获取。任何人都可以下载并在自己的 PC、服务器或云端运行它。ChatGPT 和 Gemini Advanced 只能通过云端使用而 Gemma 最大的优势在于它可以直接安装并运行在你的本地环境中。这一切是怎么突然实现的因为谷歌最新发布的TurboQuant技术能够大幅降低大语言模型LLM的内存消耗。据说它可以将内存效率降低到原来的六分之一这有潜力显著降低 AI 的运行成本。我认为很多人都在期待本地 LLM 终于变得更加轻量化以及以前无法运行的大模型现在可以在家用 GPU 上轻松运行。当 LLM大规模语言模型生成文本时它使用一种称为键值缓存KV Cache的工作内存来存储过去已计算的 token 信息。没有它每次生成新 token 时都得从头重新计算。问题在于KV 缓存的大小会随着上下文context 的长度的增长而线性增加。当尝试处理长对话或长文档时消耗 GPU 内存的正是 KV 缓存而非模型权重。总而言之TurboQuant 并不是一种神奇地大幅减少本地 LLM 整体体重的法术。其本质在于对运行时不断膨胀的内存——主要是 KV 缓存——进行强力压缩。那么让我通过一个实时聊天机器人的快速演示来展示一切是如何运作的。我将上传一张包含资产和负债信息的图片你可以以任何格式上传。图片会直接显示出来。如果你观察 Agent 的输出方式你会发现它将文件保存到磁盘上的临时位置。文件以随机名称保存但保留了正确的扩展名因此格式可以被正确检测。如果输入是 PDF则使用 Poppler 库中的pdftoppmPortable Pixmap工具将每一页转换为 PNG 图像。这一步是必需的因为视觉模型只接受图像输入。页面以配置的 DPI每英寸点数进行渲染通常为 300。较高的 DPI 可以提高准确性但也会增加处理时间和文件大小。如果选择了页面范围则只处理这些页面。图像临时存储处理完毕后自动清理。接下来所有图像都会检查尺寸。如果图像的最大边超过最大维度限制默认为 1536 像素则使用高质量的 Lanczos 滤镜按比例缩放。这样既保持处理速度又能保留足够的细节以确保文本识别的准确性。如果文档类型设置为autoAgent 会快速对图像进行分类如通用、表格、手写或扫描件。这有助于选择最佳的 OCR 提示词。如果手动选择了类型则跳过此步骤。之后图像被编码并与选定的提示词一起发送到本地 Ollama API。请求使用流式传输因此文本会在生成过程中逐步返回。Agent 将这些片段收集为最终结果并跟踪 token 计数和处理时间等元数据。最后根据所选的输出类型对结果进行格式化。纯文本模式将所有内容合并Markdown 模式添加结构JSON 模式保留所有元数据。这段代码将在我的Patreon上提供因为它花费了我大量的时间和精力。如果你喜欢我的创作并希望看到更多类似的项目在 Patreon 上支持我可以帮助我持续制作高质量的内容。我真心感谢你的支持。1、Gemma 4 有何独特之处Gemma 4 有四种尺寸E2B、E4B、26B、A4B 和 31B。较小的模型专为智能手机和边缘设备设计而较大的模型则用于本地 PC 和工作站。此外它支持高达 256K token 的长上下文长度并能处理超过 140 种语言。较小尺寸支持 128K较大尺寸支持 256K这使得共享完整代码库或长设计文档变得非常实用。其功能也高度面向实际应用场景。它原生支持函数调用一种调用外部工具和 API 的机制并且默认支持系统角色。所有模型都能处理文本和图像较小的模型还原生支持语音。换言之它从一开始就不只是为聊天场景设计的而是作为连接搜索、执行、格式化和决策的 Agent 基础设施。不仅仅是聪明更是易于集成到工作流中。我相信这就是 Gemma 4 的精髓所在。虽然模型本身的智能程度很重要但在实际应用中真正发挥作用的是三点阅读长文本的能力、调用工具的能力、以及在本地运行的能力。2、TurboQuant 有何独特之处我想在这里澄清的是它不是让模型本身变得更轻量化的技术这一事实绝不意味着它的价值很低。相反在本地 LLM 的实际运行中KV 缓存才是后期变得更加显著的因素因此减轻其负载的收益是相当可观的。根据谷歌研究TurboQuant 以极低位深度为目标进行压缩。对于 KV 缓存量化它在3.5 位/通道时实现了绝对质量中性在 2.5 位/通道时也仅有边际质量下降。粗略来说这个数字意味着用于上下文保留的内存可以大幅减少。尽管如此质量下降的可能性可以降到最低。如果 TurboQuant 在本地 LLM 中得到实现和普及可以预期以下变化更容易处理长文本这有可能使长文本摘要、代码库分析、文档输入和 RAG 等任务中处理更长的上下文变得更加容易。由于 KV 缓存随序列长度增长而增大压缩它将使长文本操作更加实用。在相同 GPU 上更容易维持性能这将缓解短文本运行良好但长文本突然变得困难的状况。这一改进对于约 16GB 显存的 GPU 尤为显著因为这类 GPU 即使能加载模型本身也常常在处理较长文本时捉襟见肘。这与 KV 缓存压缩研究的总体趋势一致研究报告表明内存减少可以提升吞吐量和批处理大小。对多次执行和基于 Agent 的操作非常有效对于需要在长时间对话、多任务处理、RAG 和代码辅助等场景下进行处理的应用KV 缓存往往比模型本身更是瓶颈所在因此优化这一方面的价值极高。3、开始编码我编写了一个函数用于读取用户输入的类似1–5或1,3,7–10的字符串并将其转换为整洁的页码列表。它首先按逗号拆分字符串因此1,3,7–10会变成更小的片段。每个片段会检查是否包含破折号——如果有则将其视为一个范围并填入起始和结束之间的所有数字。如果没有破折号就直接取那个单独的数字。它在构建列表时使用了集合因此像1–3,2这样的重复项会被自动去除。最后它将所有内容转换为排序后的列表因此页码始终按顺序返回。def parse_pages(page_str: str) - list[int]: Parse a page range string like 1-5 or 1,3,7-10 into a sorted list of 1-based page numbers. pages set() for part in page_str.split(,): part part.strip() if - in part: start, end part.split(-, 1) pages.update(range(int(start), int(end) 1)) else: pages.add(int(part)) return sorted(pages)我编写了一个函数将 PDF 转换为 PNG 图像以便 Gemma 4 能够读取它们。它首先检查pdftoppm是否已安装——如果没有则打印安装提示并退出。然后设置输出路径使图像命名为page-1、page-2等。如果用户选择了特定页面则为每一页运行一次pdftoppm否则对整个 PDF 运行一次。-r标志设置图像质量。转换完成后它抓取所有page-*.png文件进行排序如果没有找到任何文件则退出。辅助函数extract_page_number读取类似page-01.png的文件名去掉扩展名提取数字并转换为整数这样应用就知道每张图像来自哪一页。def pdf_to_images( pdf_path: str, output_dir: str, dpi: int DEFAULT_DPI, pages: list[int] | None None, ) - list[Path]: Convert a PDF to PNG images using pdftoppm (poppler). If pages is provided, only those pages are converted. Returns a sorted list of image paths. if not shutil.which(pdftoppm): print(Error: pdftoppm not found. Install poppler:) print( macOS: brew install poppler) print( Ubuntu: sudo apt install poppler-utils) sys.exit(1) output_prefix str(Path(output_dir) / page) if pages: # Convert each requested page individually for p in pages: cmd [ pdftoppm, -png, -r, str(dpi), -f, str(p), -l, str(p), pdf_path, output_prefix, ] subprocess.run(cmd, checkTrue, capture_outputTrue) else: # Convert all pages at once cmd [pdftoppm, -png, -r, str(dpi), pdf_path, output_prefix] subprocess.run(cmd, checkTrue, capture_outputTrue) images sorted(Path(output_dir).glob(page-*.png)) if not images: print(fError: No page images extracted from {pdf_path}) sys.exit(1) return images def extract_page_number(image_path: Path) - int: Extract the 1-based page number from a pdftoppm filename (e.g. page-03.png → 3). return int(image_path.stem.split(-)[-1])接下来我创建了一个函数决定如何处理任何文件。它启动计时器检查文件类型然后走两条路径之一。对于图像它运行ocr_single_image并将结果封装在一个包含文件信息、时间戳、模型名称和页码列表的字典中。对于 PDF它创建一个临时文件夹使用pdf_to_images将页面转换为 PNG对每张图像运行 OCR收集结果并构建相同的字典。临时文件夹会被自动删除。如果文件既不是图像也不是 PDF则打印错误并退出。无论哪种情况函数始终返回相同的字典格式因此 Agent 以相同的方式处理图像和 PDF。# ── Single file processing ──────────────────────────────── def process_single_file( file_path: str, doc_type: str auto, model: str DEFAULT_MODEL, dpi: int DEFAULT_DPI, pages: list[int] | None None, max_long_edge: int MAX_IMAGE_LONG_EDGE, ) - dict: Run OCR on a single PDF or image file. Returns a unified result dict with per-page text and metadata. file_path Path(file_path) start_time time.time() if file_path.suffix.lower() in IMAGE_EXTS: # Direct image OCR — single page result print(Recognizing image..., end, flushTrue) result ocr_single_image(str(file_path), doc_type, model, max_long_edge) secs result[duration_ms] / 1000 print(f Done ({secs:.1f}s, {result[tokens]} tokens)) total_ms (time.time() - start_time) * 1000 return { file: str(file_path.resolve()), total_pages: 1, processed_pages: 1, model: result[model], created_at: datetime.now().isoformat(), total_duration_ms: round(total_ms, 1), pages: [ { page: 1, text: result[text], doc_type: result[doc_type], tokens: result[tokens], duration_ms: result[duration_ms], } ], } elif file_path.suffix.lower() PDF_EXT: # PDF: convert to images first, then OCR each page with tempfile.TemporaryDirectory(prefixocr_) as tmpdir: print(fConverting PDF to images (DPI{dpi})...) images pdf_to_images(str(file_path), tmpdir, dpi, pages) page_results [] print(fStarting OCR on {len(images)} page(s)\n) for idx, img_path in enumerate(images): page_num extract_page_number(img_path) print(f [{idx1}/{len(images)}] Page {page_num} — recognizing..., end, flushTrue) result ocr_single_image(str(img_path), doc_type, model, max_long_edge) secs result[duration_ms] / 1000 print(f Done ({secs:.1f}s, {result[tokens]} tokens)) page_results.append({ page: page_num, text: result[text], doc_type: result[doc_type], tokens: result[tokens], duration_ms: result[duration_ms], }) total_ms (time.time() - start_time) * 1000 return { file: str(file_path.resolve()), total_pages: len(images), processed_pages: len(page_results), model: model, created_at: datetime.now().isoformat(), total_duration_ms: round(total_ms, 1), pages: page_results, } else: print(fError: Unsupported format {file_path.suffix}) sys.exit(1)接下来我编写了三个函数它们都用于将结果字典转换为可读的字符串只是风格不同。format_as_json最简单——它将整个字典以格式化的 JSON 输出保留时间戳和 token 计数等所有信息。format_as_markdown更有结构它将输入标准化为列表添加包含文件名、模型、页数、时间的标题并循环遍历每一页添加分隔线、页眉、隐藏的 HTML 注释和文本内容。format_as_text是最精简的——它标准化输入只在有多个文档时添加文件名分隔符然后直接输出原始文本几乎没有额外格式。在底部FORMATTERS将json、md或txt映射到对应的函数这样 Agent 就可以用一行代码选择正确的格式化器。# ── Output formatters ───────────────────────────────────── def format_as_json(data: dict | list[dict]) - str: Serialize the result as pretty-printed JSON. return json.dumps(data, ensure_asciiFalse, indent2) def format_as_markdown(data: dict | list[dict]) - str: Format the result as Markdown with per-page sections. items data if isinstance(data, list) else [data] parts [] for doc in items: filename Path(doc[file]).name model doc.get(model, unknown) created doc.get(created_at, ) total_sec doc.get(total_duration_ms, 0) / 1000 parts.append(f# OCR: {filename}\n) parts.append( f Model: {model} | Pages: {doc[processed_pages]} | fTime: {total_sec:.1f}s | Date: {created}\n ) for page in doc[pages]: page_sec page.get(duration_ms, 0) / 1000 parts.append(\n---\n) parts.append(f## Page {page[page]}\n) parts.append( f!-- type: {page.get(doc_type, unknown)} | f{page_sec:.1f}s | {page.get(tokens, 0)} tokens --\n ) parts.append(f\n{page[text]}\n) return \n.join(parts) def format_as_text(data: dict | list[dict]) - str: Format the result as plain text, one page after another. items data if isinstance(data, list) else [data] parts [] for doc in items: if len(items) 1: filename Path(doc[file]).name parts.append(f{ * 60}) parts.append(fFILE: {filename}) parts.append(f{ * 60}\n) for i, page in enumerate(doc[pages]): if len(doc[pages]) 1: parts.append(f--- Page {page[page]} ---\n) parts.append(page[text]) if i len(doc[pages]) - 1: parts.append() # blank line between pages return \n.join(parts) # Map format name → formatter function FORMATTERS { json: format_as_json, md: format_as_markdown, txt: format_as_text, }4、我的感想Gemma 4 最重要的一点不在于又多了一个顶级模型。我认为真正的趋势是AI 开发正从纯粹的云端模式转向云端与本地计算相结合的混合模式。重型推理和最终决策在云端处理而日常辅助和内部数据处理则在本地完成。另一方面TurboQuant 是一项有潜力大幅提升 AI 性能和效率的技术预计未来将受到更多关注。它目前还不是面向普通用户的服务但它是一项将影响 AI 未来走向的重要技术值得持续关注 ✨原文链接用Gemma 4构建自托管OCR - 汇智网

更多文章