简介:这份2021款广汽埃安AION S Plus使用手册电子版面向该车型车主与驾驶人员,提供官方用户操作图示与驾驶指南,帮助快速熟悉车辆功能、规范日常用车与养护流程。压缩包内共1个PDF文件,约6.53MB,为广汽埃安原厂手册的完整电子文档,含前言、用户须知及图文对照的操作说明,可离线查阅或打印备用。手册内容覆盖绑定车辆与APP远程设置、智能钥匙与蓝牙钥匙激活、车门与行李箱开启等基础操作,也系统讲解安全带、安全气囊、儿童座椅与ISOFIX/LATCH接口等安全规范,以及座椅、车窗、方向盘、空调、无线充电等配置的调节方式。多媒体与故障排除部分则给出常见问题的排查思路,并附维修保养记录参考,便于车主对照实车自查。目前已有893人学习下载,适合新车用户、二手车买家及汽车爱好者参考。
1. 一本 AION S Plus 车主手册,为什么搜不到你想找的那句话
在 2021款广汽埃安AION S Plus 的使用手册电子版里按 Ctrl+F 搜「胎压」,命中的多半是「胎压监测系统报警」这类正文,写着建议值的数字却躺在轮胎规格表里,或压在车门标签示意图旁边;搜「保险丝」更典型,正文只留一句「请查阅保险丝对照表」,表本身是跨页的排布图。
这不是阅读器的问题,是车主车辆说明书的组织方式决定的:驾驶指南大量使用编号图示,用户操作图示的说明文字与图上序号分离,参数集中在表格,页码还分物理页与印刷页两套。把它当成一次文档解析任务来做,反而最快。
下面按摸版面体质、抽图示与表格、建可分页回溯的检索、做质量校验推进,适合做售后知识库、车机问答和汽修系统集成的开发者,也适合只想存一份离线速查表在手机里的车主。
2. 解析 AION S Plus 使用手册 PDF 的版面体质:先判断哪些页能直接抽文本
2.1 用 pdfinfo 和 pdffonts 三分钟判定是文字版还是扫描版
不同渠道流出的同一份手册,体质可能完全不同。官方渠道的电子版通常是排版软件生成的文字版 PDF,正文和多数字图标注都有文本层;而从打印件二次扫描、或从第三方拼版下来的文件,整页就是一张位图,文本层为空。这一步判错,后面所有抽取代码都白写。
先跑两条命令行工具,几秒钟就能定性:
# 总页数、是否加密、页面尺寸 pdfinfo 2021_AION_S_Plus_manual.pdf # 字体列表:emb 列为 yes 说明字体内嵌 pdffonts 2021_AION_S_Plus_manual.pdf | head -20pdfinfo重点看Pages(总页数,这类手册通常两三百页起步)、Encrypted(若是 yes,PyMuPDF 打开时可能需要密码参数)、Page size(判断是否标准竖版 A4,横版通常是折页图)。pdffonts的输出来得更直接:如果打印出来是一张只有表头的空表,说明页面里没有任何字体对象,基本可以断定是纯扫描件,直接跳到第 3 章的 OCR 分支。
提示:有些手册把正文做成文字层,但把整车电路图、保险丝排布图整张转成 JPEG 塞进去。这种「半文字版」最容易骗过肉眼,必须做分页统计才能发现。
2.2 用 PyMuPDF 做分页体检:字符数、图片数、矢量对象数
判断页面类型不能只看整本 PDF,要逐页统计三个指标:可抽取字符数、内嵌图片数量、矢量绘图对象数量。前两个好理解,第三个get_drawings()是关键——文字版手册里的操作图示往往是矢量线稿加文字标注,矢量对象多说明这页是图,但只要字符数不低,标注文字依然能直接抽出来,不必上 OCR。
import fitz # PyMuPDF doc = fitz.open("2021_AION_S_Plus_manual.pdf") stats = [] for pno in range(doc.page_count): page = doc[pno] # 纯文本模式,速度最快,够用 text = page.get_text("text") chars = len(text.strip()) imgs = page.get_images(full=True) # 内嵌位图数量 vectors = len(page.get_drawings()) # 矢量线条/填充对象数量 stat = { "page": pno + 1, "chars": chars, "imgs": len(imgs), "vectors": vectors, # 位图面积占比:判断是否整页就是一张扫描图 "img_ratio": round( sum((i[2] * i[3]) for i in imgs) / (page.rect.width * page.rect.height), 3 ) if imgs else 0.0, } stats.append(stat) doc.close() for s in stats[:15]: print(s)这段代码输出的是后续所有决策的依据。chars是空白剔除后的字符数,注意get_text("text")会把页眉页脚的「广汽埃安 AION S Plus 使用手册」也算进去,所以后面判定阈值要留出余量;img_ratio用图片像素面积除以页面面积,超过 0.8 基本就是整页扫描;vectors在两三百以上的页面,多半是带引出线的结构图或电路图。
跑完把结果落成 CSV,按chars升序看一遍,再随机抽 20 页人工核对,分类规则就稳了。
| 页面类型 | 判定规则(启发式) | 后续处理 |
|---|---|---|
| 纯正文页 | chars ≥ 200,img_ratio < 0.2 | 直接抽文本,按目录树分块 |
| 图示标注页 | chars 80~400,vectors ≥ 200 | 抽文本 + 坐标聚类配对序号 |
| 表格页 | chars ≥ 100,且page.find_tables()命中 | 走表格抽取分支 |
| 扫描图页 | chars < 80,img_ratio ≥ 0.8 | 渲染成图后 OCR |
| 纯插图页(无标注) | chars < 80,img_ratio < 0.8 | 整页存为图片块,只保留缩略图引用 |
2.3 抽取目录树,给每一页挂上章节路径
手册的价值一半在目录。检索「保养周期」时,返回一段孤立文字远不如返回「第 7 章 保养与维护 > 7.3 定期保养项目」有用。PDF 的目录树用get_toc()一次拿全:
import fitz doc = fitz.open("2021_AION_S_Plus_manual.pdf") toc = doc.get_toc(simple=False) # [层级, 标题, 页码(1-based), 目标信息] for level, title, page, _ in toc[:12]: print(f"{' ' * (level - 1)}{title} -> p{page}")level是层级深度(1 为章、2 为节、3 为小节),page是 1 起始的物理页码。接下来构造「页 → 章节路径」的映射:把 toc 按页排序,对任意一页,找最后一个起始页不大于它的条目,把从该条目向上到根节点的标题用>连起来,就是这一页的section_path。
后面每个 chunk、每条表格记录、每一页 OCR 结果,都要带上这个字段。没有目录的手册(少见但存在),退而求其次的做法是统计正文字号,取出现次数最多的字号为正文,比它大 2pt 以上的文本块按 y 坐标顺序当作标题,手工确认一遍层级关系。这份工作只做一次,收益贯穿整条链路。
3. 图示标注与参数表格的结构化:把驾驶指南里的图变成可查询字段
3.1 操作图示页的标注配对:优先用坐标,不要先上 OCR
用户操作图示的典型版式是:左半边一张仪表台或充电口线稿,线条上引出一串带圈数字;右半边是「① 充电口盖开启开关」这样的条目列表。文字版手册里,左右两侧的序号和条目本身都是可抽取文本,真正要做的只是配对。
做法是按页宽中线切左右栏,各自按 y 坐标排序,再用正则从条目里抠出序号,最后按序号回填到图上的标注坐标。
import re import fitz CIRCLED = "①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮" PAT = re.compile(r"^[((]?\s*(?P<idx>\d{1,2}|[" + CIRCLED + r"])\s*[))]?\s*(?P<label>.+)$") page = fitz.open("manual.pdf")[41] blocks = [b for b in page.get_text("blocks") if b[4].strip()] mid_x = page.rect.width / 2 left = sorted([b for b in blocks if b[2] <= mid_x], key=lambda b: b[1]) right = sorted([b for b in blocks if b[0] > mid_x], key=lambda b: b[1]) pairs = [] for b in right: line = b[4].strip().replace("\n", "") m = PAT.match(line) if not m: continue idx = m.group("idx") # 带圈字符换算成阿拉伯数字,统一编号口径 if idx in CIRCLED: idx = str(CIRCLED.index(idx) + 1) # 在左栏找同号标注的坐标,作为图上的位置锚点 anchor = next((b2 for b2 in left if b2[4].strip().strip("()()") == idx), None) pairs.append({ "figure_id": f"p{page.number + 1}-fig", "index": idx, "label": m.group("label").strip(), "anchor_bbox": list(anchor[:4]) if anchor else None, "page": page.number + 1, }) print(pairs[:5])anchor_bbox是把说明条目反查回图上位置的关键,答「充电口盖开关在哪」时可以直接给出图上坐标做高亮。注意两点局限:一是部分手册用引出线而非数字,anchor会取到 None,此时保留条目文本即可,不要强行配对;二是跨栏排版(一页两栏正文)会让mid_x切分失效,稳妥做法是先跑一遍page.get_text("dict"),统计文本块 x 起点的分布,双峰才判定为双栏。
3.2 扫描页与低质量插图的 OCR 预处理参数
扫描页走 OCR 之前必须先做图像预处理,直接拿原图喂识别引擎,小字号中文的错字率会高到没法用。常见做法是灰度化、二倍上采样、Otsu 自动二值化三步:
import cv2 img = cv2.imread("page_012.png") # 建议按 300dpi 渲染 gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 小字号放大两倍再识别,中文笔画粘连会明显减少 up = cv2.resize(gray, None, fx=2, fy=2, interpolation=cv2.INTER_CUBIC) # Otsu 自动找阈值,比固定 127 更适应扫描件的明暗差异 _, bw = cv2.threshold(up, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) cv2.imwrite("page_012_pre.png", bw)命令行识别用 Tesseract 的话,版面模式要选对:
# psm 6:假定整页是一块统一排版的文本,适合连续正文 tesseract page_012_pre.png stdout -l chi_sim+eng --psm 6 --oem 1 # psm 4:假定是可变宽度的多栏/表格,适合参数表和对照表 tesseract page_012_pre.png stdout -l chi_sim+eng --psm 4 --oem 1--oem 1表示只用 LSTM 引擎,中文场景比默认的混合模式稳定;--psm是版面切分假设,选错的代价是把整页识别成一行乱码。表格页用 4,正文页用 6,先各试一页肉眼比一遍再批量跑。如果对准确率要求更高,可以换成 PaddleOCR 的中文检测识别模型,检测框对表格线更友好,代价是依赖体积和推理时间。
注意:OCR 出来的数字必须打标记。轮胎胎压、保险丝安培数、螺栓扭矩这类数值一旦识别错一位,后果比漏抽严重得多,后面第 5 章的锚点校验就是专门拦这类错误的。
3.3 三类参数表格的处理差异:胎压表、保险丝对照表、保养周期表
手册里的表格不是一种东西,处理策略要分开。
胎压表通常行少列短,单位写在表头或单元格里(kPa 和 psi 混排),并且按车型配置分列。抽取时要把单位从单元格里剥离出来做归一化,否则「240」和「35」会被当成同一量纲的两个值。
保险丝对照表是行数最多、最容易跨页的一类,三列结构通常是「编号 / 额定电流 / 保护部件」。跨页时下一页会重复表头,需要识别并去重,否则会出现一条编号为「编号」的脏数据。去重规则取表头相似度:把本页首行与上一页表头逐格比对,相同格数占比超过 80% 就丢弃。
保养周期表的坑在空单元格。它首列是项目,后续列是里程或时间区间,空单元格的含义是「该项目在此周期不需要做」,而不是「数据缺失」。抽取时必须区分null(真的没有)和"—"(不需要),否则下游做提醒逻辑时会误报。
import pdfplumber def header_similarity(a, b): if not a or not b or len(a) != len(b): return 0.0 same = sum(1 for x, y in zip(a, b) if (x or "").strip() == (y or "").strip()) return same / len(a) def extract_cross_page_tables(path, pages): prev_header, rows = None, [] with pdfplumber.open(path) as pdf: for pno in pages: page = pdf.pages[pno] tables = page.extract_tables({ "vertical_strategy": "lines", # 有线框的表格直接用线检测 "horizontal_strategy": "lines", "snap_tolerance": 3, # 像素级吸附,容忍扫描噪声 "join_tolerance": 3, "intersection_tolerance": 5, # 交叉点容差,抗线条断裂 }) for tb in tables: if not tb or len(tb) < 2: continue head, body = tb[0], tb[1:] if header_similarity(head, prev_header) > 0.8: body = tb[1:] # 续表:丢掉重复表头 else: prev_header = head for r in body: rows.append({"header": prev_header, "cells": r, "page": pno + 1}) return rowsvertical_strategy和horizontal_strategy设成lines表示只认显式表格线,适合手册里绝大多数带框表格;如果表格没有外框(纯靠空白对齐),改成"text"并按列间距调min_words_vertical。三个容差参数是抗扫描噪声的,snap_tolerance越小越严格,扫描件建议放到 3~5。
3.4 统一的块级 schema:后续检索只认一种结构
抽完文本、配对、表格、OCR 之后,全部归一化成同一种块结构,落成 JSONL,每行一个块:
| 字段 | 类型 | 说明 |
|---|---|---|
block_id | string | page-blockindex,全局唯一 |
block_type | string | text/table/figure/ocr |
page_physical | int | PDF 物理页码,从 1 开始 |
page_printed | int/null | 手册页脚印刷页码,见第 5 章对齐 |
section_path | string | 目录树拼出的章节路径 |
text | string | 文本内容;表格给序列化后的 markdown |
table_json | array/null | 表格保留二维结构,供精确查询 |
confidence | float | OCR 来源时给置信度,非 OCR 为 1.0 |
source_hash | string | 原文片段哈希,用于版本比对 |
table_json和text同时保留是刻意的:前者给「保险丝 15 号是哪个部件」这类精确查询用,后者给向量检索用。只留一种,另一类查询就会退化。
4. 给 AION S Plus 手册建检索:分块、页码回溯和数字兜底
4.1 按目录树分块,别按固定字数切
固定 500 字滑窗在这类手册上效果很差:一个操作说明被从中间切断,上半段讲「将充电枪插入充电口」,下半段讲「听到锁止声后松开」,两段都不完整。正确做法是先按section_path切,同一小节内如果超过 500 字再按段落切,相邻块保留 1 段重叠。
表格块单独成块,不参与字数切分,并且要把表头和章节路径拼成前缀。原因很实际:胎压表里某一行只写了「240」,向量模型不可能知道这是 kPa 还是 psi;拼上「第 6 章 轮胎与车轮 > 6.2 轮胎气压 | 规格 215/50 R17 | 前轮 后轮 | 单位 kPa」之后,检索「前轮胎压多少」才可能命中。
def build_chunks(blocks, max_chars=500, overlap_paras=1): chunks = [] for b in blocks: if b["block_type"] in ("table", "figure"): # 表格/图示整块保留,表头转成前缀文本 prefix = f'{b["section_path"]} | {b.get("caption", "")}' chunks.append({ "chunk_id": b["block_id"], "content": prefix + "\n" + b["text"], "meta": {k: b[k] for k in ("block_type", "page_physical", "section_path")}, }) continue paras = [p for p in b["text"].split("\n") if p.strip()] buf = "" for p in paras: if len(buf) + len(p) > max_chars and buf: chunks.append({ "chunk_id": f'{b["block_id"]}-{len(chunks)}', "content": buf.strip(), "meta": {"block_type": "text", "page_physical": b["page_physical"], "section_path": b["section_path"]}, }) tail = paras[max(0, paras.index(p) - overlap_paras):paras.index(p)] buf = "\n".join(tail) + "\n" + p else: buf += "\n" + p if buf.strip(): chunks.append({ "chunk_id": f'{b["block_id"]}-{len(chunks)}', "content": buf.strip(), "meta": {"block_type": "text", "page_physical": b["page_physical"], "section_path": b["section_path"]}, }) return chunksmax_chars取 400~600 之间比较稳,中文句向量模型的有效窗口普遍够用;overlap_paras保留 1 段即可,多了会让同一条答案在多个 chunk 里重复召回。
4.2 元数据与检索表结构:页码回溯是刚需
车主问「这个灯亮了怎么办」,回答里必须能给出页码,否则等于没答。元数据设计要围绕可回溯来定,用 SQLite 存就够,不必上重型组件:
CREATE TABLE chunks ( chunk_id TEXT PRIMARY KEY, content TEXT NOT NULL, -- 供向量化与全文检索的正文 block_type TEXT NOT NULL, -- text / table / figure / ocr page_physical INTEGER NOT NULL, -- 物理页,定位 PDF 用 page_printed INTEGER, -- 印刷页,念给用户翻书用 section_path TEXT NOT NULL, manual_ver TEXT NOT NULL, -- 手册版本标识,用于多版本共存 source_hash TEXT NOT NULL ); -- 关键词兜底用:FTS5 建全文索引,数字和专有名词靠它 CREATE VIRTUAL TABLE chunks_fts USING fts5( content, chunk_id UNINDEXED, tokenize = 'unicode61' ); CREATE INDEX idx_chunks_page ON chunks(page_physical); CREATE INDEX idx_chunks_type ON chunks(block_type);manual_ver这一列容易被忽略。同一款车不同年款的手册会更新,把版本写进元数据,检索时按版本过滤,才不会把 2021 款的参数答成别的年款。source_hash用于比对两个版本之间哪些块变了,做增量更新时只重算变化的部分。
4.3 数字与单位抽取:让「胎压多少」命中精确值
参数类问题不能只靠向量相似度。向量检索对「胎压多少」和「胎压警告灯」的区分能力很弱,必须加一层正则提取,把「数值 + 单位」建成独立索引,命中时直接返回精确值。
import re NUM_UNIT = re.compile( r"(?P<val>\d+(?:\.\d+)?)\s*(?P<unit>kPa|KPa|bar|psi|PSI|kW|kWh|" r"V|A|Ah|km|℃|°C|mm|L|N·m|N\.m)" ) # 统一到基准单位,方便跨页比较和区间判断 TO_BASE = { "kpa": ("pressure", 1.0), "bar": ("pressure", 100.0), "psi": ("pressure", 6.894757), "kw": ("power", 1.0), "kwh": ("energy", 1.0), "km": ("distance", 1.0), "mm": ("length", 1.0), } def extract_values(text): out = [] for m in NUM_UNIT.finditer(text): val = float(m.group("val")) key = m.group("unit").lower() dim, factor = TO_BASE.get(key, (None, 1.0)) if dim is None: continue out.append({ "raw": m.group(0), "value": val, "unit": m.group("unit"), "dim": dim, "base": round(val * factor, 3), # 统一基准值 }) return out归一化到基准值之后,「2.4 bar」「240 kPa」「35 psi」会被识别成同一个量纲下的可比值,做区间判断(前后轮胎压是否一致)才不会出错。注意℃的匹配要放在°C之后,避免同一段文字被重复计数;N·m里的间隔号在不同 PDF 里可能是.或空格,所以两种写法都列进正则。
4.4 混合检索的最小实现:BM25 与向量结果做 RRF 融合
单靠向量,编号类查询(「15 号保险丝」)经常召不回对人;单靠 BM25,口语提问(「充电的时候能不能开空调」)又召不回。常见做法是两路各取前 20,用倒数排名融合(RRF)合并。
def rrf_fuse(vector_hits, bm25_hits, k=60, top_n=8): """两路召回按排名倒数相加,k 越大越平滑,一般取 60""" score = {} for rank, cid in enumerate(vector_hits): score[cid] = score.get(cid, 0) + 1 / (k + rank + 1) for rank, cid in enumerate(bm25_hits): score[cid] = score.get(cid, 0) + 1 / (k + rank + 1) return sorted(score.items(), key=lambda x: -x[1])[:top_n]k取 60 是经验值,作用是压低头部排名的绝对优势,让两路召回都有机会进最终结果。融合之后再挂一层轻量重排(比如用交叉编码器对前 8 条打分重排),代价可控,命中率提升明显。最终回答模板里固定带上section_path和page_printed,用户能直接翻到对应页。
5. 进阶:用锚点用例校验抽取质量,并把物理页对齐到印刷页
5.1 建一组锚点断言,每次重跑都跑一遍
抽取链路改一点参数就可能悄悄崩掉,靠肉眼翻页检查不现实。做法是挑 15 到 20 个确定存在的事实做锚点,写成一跑就出结果的断言表。选择标准是:数值唯一、位置固定、跨页分布。
| 用例 ID | 目标内容 | 期望类型 | 判定方式 |
|---|---|---|---|
tire_pressure | 轮胎气压建议值 | table | 命中表格且 base 值落在合理区间 |
tire_spec | 轮胎规格 | table | 正则匹配\d{3}/\d{2}\s?R\d{2} |
fuse_15 | 15 号保险丝保护部件 | table | 表格行索引命中,非空 |
charge_cover | 充电口盖操作 | figure | 存在 index=1 的配对条目 |
warning_lamp | 仪表警告灯一览 | figure/table | 章节路径含「指示灯」 |
maintenance_1st | 首次保养里程 | table | 数值 + km 单位命中 |
def run_anchors(chunks, cases): failed = [] for c in cases: hits = [k for k in chunks if c["section_kw"] in k["meta"]["section_path"] and k["meta"]["block_type"] == c["expect_type"]] if not hits: failed.append((c["id"], "no-hit")); continue blob = "\n".join(k["content"] for k in hits) if c.get("regex") and not re.search(c["regex"], blob): failed.append((c["id"], "regex-miss")) elif c.get("value_range"): vals = [v["base"] for v in extract_values(blob) if v["dim"] == c["value_range"][0]] if not any(c["value_range"][1] <= v <= c["value_range"][2] for v in vals): failed.append((c["id"], "range-miss")) return failed断言表要在最初人工核对那 20 页时同步建起来,后面每次换 OCR 参数、换表格策略、换分块长度,跑一次就知道有没有回归。失败项直接定位到页,比在整个 PDF 里翻找快得多。
5.2 物理页与印刷页的偏移映射
PDF 里的第 1 页是封面,手册页脚印的可能是「1」,也可能是没有任何编号的扉页之后才开始计。检索结果给物理页,用户翻纸质手册或按页码输进阅读器时会对不上。映射关系很简单:偏移 = 物理页 - 印刷页,通常在目录之后固定下来。
检测方法是逐页正则抽页脚数字:
import re, fitz doc = fitz.open("manual.pdf") offsets = {} for pno in range(doc.page_count): page = doc[pno] # 只取页面底部 8% 区域的文本,避开正文 clip = fitz.Rect(0, page.rect.height * 0.92, page.rect.width, page.rect.height) tail = page.get_text("text", clip=clip) m = re.search(r"(?:^|\s)(\d{1,4})(?:\s|$)", tail) if m: printed = int(m.group(1)) offs = (pno + 1) - printed offsets[offs] = offsets.get(offs, 0) + 1 # 出现次数最多的偏移值即为整本手册的主偏移 main_offset = max(offsets.items(), key=lambda x: x[1])[0] print("主偏移 =", main_offset)取众数而不是取第一个匹配值,是因为封面、插页、折页图可能带无关数字。算出main_offset之后写回page_printed = page_physical - main_offset,负数或超出总页数的判为无印刷页码,置为null。
这一步做完,检索结果里显示的页码就和手册页脚上的数字一致了,用户报「第 156 页那个警告灯」时,两边说的是同一页。
本文还有配套的精品资源,点击获取