简介这份2023年外泌体行业分析报告以PPTX演示文稿呈现面向生物医药研究者、产业分析师、投资人与关注再生医学的从业者用于系统梳理外泌体技术从基础概念到商业化落地的整体图景。压缩包共1个文件为pptx格式大小约4.55MB内容按概述、行业现状、应用领域、市场分析、发展瓶颈与解决方案、研究前沿与展望等模块组织。幻灯片中既有外泌体定义、分类、结构、功能及分泌摄取机制等基础知识也覆盖肿瘤免疫治疗、药物递送、疾病诊断、组织工程、软骨修复、神经再生、美容护肤与细胞治疗等场景并对市场规模、超过15%的年增长预期、主要参与者、技术转化和提取纯化、检测表征等挑战做了归纳。已有93人学习下载适合用于快速建立行业认知、整理汇报框架或辅助选题调研。1. 从一份 2023 年外泌体行业分析报告 PPTX 说起做行业研究的人手里大概率躺着几份这样的文件四十到八十页前面十几页讲技术路线中间是市场规模和产业链图谱后面是公司管线、融资和监管进度。真正要用的时候才发现想回答「NTA 和 TRPS 在表征环节各占多少」「外泌体 CDMO 产能集中在哪几家」「诊断管线扎堆在哪些标志物」这类问题只能一页页翻翻完还无法复现。PPTX 的本质是一个 OOXML 压缩包正文段落、表格、图表数值、演讲者备注、贴图各自是独立部件都能被程序按路径取出来。把「2023年外泌体行业分析报告.pptx」拆成结构化数据价值不在转成 Markdown而在于指标可对账、结论可回溯到具体页码、明年新报告出来能做跨版本差异比对。这条链路适合三类人做投研和 BD、需要把报告变成可查询底表的人做行业数据库、要把非结构化 PPT 灌进检索系统的人以及做数据工程、被要求「把这份 PPT 整理成 Excel」的人。下面按 unzip 看结构、抽取与归一化、检索切块、差异校验四步走完每步都给能直接跑的代码和会踩的坑。2. 拆解 PPTX 的 OOXML 结构先看清 slide、chart、embedding 三类零件处理这类报告最常见的失败方式是直接上python-pptx遍历slide.shapes打印文本然后发现图表一个数字都没拿到贴图页整页空白产业链图谱那一页只剩几个孤零零的标签。原因是 PPTX 里「看起来是一张图的东西」至少有三种物理形态读取方式完全不同。2.1 一次 unzip 就能看到的目录树把 pptx 后缀改成 zip 解开或者直接用 Python 的zipfile遍历会看到这样的结构部件路径内容读取方式常见坑ppt/slides/slideN.xml该页文本框、表格、图形的位置与文字XML / python-pptx页码 N 不等于放映顺序要看presentation.xml的 sldIdLstppt/notesSlides/notesSlideN.xml演讲者备注常含口径说明和来源python-pptx 的notes_slide访问即创建只读遍历要用has_notes_slide判断ppt/charts/chartN.xml图表类型、系列名、缓存数值python-pptx 的shape.chart只给缓存值改了内嵌表格没刷新会读到旧数ppt/embeddings/*.xlsx图表背后的原始工作簿zipfile pandas一份报告可能嵌十几个文件名无规律ppt/media/imageN.png贴图、截图、图谱导出后 OCR分辨率决定 OCR 质量原图往往够用docProps/app.xml每页标题清单TitlesOfPartsXML只反映占位符标题正文页往往为空一个容易被忽略的细节很多行业报告的章节划分是写在ppt/presentation.xml的p14:sectionLst扩展里的python-pptx 不暴露这个接口得自己读 XML。章节名对后面切块非常有用因为它天然对应「技术路线」「市场规模」「公司管线」这类语义边界。import re, zipfile def read_sections(pptx_path: str) - list[str]: 读取 PPT 自定义节section名称用于后续按章节切块 with zipfile.ZipFile(pptx_path) as z: xml z.read(ppt/presentation.xml).decode(utf-8) # p14:sectionLst 里每个 p14:section 带 name 属性 return re.findall(rp14:section[^]*name([^]), xml)逻辑说明p14是 Office 2010 之后的扩展命名空间section 列表挂在presentation.xml尾部正则匹配name属性即可拿到章节名顺序。参数上不需要额外配置如果返回空列表说明这份 PPT 没有用「节」组织退化成按页切块即可不要在这里死磕。2.2 用 python-pptx 枚举形状时的正确姿势slide.shapes只返回顶层形状。产业链图谱、流程图几乎一定是组合形状group必须递归展开否则整块内容丢失。展开之后还要按阅读顺序排序——PPT 里形状的 XML 顺序是编辑顺序不是人眼顺序直接遍历会得到语序错乱的段落。from pptx import Presentation from pptx.enum.shapes import MSO_SHAPE_TYPE EMU_PER_LINE 228600 # 0.25 英寸作为「同一行」的聚类阈值 def walk(shapes): 递归展开组合形状产出所有叶子形状 for shape in shapes: if shape.shape_type MSO_SHAPE_TYPE.GROUP: yield from walk(shape.shapes) else: yield shape def reading_order(shapes): 按上到下、左到右排序同一行内按 left 排 def key(s): top s.top if s.top is not None else 0 left s.left if s.left is not None else 0 return (round(top / EMU_PER_LINE), left) return sorted(shapes, keykey) prs Presentation(2023年外泌体行业分析报告.pptx) for idx, slide in enumerate(prs.slides, start1): for shape in reading_order(list(walk(slide.shapes))): if shape.has_text_frame and shape.text_frame.text.strip(): print(idx, shape.shape_id, shape.text_frame.text[:40].replace(\n, ))逻辑说明EMU_PER_LINE是行聚类阈值PPT 坐标单位是 EMU914400 EMU 等于 1 英寸。取 0.25 英寸能在大多数版式下把同一行的左右两栏归到一行同时不会把上下两行误并。若报告用了大字号标题行高超过 0.5 英寸把阈值调到 457200 更稳。shape.shape_id建议保留后面做结论回溯时要靠它定位到具体形状。2.3 图表不是图片chart XML 与内嵌工作簿的入口图表页是信息密度最高的部分也是最容易漏的。shape.has_chart为真时可以直接拿系列名和数值has_table为真时拿表格。但要注意两个陷阱一是组合形状里的图表需要递归后判断二是图表缓存值可能和你看到的渲染结果不一致严谨场景要回到内嵌 xlsx 取数。import io, zipfile import pandas as pd def dump_charts(prs): for idx, slide in enumerate(prs.slides, start1): for shape in walk(slide.shapes): if not getattr(shape, has_chart, False): continue ch shape.chart cats list(ch.plots[0].categories) # 横轴分类 for s in ch.series: print(idx, ch.chart_type, s.name, cats[:3], list(s.values)[:3]) def read_embedded_workbooks(pptx_path: str) - dict: 把 ppt/embeddings 下所有 xlsx 读成 DataFrame 字典键为部件名 out {} with zipfile.ZipFile(pptx_path) as z: names [n for n in z.namelist() if n.startswith(ppt/embeddings/) and n.endswith(.xlsx)] for n in names: out[n] pd.read_excel(io.BytesIO(z.read(n)), sheet_nameNone, headerNone) return out逻辑说明ch.plots[0].categories是横轴标签多系列图表的分类在第一个 plot 上s.values是纵轴数值注意它可能是None系列只有名称没有数据要做空值保护。read_embedded_workbooks用headerNone读因为报告里的图表工作簿首行经常是标题而不是表头强行让 pandas 猜表头会把真实第一行数据吃掉。拿到的 DataFrame 字典再按内容判断哪张表对应哪个图表用系列名做匹配最可靠。提示如果只想快速确认某页图表的数据源把 pptx 解压后直接搜ppt/embeddings/按修改时间排序最新写入的通常就是刚编辑过的那张图。3. 把外泌体行业指标抽成结构化表文本、表格、图表三路合流拿到零件之后要解决的是「口径」问题。同一份 2023 年外泌体行业分析报告里市场规模可能同时出现「亿元」「亿美元」「百万美元」分离工艺可能写作「超速离心」「UC」「差速离心」三种说法检测方法写作「NTA」「纳米颗粒跟踪分析」。不归一化就做不了聚合也做不了跨版本对账。3.1 正文与表格抽取按阅读顺序重建段落正文抽取的关键是不要按 run 切。一个中文句子的不同字词可能被拆成多个 run改过字体、加粗就会拆按 run 输出会得到碎片。正确粒度是段落text_frame.paragraphs里每个 paragraph 的text是完整一行。表格抽取要处理合并单元格。python-pptx 会把被合并区域里的每个格子都返回同一段文字直接落表会出现大量重复值。相邻去重是最省事且效果稳定的做法。def table_to_rows(tbl) - list[list[str]]: rows [] for row in tbl.rows: cells [c.text.strip().replace(\n, ).replace(\u00a0, ) for c in row.cells] # 合并单元格会让同一文本连续重复按相邻去重压缩 merged [c for i, c in enumerate(cells) if i 0 or c ! cells[i - 1]] rows.append(merged) return rows def slide_text_blocks(slide) - list[dict]: blocks [] for shape in reading_order(list(walk(slide.shapes))): if shape.has_table: for r in table_to_rows(shape.table): blocks.append({kind: table_row, shape_id: shape.shape_id, text: | .join(x for x in r if x)}) elif shape.has_text_frame and shape.text_frame.text.strip(): for p in shape.text_frame.paragraphs: t p.text.strip() if t: blocks.append({kind: text, shape_id: shape.shape_id, text: t}) return blocks逻辑说明相邻去重只能解决横向合并纵向合并整列合并的「技术路线」大类仍会产生跨行重复这类要在 DataFrame 层用前向填充处理而不是在抽取层丢数据。kind字段区分text和table_row是有意保留的——表格行在检索时应该整体召回拆成单格会丢掉「指标名 数值 单位」的上下文。3.2 图表数值还原读 ppt/embeddings 下的 xlsx图表页里最典型的是「2019—2023 年全球外泌体市场规模及增速」这类双轴图柱状是规模折线是增速系列名写在图例里。用shape.chart拿到的系列名往往是「系列1」因为制图时没改默认名。这时候必须回到内嵌工作簿靠行列结构还原语义。常见做法是先读所有内嵌 xlsx找出区域面积最大、且第一列是年份或类别的那张表把首行当系列名、首列当分类转成长表结构。import numpy as np def xlsx_to_long(df: pd.DataFrame, sheet_hint: str 市场规模): 把宽表首行系列名、首列分类转成长表便于与正文指标合并 raw df.dropna(howall).dropna(axis1, howall) if raw.shape[0] 3 or raw.shape[1] 2: return None header [str(x) for x in raw.iloc[0].tolist()] body raw.iloc[1:].copy() body.columns header first_col body.columns[0] long body.melt(id_vars[first_col], var_nameseries, value_namevalue) long long.rename(columns{first_col: category}) long[value] pd.to_numeric(long[value], errorscoerce) long[sheet_hint] sheet_hint return long.dropna(subset[value])逻辑说明dropna(howall)与dropna(axis1, howall)用来清掉工作簿里的大片空行列这是图表工作簿的常态。header raw.iloc[0]是冒险但有效的假设——制图数据的首行几乎总是系列名。若返回的series全是「系列1」「系列2」说明首行不是表头此时退回用shape.chart的系列名加categories手工对齐不要硬套。3.3 领域词典与单位归一化把「超速离心/UC」合成一个实体归一化要建两张表实体别名表和单位换算表。前者靠外泌体领域的常识先写一版再从上一步抽出的全量文本里用频次补充后者处理「亿元 / 亿美元 / 百万美元」这类混用。原始写法归一化标签类型说明超速离心、UC、差速离心分离_超速离心工艺差速离心通常指同一路径的前置步骤合并统计SEC、尺寸排阻、凝胶过滤分离_SEC工艺与 UC 常串联使用标签不互斥TFF、切向流过滤分离_TFF工艺多用于中试放大场景NTA、纳米颗粒跟踪分析表征_NTA表征常与 TRPS、DLS 并列出现CD63、CD81、CD9、TSG101标志物_四跨膜蛋白标志物MISEV 指南里的经典组合检索时按组召回亿元、亿美元、百万美元金额_*单位统一折算到人民币亿元注明汇率假设import re ALIAS { 分离_超速离心: [超速离心, UC, ultracentrifugation, 差速离心], 分离_SEC: [SEC, 尺寸排阻, 凝胶过滤, size exclusion], 分离_TFF: [TFF, 切向流过滤], 表征_NTA: [NTA, 纳米颗粒跟踪分析], } def normalize_entity(text: str) - list[str]: hits [] for label, words in ALIAS.items(): if any(w.lower() in text.lower() for w in words): hits.append(label) return hits UNIT {亿元: 1.0, 亿: 1.0, 万美元: 0.0007, 百万美元: 0.007, 亿美元: 0.07, 百万: 1e6} def parse_amount(text: str, fx: float 7.0) - float | None: m re.search(r([\d,](?:\.\d)?)\s*(亿美元|百万美元|万美元|亿元|亿), text) if not m: return None num float(m.group(1).replace(,, )) unit m.group(2) if unit.endswith(美元): # 先按汇率折成人民币再统一到「亿元」 usd num * {亿美元: 1e8, 百万美元: 1e6, 万美元: 1e4}[unit] return usd * fx / 1e8 return num * {亿元: 1.0, 亿: 1.0}[unit]逻辑说明normalize_entity用子串匹配而非分词因为「超速离心」这类词在 jieba 默认词典里可能被切碎反而不如直接匹配稳定代价是「UC」这种短英文缩写容易误命中比如单词内含 UC实际使用时对纯 ASCII 缩写加词边界断言\bUC\b。parse_amount的fx是汇率参数必须显式传参并在结果表里留一列记录假设值——行业报告做同比时汇率口径不一致是结论翻车的头号原因。4. 让报告可检索按 slide 切块、加元数据、做混合召回抽完数据只是第一步。真正被高频使用的形态是「问一句话返回报告里的具体页码和原文」。把几十页 PPT 切成检索单元时最忌讳按固定字数硬切——指标名和数值被切到两个块里谁都答不准。4.1 切块策略标题-正文-表格行三层粒度合理粒度是三层的一张 slide 作为一个父块slide 内的标题、正文段落、表格行各自作为子块。父块用于给子块补上下文页码、章节、页标题子块用于精确命中。字段示例用途slide_no27答案回溯到具体页码section公司管线从p14:sectionLst取用于过滤block_kindtext / table_row / chart_series不同块给不同权重entity_tags分离_SEC, 表征_NTA结构化过滤条件shape_id105定位到页面上的具体形状页标题怎么取如果是标题占位符用slide.shapes.title.text如果不是行业报告大量使用手绘标题框用阅读顺序里 top 最小、字号最大的文本框内容兜底。取不到就留空不要用第一段正文冒充标题。4.2 BM25 加向量混合召回的最小实现行业问答里两类查询都有精确术语查询「TSG101」和语义查询「哪些工艺适合放大生产」。只用向量术语会漂只用 BM25同义改述召回不到。混合召回是性价比最高的选择。import jieba, numpy as np from rank_bm25 import BM25Okapi STOP set(的 了 和 与 及 在 是 为 对 中 上 下 等 一 个 以及.split()) ALIAS_WORDS [超速离心, 尺寸排阻, 切向流过滤, 纳米颗粒跟踪分析, 外泌体, 标志物, 冷冻电镜, 电穿孔] def tokenize(text: str) - list[str]: for w in ALIAS_WORDS: jieba.add_word(w, freq20000) # 保证领域词不被切碎 return [w for w in jieba.lcut(text.lower()) if w.strip() and w not in STOP and len(w) 1] def hybrid_search(query, chunks, embed_fn, top_k8, alpha0.5): alpha 控制向量权重0 纯 BM251 纯向量 corpus [tokenize(c[text]) for c in chunks] bm25 BM25Okapi(corpus) bm_scores np.array(bm25.get_scores(tokenize(query))) doc_vecs np.array([embed_fn(c[text]) for c in chunks]) q_vec np.array(embed_fn(query)) cos doc_vecs q_vec / (np.linalg.norm(doc_vecs, axis1) * np.linalg.norm(q_vec) 1e-9) norm lambda x: (x - x.min()) / (x.max() - x.min() 1e-9) final alpha * norm(cos) (1 - alpha) * norm(bm_scores) order np.argsort(-final)[:top_k] return [(chunks[i], float(final[i])) for i in order]逻辑说明jieba.add_word必须在分词前调用且只调一次反复调用会拖慢频率给 20000 是为了压过默认词典里的切分倾向。alpha0.5是行业报告的稳妥起点——术语密度高、表述相对固定的文档把alpha调到 0.3 会更准如果用户提问偏口语化调到 0.7。两路分数必须先做 min-max 归一再加权BM25 分值和余弦相似度量纲完全不同直接相加等于让 BM25 单方面决定排序。4.3 元数据过滤按章节、页码、指标类型收窄混合召回之后经常需要收窄范围比如「只看公司管线那一节的表格行」。这类需求用元数据过滤比调权重有效得多也更好解释。def filter_chunks(chunks, sectionNone, kindsNone, page_rangeNone): out chunks if section: out [c for c in out if section in (c.get(section) or )] if kinds: out [c for c in out if c.get(block_kind) in kinds] if page_range: lo, hi page_range out [c for c in out if lo c.get(slide_no, -1) hi] return out逻辑说明三个条件都是可选收窄不做任何默认值兜底避免调用方以为过滤生效实际没有。page_range在真实场景里很常用——用户会说「后面附录那部分」此时先用章节名定位页码范围再传进来。返回结果的块要带上slide_no和shape_id前端做高亮跳页时不必再查一次原始文件。注意表格行子块入库时建议把同一张表的前两行通常是表头和大类拼到每个子块的文本前面成本很低但能把「数值行脱离表头无法理解」这类错误大幅压下去。5. 进阶校验跨版本差异比对与结论回溯把 2022 和 2023 两份外泌体行业分析报告一起处理能做一件单份报告做不到的事找出结论被改动的具体位置。这在尽调和投资复核里比单份问答更值钱。5.1 数值级 diff按指标主键对齐两年数据对齐的前提是两张宽表都已经归一化到「指标名 年份 数值 单位」的长表结构。主键取指标名的归一化标签而不是原始字符串否则「超速离心占比」和「UC 占比」会被当成两个指标。import pandas as pd def diff_metrics(old: pd.DataFrame, new: pd.DataFrame, key指标, tol0.01): cmp old.merge(new, onkey, howouter, suffixes(_2022, _2023), indicatorTrue) added cmp[cmp[_merge] right_only][[key]] dropped cmp[cmp[_merge] left_only][[key]] both cmp[cmp[_merge] both].copy() both[变化率] (both[数值_2023] - both[数值_2022]) / \ both[数值_2022].abs().replace(0, pd.NA) changed both[both[变化率].abs() tol] return added, dropped, changed.sort_values(变化率, keylambda s: s.abs(), ascendingFalse)逻辑说明indicatorTrue一次性区分新增、删除、共有三类指标比做两次集合运算清晰。tol0.01是相对阈值用来过滤四舍五入和汇率微小波动带来的噪声。replace(0, pd.NA)防止基期为零时除零报错——报告里「新增品类」的基期经常就是 0这类行应该单独看而不是塞进变化率排序。5.2 幻灯片级 diff 与人工复核工作流数值对完还要看表述。用difflib对同一页的文本块序列做差异比对能把「措辞调整」和「结论反转」区分开。变更类型判定规则处置数值调整同一指标变化率超阈值自动进复核清单标注页码新增指标新版有、旧版无判断是统计口径扩展还是新赛道出现文本微调字符级相似度高于 0.9可忽略除非含「首次」「唯一」等强断言结论反转相似度低且含否定词或趋势词必须人工确认优先级最高页面新增新版页数多出且无对应页整页进复核清单import difflib def slide_text_diff(old_blocks, new_blocks, threshold0.9): old_txt \n.join(b[text] for b in old_blocks) new_txt \n.join(b[text] for b in new_blocks) ratio difflib.SequenceMatcher(None, old_txt, new_txt).ratio() if ratio threshold: detail [l for l in difflib.unified_diff( old_txt.splitlines(), new_txt.splitlines(), lineterm, n0) if l.startswith((, -)) and not l.startswith((, ---))] return {ratio: ratio, diff: detail} return None逻辑说明页级对齐先用章节名加页标题做粗匹配匹配不上的页再按页面顺序兜底能避免因中间插页导致后面全部错位。SequenceMatcher的ratio是基于字符的对中文长度差异不敏感所以阈值给 0.9n0让 diff 只输出变更行本身方便直接渲染成复核清单。所有差异结果落成一张带slide_no的 CSV挂到复核看板上人工只处理清单里的行不再整份重读。本文还有配套的精品资源点击获取