历史墓地碑文数字化:OCR+LLM管线的防幻觉设计与审核闭环 📅 发布时间:2026/8/28 3:50:35 👁 浏览次数: 在一个晴朗的下午我拿到了一批来自本地历史墓园的墓碑照片。照片里是风化严重的花岗岩碑面有的字迹已经被苔藓覆盖有的姓氏只剩下半个偏旁。任务很明确将这些照片转换成结构化的档案数据用于家族史研究和建筑文化遗产保护。听上去这就是一个典型的 AI 图像识别加信息抽取项目但真正执行起来问题远比“把文字提取出来”要复杂得多。科技行业每隔一段时间就会提出类似的疑问Are we sacrificing cemeteries for AI翻译成工程语言就是当我们用 AI 大规模数字化历史墓园时是为了效率而无条件接受模型的“发挥”还是应该设计一套机制保证自动化不会扭曲真实的历史细节和逝者信息答案显然是后者。这篇博客围绕“历史墓地碑文数字化”这个具体场景完整演示一条可复现的 AI 数据处理管线从图片输入到 OCR 识别再到 LLM 字段抽取、知识存储、置信度门禁和人工审核闭环最终目标是做到“AI 处理可以快但不许胡编”。之所以选择墓地作为案例是因为它同时具备三个典型技术挑战文本退化、数据敏感、事实不可逆。风化、磨损、手写变体、多语言混排会让 OCR 的置信度波动非常大墓碑上的人名、生卒日期、亲属关系又是典型的个人敏感信息而历史数据一旦被 AI 幻觉“补全”后续学者很难再辨别哪些是原始字迹、哪些是模型猜测。这三类问题在任何一个历史文献数字化项目里都会遇到所以案例本身并不小众。1. 先理解场景墓地数字化到底在解决什么问题AI 又介入在哪一层1.1 墓地数字化的原始痛点传统墓地档案管理通常依赖手写登记表、纸质测绘和人工拍照。一座中型历史墓园如果有一万块墓碑整理全部信息可能需要数月。更糟糕的是许多墓碑的石材正在快速风化如果不趁字迹还能辨认时完成数字化信息就会永久消失。数字化并不只是拍照片。一张照片无法回答“这块碑属于哪个家族”“碑主生卒年是什么”“立碑人与碑主是什么关系”这些问题。要支撑历史研究、家谱关联和遗产保护必须把照片里的非结构化信息转成字段明确的记录例如碑主姓名生年、卒年立碑人立碑时间与碑主关系碑文原文备注和特殊符号人工录入准确率高但成本高、速度慢。纯 AI 自动录入速度快但容易把看不清的部分“脑补”出来。因此正确的做法不是让 AI 完全替代人而是让 AI 把图片处理到“人工只需要复核低置信度记录”的程度。1.2 AI 介入的三个关键环节在这个场景中AI 不是单个模型而是由多个模型协作组成的流水线。第一环是 OCR负责把红色或灰色碑面上的文字转成文本第二环是信息抽取由大语言模型把自由文本切分到姓名、日期、关系等字段第三环是知识组织把结构化记录写入数据库并在需要时关联到家族树知识图谱。三个环节各有不同的失败模式。OCR 的典型问题是缺字、多字、繁体简体混淆信息抽取的典型问题是幻觉和字段落错知识组织的典型问题则是重复实体和溯源丢失。只有把三个环节都约束住整条管线才具备生产价值。1.3 “牺牲”的本质自动化带来的失真、隐私和不可解释性回到标题中的 sacrifice。当项目强调“更快上线”“更高自动化率”时牺牲掉的东西往往是三样事实完整性。模型为了输出通顺结果会把模糊的日期补成最可能的年份而不是承认“此处无法识别”。隐私安全。墓碑上的姓名、日期、亲属关系对活着的后人仍然敏感如果数据不加脱敏直接进入外部大模型会造成隐私泄露。可解释性。当学者追问“这个日期是AI推断的还是原碑上写的”系统必须能给出答案否则数字档案会失去研究价值。所以工程上的核心不是“如何让 AI 更聪明”而是“如何让 AI 在不确定时保持诚实并把不确定的部分交给人工”。这就是这篇博客要解决的问题。2. 环境准备与技术选型识别、抽取、存储和审核2.1 最小技术栈与版本下面这套组合适合学习环境快速验证也便于后续替换成生产级组件。组件选型用途说明Python3.10开发语言异步、类型标注都友好OCRPaddleOCR 2.7 或 Tesseract 5碑文文字识别PaddleOCR 对中文碑文效果更好Tesseract 更容易离线安装LLM 服务OpenAI 兼容接口或本地 Ollama 模型字段抽取和格式清洗本地部署时可选 Qwen2.5-7B 等注意中文效果Web 框架FastAPI接口和审核工作台后端轻量适合原型数据库SQLite SQLAlchemy开发环境存储生产环境可切换到 PostgreSQL前端简单的 Vue 或静态 HTML人工审核工作台原型阶段用简单 HTML 就可以版本细节在真实项目里要根据机器环境确认。这里要特别强调OCR 模型和 LLM 模型各自的版本会直接影响识别效果落地前不要在旧版本上浪费时间。2.2 项目目录结构建议把管线拆分成独立模块方便单独测试和替换cemetery_ai/ ├── app/ │ ├── main.py # FastAPI 入口 │ ├── pipeline/ │ │ ├── ocr_engine.py # OCR 识别封装 │ │ ├── llm_extractor.py # LLM 字段抽取 │ │ ├── validator.py # 字段校验与置信度门禁 │ │ └── image_utils.py # 图片裁剪、缩放、区域标注 │ ├── models/ │ │ ├── database.py # SQLAlchemy 连接 │ │ └── schemas.py # 墓碑记录模型 │ ├── services/ │ │ └── audit_service.py # 人工审核流转服务 │ └── web/ │ └── review.html # 审核工作台页面 ├── data/ │ ├── raw/ # 原始照片只读不可修改 │ ├── crops/ # 裁剪出的文字区域 │ └── output/ # 结构化输出 ├── tests/ │ └── test_pipeline.py └── requirements.txt目录设计决定了数据溯源是否可靠。raw/目录必须只读所有下游处理都基于裁剪图或派生图不允许修改原始照片。这样才能在人工审核时随时回到原始影像。2.3 数据采集规范为什么要预留原始影像链一块碑通常只拍一张照片是不够的。建议包含全景图展示墓碑全貌。碑面近景保证文字清晰。局部特写对风化区域单独拍摄。拍摄时间、GPS、拍摄人元数据。元数据比想象中重要。如果后续需要做三维建模或者多视角重建这些信息能帮助算法对齐。更关键的是当 AI 识别结果被质疑时原始照片和拍摄信息是最有效的证据。注意不要把原始影像和识别结果混在一个目录里。识别结果可以被多次覆盖原始影像必须保持只读。3. 构建核心管线从碑文图片到结构化记录3.1 第一步用 OCR 识别碑文并保留置信度OCR 的目标不是直接输出最终文字而是输出带置信度的“候选文本块”。下面用 PaddleOCR 做一个最小封装# app/pipeline/ocr_engine.py from paddleocr import PaddleOCR from dataclasses import dataclass dataclass class OCRBlock: text: str confidence: float box: list image_path: str class GraveOCR: def __init__(self): # lang 参数可按碑文语言切换中文碑文用 ch self.engine PaddleOCR(use_angle_clsTrue, langch, show_logFalse) def extract_blocks(self, image_path: str) - list[OCRBlock]: result self.engine.ocr(image_path, clsTrue) blocks [] for page in result: if not page: continue for line in page: box, line_info line text, confidence line_info[0], line_info[1] blocks.append( OCRBlock( texttext, confidencefloat(confidence), boxbox, image_pathimage_path, ) ) return blocks这里的关键不是单行文字而是confidence和box。box是文字在图片中的坐标后续裁剪和可视化依赖它。confidence会在下一阶段参与判断不能丢弃。OCR 模型很容易把“光绪十三年”识别成“光绪十三年”中的某个字缺笔画因为碑上有裂纹。所以不要直接信任第一轮文本要把它当作“候选项”。3.2 第二步用 LLM 抽取字段但必须给模型“原文上下文”OCR 输出的文本是线性排列可能没有标点也可能缺少段落结构。此时用 LLM 做信息抽取非常合适但有两个前提让 LLM 只基于传入的 OCR 文本输出不允许额外补充原文没有的信息。要求 LLM 对不确定字段返回null而不是猜测一个值。示例提示词你是墓碑档案信息抽取助手。请根据 OCR 识别出的碑文文本抽取以下字段 姓名、出生年份、去世年份、立碑人、立碑年份、与碑主关系、完整碑文原文。 要求 - 只使用给定文本中的信息禁止推断。 - 如果字段无法确定输出 null。 - 输出 JSON格式必须严格符合 {name: ..., birth_year: ..., ...} - 如果原文存在明显 OCR 错误不要修正不要脑补。 碑文文本 {ocr_text}对应的 Python 调用代码# app/pipeline/llm_extractor.py import json from openai import OpenAI client OpenAI(base_urlhttp://localhost:11434/v1, api_keyollama) SYSTEM_PROMPT 你是墓碑档案信息抽取助手必须严格遵守输入约束。 def extract_fields(ocr_text: str) - dict: user_prompt f请根据 OCR 识别出的碑文文本抽取以下字段 姓名、出生年份、去世年份、立碑人、立碑年份、与碑主关系、完整碑文原文。 要求 - 只使用给定文本中的信息禁止推断。 - 如果字段无法确定输出 null。 - 输出 JSON格式严格。 - 如有 OCR 错误不要修正。 碑文文本 {ocr_text} resp client.chat.completions.create( modelqwen2.5:7b, messages[ {role: system, content: SYSTEM_PROMPT}, {role: user, content: user_prompt}, ], temperature0, response_format{type: json_object}, ) content resp.choices[0].message.content return json.loads(content)设置temperature0很重要字段抽取任务需要确定性不需要创造性。同时如果使用的本地模型不支持response_format要在提取后做 JSON 解析异常处理避免一次坏响应让整条任务崩溃。3.3 第三步图片关键区域裁剪与可视化标注LLM 抽取的字段需要被审阅者验证。审阅时如果只给一个结构化 JSON审阅者必须回到整张照片中找字效率很低。所以应该在抽取时同步生成文字区域裁剪图和字段关联提示。# app/pipeline/image_utils.py import cv2 def crop_block(image_path: str, box, out_path: str) - str: image cv2.imread(image_path) pts [(int(p[0]), int(p[1])) for p in box] xs [p[0] for p in pts] ys [p[1] for p in pts] x_min, y_min max(0, min(xs) - 10), max(0, min(ys) - 10) x_max min(image.shape[1], max(xs) 10) y_max min(image.shape[0], max(ys) 10) crop image[y_min:y_max, x_min:x_max] cv2.imwrite(out_path, crop) return out_path裁剪时预留 10 像素边距避免把文字贴边切掉。实际项目中可以增加预处理步骤比如灰度化、二值化、去除苔藓背景但这些操作只用于派生图不应覆盖原始照片。3.4 第四步字段入库建立墓碑与影像的溯源关系数据库表设计至少要包含三张表影像表、墓碑记录表、字段置信度表。这里给出核心字段# app/models/schemas.py from sqlalchemy import Column, String, Float, Integer, Text, ForeignKey from sqlalchemy.orm import declarative_base Base declarative_base() class ImageAsset(Base): __tablename__ image_assets id Column(Integer, primary_keyTrue, autoincrementTrue) path Column(String(500), nullableFalse) tomb_id Column(Integer, ForeignKey(tomb_records.id)) checksum Column(String(64), nullableFalse) # 原始影像校验值 class TombRecord(Base): __tablename__ tomb_records id Column(Integer, primary_keyTrue, autoincrementTrue) name Column(String(100)) birth_year Column(String(20)) death_year Column(String(20)) stone_erector Column(String(100)) stone_date Column(String(50)) relation Column(String(50)) raw_ocr_text Column(Text) status Column(String(20), defaultpending_review) # pending/completed confidence_score Column(Float) class FieldConfidence(Base): __tablename__ field_confidence id Column(Integer, primary_keyTrue, autoincrementTrue) tomb_id Column(Integer, ForeignKey(tomb_records.id)) field_name Column(String(50)) value Column(String(200)) confidence Column(Float) source Column(String(20)) # ocr/llm/humanFieldConfidence表记录的是每个字段级别的置信度而不是整条记录一个分数。这样才能在人工审核时精确到字段而不是整条记录重新录入。4. 防止“AI 幻觉”污染历史事实置信度门禁与人工审核闭环4.1 幻觉为什么会破坏数据可信度墓碑 OCR 文本往往残缺不全例如“民国十 年”中间缺了一个数字。LLM 在训练时见过大量日历数据很容易在抽取时自动补成“民国十一年”。这个补全在自然语言对话中可能无伤大雅但在历史档案里就是事实错误。更隐蔽的是当 OCR 把“妣”识别为“姑”时LLM 可能基于上下文推断出“母亲”的亲属关系而这个推断在原文中并不存在。历史研究最忌这种“平滑化处理”。所以必须让模型有机会表达“我不确定”而不是强制输出一个完整 JSON。4.2 设计三层校验OCR置信度、LLM自检、字段交叉验证第一层OCR 置信度门禁。OCR 返回的每个文本块都有一个置信度分数。低于阈值的文本块不进入 LLM 抽取而是直接标记为“需人工辨认”。def filter_low_conf_blocks(blocks, threshold0.75): passed [] low_conf [] for b in blocks: if b.confidence threshold: passed.append(b) else: low_conf.append(b) return passed, low_conf第二层LLM 自检。抽取完成后让同一个模型执行一次自检任务把抽取结果逐项与 OCR 原文对比输出每个字段的可信度并列出“字段是否能在原文中找到依据”。def self_check(ocr_text: str, extracted: dict) - dict: check_prompt f请检查下面的抽取结果是否完全基于原文。 原文{ocr_text} 抽取结果{extracted} 对每个字段输出 - supported: true/false是否原文有明确依据 - reason: 简短说明 只输出 JSON。 # 调用 LLM ...第三层字段交叉验证。例如如果石碑格式是“先考某公讳某”那么姓名和立碑人不能重复如果生卒年存在去世年份一般大于出生年份。这类规则可以写成本地规则引擎不依赖模型。4.3 人工审核工作台只显示低置信度记录保留原始切片人工审核不应该面对全部记录否则效率太低。审核台只需要展示以下内容低置信度字段列表对应的裁剪图对应的整碑照片链接OCR 原始文本LLM 抽取结果与原因可供人工修改的表单一个极简的审核接口可以这样设计from fastapi import FastAPI from pydantic import BaseModel app FastAPI() class ReviewResult(BaseModel): record_id: int field_name: str corrected_value: str reviewer: str app.get(/records/pending) def list_pending(limit: int 20): # 查询 statuspending_review 的记录并按置信度升序 ... app.post(/records/review) def submit_review(result: ReviewResult): # 更新字段值记录审核人将 status 改为 completed ...在真实项目中审核工作台还需要加入操作审计谁改了什么、什么时候改的、原始值是什么。这里的核心思想是“AI 先跑人来兜底”。4.4 示例一条错误记录的完整拦截过程假设 OCR 输出文本是“先考张公讳学 之墓民国十 年立”。其中“学”和“十”后面的文字因风化缺失。LLM 抽取可能输出{ name: 张学义, death_year: 民国十二年, stone_erector: 张学义之子, relation: 父亲 }这个结果看起来通顺但完全是编造的。三层校验会如何拦截OCR 置信度缺失字符区域的文本块低于阈值会被标记。LLM 自检当被要求提供依据时模型会发现name中的“义”无法在原文中找到对应字符返回supported: false。字段交叉验证如果“立碑人”是“张学义之子”那么姓名和立碑人的关系存在矛盾规则引擎会标记异常。最终这条记录进入人工审核审阅者看到的是原始照片和裁剪图而不是一个看似合理的 JSON。注意AI 在历史数据场景中最大的危险不是效果差而是“效果看起来很好”。所以任何自动化输出都必须能够回答一个问题这个值是从原文哪里得到的5. 隐私与合规是不可回避的工程问题5.1 墓地数据中的个人敏感信息墓碑数据虽然年代久远但其中的人名、生卒日期、亲属关系可能仍涉及在世后人。尤其是二十世纪中后期的墓碑去世者可能是当代人的直系亲属。如果把这些数据直接用于外部大模型训练或公开发布会带来隐私风险。工程上不能因为数据“本来就是公开刻在墓碑上”就认为可以随意处理。公开可见不等于可以被任意收集、聚合、分析这是两类问题。5.2 脱敏方案姓名与日期如何分级展示在实际系统中可以把数据分为三级数据级别内容示例处理方式完全公开建筑风格、铭文格式、碑额纹饰、年代区间“民国风格碑”可以公开到图谱和检索受控公开碑主姓名、生卒年份、立碑人关系“张某某1889-1945”需要登录或授权后才可查看全文严格受限具体日期、详细住址、后人联系方式、特殊备注“生于农历三月初八”仅限研究团队和直系亲属可查脱敏不应只做前端显示后端查询也要按角色过滤。字段置信度表与原始文本也属于受限数据因为原始 OCR 文本可能包含非常具体的细节。5.3 数据保留与访问审计任何上传到外部 LLM 的图片或文本都应该默认带有租户级隔离。如果使用公有云 API最好提前确认数据存储政策如果隐私要求高建议本地部署模型。生产系统还需要记录谁在什么时间导出了哪些记录谁查看了原始碑文图片谁修改了低置信度字段这些审计日志的位置不要和应用日志混在一起建议单独建立审计表并使用只追加权限。6. 运行与验证指标、日志和效果评估6.1 关键指标定义评估这类管线不能只看端到端准确率还需要拆开每一环。推荐使用以下指标指标含义计算方式目标参考OCR 字符准确率识别字符与人工标注字符的匹配程度编辑距离 / 总字符数视碑面质量而定一般 0.7 以上可接受字段覆盖度能被 LLM 正确抽取且人工确认的字段占比正确字段数 / 应抽取字段数越高越好但不必追求 100%幻觉率抽取结果中存在原文没有的字段占比模型输出且无依据字段数 / 总输出字段数目标小于 2%人工复核率需要人工确认的记录比例进入审核台的记录 / 总记录初期可能 30%-50%逐步降低平均复核时间每条记录人工处理耗时总复核时间 / 条数初期 2 分钟目标降到 20 秒内6.2 用测试集验证管线不要只看“看起来不错”构建一个至少包含 50 张图片的测试集每张图片都有独立人工标注作为 ground truth。运行管线后把输出和 ground truth 对比计算指标。重点看三类失败样本OCR 失败但人工可读OCR 成功但 LLM 抽取错LLM 抽取正确但置信度门禁算错测试代码最小示例# tests/test_pipeline.py def test_end_to_end(): image_path data/raw/sample_001.jpg expected {name: 张某某, birth_year: 1889, death_year: 1945} ocr_blocks ocr_engine.extract_blocks(image_path) ocr_text \n.join([b.text for b in ocr_blocks]) extracted extract_fields(ocr_text) validated validate_record(extracted, ocr_blocks) assert validated[status] pending_review # 不要求直接相等因为可能被门禁拦截测试的重点是“流程正确”而不是所有图片都得到最终结果。那些被拦截的记录同样说明系统在正常工作。6.3 预期输出示例处理完成后一条记录的输出可能如下{ id: 18, name: 张学义, name_confidence: 0.42, birth_year: null, death_year: 民国十二年, death_year_supported: false, stone_erector: 张王氏, stone_date: 民国十五年, status: pending_review, source_image: data/raw/tomb_018.jpg, crop_path: data/crops/tomb_018_death_year.png, original_ocr_text: 先考张公讳学 之墓\n民国十 年立 }death_year虽然被抽取出来了但supported: false所以字段置信度低记录进入人工审核。这样下游学者看到这条记录时至少知道日期不可靠。7. 常见问题排查从现象定位到处理方案7.1 OCR 把碑文识别成乱码现象输出文本中出现大量无意义字符或者和碑文完全无关。可能原因图片光照不均阴影压住文字。碑面青苔、裂纹干扰。OCR 语言模型与字体不匹配。图片分辨率过低。检查方式查看 OCR 单行置信度通常乱码行的分数会较低。在图像处理阶段做灰度化和对比度增强后重试。把低分文字区域裁剪出来人工查看。解决建议拍摄时使用偏振镜头或补光。预处理时使用自适应二值化。对高分区域优先送 LLM低分区域直接进人工审核。预防拍照规范里要求每个文字区域至少 100 像素高度避免远距离拍整碑后直接裁剪文字。7.2 LLM 抽取出了原文没有的字段现象明明 OCR 文本里没有“葬于”模型却输出了地点信息。原因LLM 受到了世界知识影响自动补全了常见碑文内容。检查方式使用第 4.2 节的自检提示词查看字段supported是否为 false。解决建议在提示词里加入“如果原文没有明确出现必须输出 null”。设置temperature0。对高风险字段做规则校验比如地点字段必须匹配原文中的特定字符。预防不要直接使用通用对话模型的默认配置一定要针对抽取任务写专用提示词和 JSON Schema。7.3 数据库里出现重复人物现象同一个人在不同照片中被识别成两条记录。原因同一块碑可能被拍了多张照片或者一个家族墓园里有多个相似名称的人。检查方式按姓名和生卒年份联合查询查看是否存在同人不同记录。解决建议在管道中加入基于相似度的实体归并步骤。人工审核时显示同名记录列表。使用数据库唯一索引约束“姓名 出生年份 去世年份”。预防拍照时先建立墓碑编号每块碑绑定唯一 tomb_id避免后续靠姓名硬匹配。7.4 低置信度记录太多人工审核堆积现象进入审核台的记录每天几百条团队处理不过来。原因OCR 质量整体差或者置信度阈值设置过高。检查方式统计置信度分布直方图看有多少记录卡在 0.7 到 0.8 之间。解决建议分批次调整阈值先处理 0.9 以上的全自动记录。对低分记录做图像增强后二次 OCR。将简单任务和复杂任务分流给不同审核者。预防把阈值做成可配置项不要让代码写死。8. 生产化建议与扩展方向8.1 从单机脚本到异步任务队列原型阶段用同步函数和 SQLite 完全够用。生产环境则要解决“批量导入几百张图片”时的效率和稳定性。推荐引入消息队列图片上传后写入对象存储。发送任务到 RabbitMQ 或 Redis Queue。OCR 和 LLM 抽取作为独立 worker 运行。结果写回数据库失败任务自动重试。这样即使某张图片处理失败也不会阻塞整批任务。8.2 多模态模型接入与成本控制目前比较新的多模态模型可以直接“看图抽字段”跳过了独立 OCR 步骤。这对清晰图片效果很好但成本通常更高。建议保留两套方案清晰碑文走传统 OCR 小模型成本低。风化严重的碑文走多模态大模型用更强的理解能力补足图像细节。多模态模型的输出同样必须经过置信度门禁和人工审核。成本控制的核心不是选择最便宜的模型而是让大部分数据走便宜路径只让边缘样本走贵路径。8.3 家族谱系、历史地图、知识图谱扩展墓碑记录一旦结构化就可以和家谱数据、历史地图关联。比如把“立碑人”和“碑主”关系抽取出来构建家族谱系图。把墓碑 GPS 坐标与历史地图叠加研究家族墓地的分布迁移规律。把碑文中的官职、籍贯、身份信息接入历史人物知识库。这些扩展方向都会复用前面的溯源机制任何图谱关系都必须能回溯到原始碑文影像。8.4 什么样的“牺牲”可以接受什么样的不能回到标题的问题Are we sacrificing cemeteries for AI工程上的回答是我们可以接受 AI 识别不出部分文字也可以接受人工审核量高但不能接受 AI 在不确定时编造事实不能接受原始影像被破坏不能接受个人信息被无差别的暴露。历史遗产数字化不是为了训练更强大的模型而是为了让真实信息能更长久地保存下去。AI 只有在“被约束”的前提下才是保护遗产的工具一旦脱离约束它就会变成破坏事实的加速器。建议正在做类似项目的团队第一版不用追求高自动化率。先把“原始影像只读、置信度可追溯、人工审核闭环、隐私分级访问”这四条底线搭起来再逐步提升模型效果。这样项目跑得越快数据越安全也越经得起历史研究者检验。