ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

RAG知识库格式选型:JSON为何优于Markdown?

RAG知识库格式选型:JSON为何优于Markdown? 突然想起来之前的一个RAG知识库项目规模不大文档也不算多但就是检索效果一直上不去。用户问“这个产品的退换货政策是什么”系统给出的片段却是产品介绍里的一段话甚至还有一句“详见附录”整个回答支离破碎。当时我把问题归咎于分块策略、嵌入模型折腾了好几天最后发现根子出在文档格式上——我把所有原始资料都转成了Markdown然后按标题切块。后来我把整个知识库的底层存储改成了JSON结构同样的分块策略、同样的嵌入模型检索命中率和回答质量肉眼可见地上来了。这篇内容就围绕“RAG场景下为什么JSON格式优于Markdown”展开结合一次真实迁移经验讲清楚格式选型背后的逻辑、实操步骤和踩过的坑。适合正在做RAG知识库、想要优化检索效果的工程师以及被“文档解析质量差”折磨的产品和技术负责人。如果你还在用一堆Markdown文件直接喂给RAG这篇文章应该能帮你省下一两个星期的排错时间。1. 先搞清楚RAG里“文档格式”到底卡在哪个环节1.1 一次让我印象深刻的线上事故那个项目是一个智能客服问答系统技术栈很典型FastAPI LangChain LangGraph RAG PGVector知识库存的是几千篇产品手册和FAQ文档。最初的方案很简单把所有文档归一成Markdown然后用LangChain的MarkdownHeaderTextSplitter做分块再嵌入PGVector。我当时的想法是Markdown有标题层级切出来的块天然带着结构信息应该比纯文本好得多。上线之后效果确实能用但就是那种“能用但不够聪明”的感觉。用户问“怎么开发票”系统有时候能从“发票常见问题”里捞对内容但更多时候会返回“发票”这个词出现频率最高的段落比如某篇文档开头写“本文档适用于所有发票相关业务”然后就没有然后了。排查时我把一条真正出错的Query拿下来跟踪整个链路解析→分块→嵌入→检索→重排→生成。问题很快暴露了Markdown分块完全依赖标题但很多FAQ的正文是表格表格一拆表头和正文就被分到不同块里检索时经常只捞到表头还有不少文档用了“####”四级标题到切块时标题语义已经稀释得厉害更麻烦的是一个文档里多个“注意”“警告”这类列表项会被切成一堆孤零零的短块嵌入之后向量距离全挤在一起区分度很差。这个事故让我意识到一个核心问题文档格式决定了RAG上游的质量天花板而Markdown这种“给人类阅读设计的轻量标记语言”在RAG链路里并不够用。1.2 RAG流程里格式影响的四个环节RAG虽然听起来高大上但本质上就是“先把知识切成小块存起来用户提问时捞相关块喂给模型”。这个链路里文档格式至少影响下面四个环节解析的确定性程序能否稳定地把文档转成结构化对象。Markdown的解析规则不唯一不同解析器对同一段文档可能产生不同的ASTJSON的解析是标准化的要么成功要么报错。分块的边界质量切出来的块是否语义完整。Markdown按视觉层级的标题切分很难保证切分边界正好落在语义边界上JSON按字段切分一条记录就是一个完整事实边界自然对齐。元数据的挂钩能力检索时能不能按某个字段做过滤或加权。Markdown的标题只是显示层级不是字段过滤和加权基本靠全文关键词JSON天然区分key和value可以直接把产品线、版本、部门、更新时间这类信息挂在元数据上。生成时的上下文拼接把检索到的内容拼给大模型时内容是否紧凑、易读。Markdown拼接出来的片段经常夹带无关的无序列表符号和代码块标记JSON可以按字段组装把最核心的答案放在最前面。这四个环节里只要有一个出问题最终生成质量就会打折。而Markdown恰好四个环节都处在“能跑但不优”的状态。接下来我从原理层面拆一下为什么它输得这么全面。2. Markdown输在哪它天生是为“给人看”设计的2.1 结构不强制、不唯一解析器各搞各的Markdown的语法设计初衷是“易读易写”但它不是一种严格定义的数据格式。同一个视觉效果能用好几种不同语法实现。比如我写一个一级标题# 退换货政策也可以不写井号直接把这行字加粗放大**退换货政策**这两种写法在网页上看起来差不多但对解析程序来说前者能识别为标题节点后者只是普通段落里加粗的文字。如果你的知识库里两种写法都有按标题分块的策略就会漏掉一批用加粗代替标题的文档这部分内容只能被当成普通文本和别的段落混在一起。更麻烦的是Markdown解析器之间存在实现差异。有的解析器会把表格识别成专门节点有的则当成普通段落有的支持“紧凑列表”把连续项合并成一个节点有的会拆成多个段落。我在那个项目里就踩过这个坑本地用Python的markdown库解析出的标题层级和Java服务端用的另一个库解析出的结果对不上导致同一个文档在两条处理链路里分块结果完全不同。这带来的直接后果是分块逻辑变得“启发式”而不是“确定性”。你没法保证每个块都代表一个完整语义单元。比如一个列表本来是一条完整操作流程的前三步但切块时可能只切到“第二步”剩下几步被丢到下一个块里。用户问“第三步怎么办”检索系统只能捞到“第一步到第二步”答案自然残缺。2.2 没有字段概念元数据靠猜Markdown有一类东西叫元信息比如“标题”“作者”“日期”但你没法用Markdown标准语法把这些信息变成机器可读的键值对。虽然有一些扩展语法比如Front Matter--- title: 退换货政策 version: 2.3 ---但Front Matter不是Markdown核心规范的一部分很多工具和解析器默认不解析它。就算解析了它也只是在文档顶部挂了一串额外信息没法让文档内部的段落、表格、列表都带上这些元数据。这在RAG场景里是个致命伤。我当时的FAQ里有很多政策类问题比如某个产品线的退换货规则其实和另一个产品线的规则差异很大。理想的做法是把“产品线”作为过滤条件只检索对应产品线下的片段。但Markdown文档没法在段落级别挂“产品线xxx”这个标签只能把产品线名字写进正文然后靠全文做一个模糊的term匹配“苹果产品线的退换货政策”和“小米产品线的退换货政策”在向量空间里距离很近经常互相干扰。如果你尝试自己写解析逻辑从Markdown里提取“标题正文”的关系其实等于在弱类型的环境里自己造一个schema费时费力且不一定完整。而JSON本身就是强类型的key-value天生就是字段不存在“这个段落属于哪个产品线”这种猜谜问题。2.3 表格、嵌套、代码块在分块时容易碎Markdown还有一批特例一旦混合使用就会让分块器抓瞎。最典型的是表格| 产品 | 保修期 | 备注 | |------|--------|------| | A | 1年 | 无 | | B | 2年 | 需注册 |当分块器按标题把“保修政策”这个二级标题下的内容全部分到一个块里时表格可能整个保留但如果你用“按字符数固定分块”的策略表格会从中间被拦腰截断剩下的半张表完全失去行列对应关系。就算不分断Markdown表格也不是一种结构化的数据表达方式它只是用竖线和横线画出来的文本。嵌套列表也类似。一个“步骤1→步骤2→步骤3”的嵌套列表分块后可能变成两个连在一起的短块且每个短块的开头都有重复的“步骤”字样嵌入后这些短块在向量空间里高度相似检索结果会重复推出同一个系列的内容而不是互补的内容。代码块则更棘手。FAQ里经常出现API调用示例代码块中的注释和代码内容是强关联的但分块时如果代码块前后被切开LLM拿到一个只有注释没有代码的片段等于拿到一张缺了半截的菜谱。这些问题的根源都一样Markdown的“视觉结构”和“语义结构”是脱节的你看到的标题层级、表格对齐、列表缩进本质上都是为了排版好看不是为数据建模。3. JSON为什么更适合它天然是数据的“强约束格式”3.1 强制结构换来的零歧义解析JSON和Markdown最本质的区别在于JSON是一种数据交换格式它的首要目的是让程序之间稳定地交换结构化数据Markdown是一种文档标记语言它的首要目的是让人类方便地阅读。这两个身份决定了它们在RAG链路里的表现天差地别。JSON语法严格对象必须用花括号包裹键必须双引号键值之间用冒号数组元素逗号分隔不能有多余的尾逗号。这一整套强制规则换来的是“确定性”同一个JSON字符串无论用什么语言、什么库解析得到的内存对象结构完全一致。解析失败时JSON解析器会明确告诉你哪一行哪一列出问题是缺了逗号还是缺了右括号。这在实际工程里非常友好。我做了一个parse_and_validate的步骤所有入库的内容必须是合法JSON否则直接拒绝。这意味着知识库里的数据在入口处就被强校验过不存在“半张表”“断掉的列表”这种残缺数据。而Markdown则没有这种“校验失败”的概念任何字符都是合法的Markdown脏数据可以轻松混进知识库。3.2 key-value语义让字段化检索真正落地JSON的另一层优势是它有明确的字段语义。一个FAQ条目可以设计成这样{ question: 如何开具发票, answer: 进入个人中心-订单管理点击申请开票。, category: billing, tags: [发票, 开票, 报销], product_line: digital-goods, version: 2.3 }这段JSON里question是问题、answer是答案、category是分类、product_line是产品线。任何一个字段都可以单独用做检索条件也可以在向量化时组装成更丰富的上下文。比如我可以决定“只把question和answer拼起来嵌入”也可以“把整个对象序列化之后嵌入”还可以“检索时先按product_linedigital-goods过滤再做向量相似度排序”。字段级检索让RAG从“全文模糊匹配”升级成了“结构化过滤语义相似”。这就像你查字典的时候既能按照拼音全文扫描也能先翻到对应偏旁部首那一章再精确定位。后者效率和准确率都有明显优势。更重要的是字段可以被程序二次加工。比如我可以写一段代码把所有question字段统一转成小写后做关键词索引把answer字段做向量嵌入把product_line字段作为倒排标签。这些加工动作对Markdown来说基本没法做因为你根本定位不到字段。3.3 能直接对接业务Schema减少一层“格式翻译”在实际RAG项目里文档往往不是孤立的它们背后有真实业务比如客服问答对应着FAQ表产品手册对应着版本库。这些业务数据在数据库里本身就是结构化表结构导出成JSON是顺理成章的。但如果你把业务数据先转成Markdown再在RAG链路里解析Markdown提取结构等于做了一次“结构化→非结构化→再结构化”的无用功。每一次转换都可能丢失信息还可能引入新的歧义。直接用JSON相当于跳过中间翻译层把数据库里的结构化记录直接交给RAG链路。我当时从Markdown迁移到JSON之后删掉了大量负责“从Markdown里抽标题、抽段落、解析表格”的清理代码。整条解析链路缩短了一大截更关键的是数据质量不再依赖于“标题写得规不规范”这种玄学条件了。4. 实操从Markdown到JSON我用的那套迁移方案4.1 先建模schema怎么设计才不会过度设计迁移的第一步不是写代码而是设计JSON的Schema。这里有个常见的弯路——有人觉得JSON嵌套越深越强大结果把一个FAQ文档变成五六层嵌套的对象分块时反而不知道怎么处理prompt拼接时也被嵌套结构搞得很乱。我的经验是尽量扁平化。能一层的不要两层能用数组解决的问题不要用复杂对象。基于当时的FAQ场景我用的核心Schema长这样{ id: faq-10086, type: faq, source: billing-faq-v2.md, title: 发票问题, question: 如何开发票, answer: 在订单页面点击申请开票..., keywords: [发票, 开票, 报销], category: billing, priority: 1 }设计原则就三条每个字段的语义要单一且明确别搞一个content字段把所有东西都塞进去重复出现的属性要抽出来作为顶层字段比如category、product_line方便过滤纯展示型内容可以丢弃不放进JSON比如“本文档适用范围”“修订记录”这类信息对回答用户问题没什么帮助。如果你的文档本身没有明确的字段结构比如是长篇说明书可以考虑先做一个自动抽取流程把Markdown的“标题段落”映射成“sectioncontent”再人工补充字段。千万不要跳过建模直接去转格式否则你得到的只是一个“字段名随意的JSON”比Markdown好不了太多。4.2 Markdown解析成JSON可用代码与边界约定把既有Markdown批量转JSON我用的方案是两条线并进规则解析处理结构化明显的文档LLM抽取处理非结构化文档。先用规则解析跑一轮给每篇文档生成一个“解析草案”再喂给LLM做字段补齐和清洗效率和准确率平衡得比较好。下面给一个可运行的规则解析核心代码用来把带标题层级的Markdown文档转成JSON数组import json import re from typing import List, Dict, Any def markdown_to_sections(md_text: str) - List[Dict[str, Any]]: 把 Markdown 按标题层级切分为 sections。 这里约定一级/二级标题作为顶层 section 边界。 lines md_text.splitlines() sections: List[Dict[str, Any]] [] current_section None for line in lines: # 匹配 # 开头的标题行 header_match re.match(r^(#{1,4})\s(.*), line) if header_match: level len(header_match.group(1)) title header_match.group(2).strip() section { level: level, title: title, content: [], } # 一级/二级标题开启新的 section三级及以下作为子标题文本记录 if level 2: if current_section is not None: sections.append(current_section) current_section section else: if current_section is not None: current_section[content].append(line) else: if current_section is not None: current_section[content].append(line) if current_section is not None: sections.append(current_section) # 将 content 列表合并为纯文本同时去掉表格的竖线噪音 for sec in sections: raw \n.join(sec[content]) # 去掉 Markdown 表格的格式符号只保留单元格文本 raw re.sub(r\|, , raw) raw re.sub(r\s, , raw).strip() sec[content] raw sec.pop(level) return sections def build_faq_json(md_text: str, source_file: str) - List[Dict[str, Any]]: 把解析后的 sections 进一步包装为 FAQ 条目。 sections markdown_to_sections(md_text) faqs [] for idx, sec in enumerate(sections): faq { id: f{source_file}-{idx}, type: faq, source: source_file, title: sec[title], content: sec[content], } faqs.append(faq) return faqs # 调用示例 with open(billing_faq.md, r, encodingutf-8) as f: raw_text f.read() result build_faq_json(raw_text, billing_faq.md) print(json.dumps(result, ensure_asciiFalse, indent2))这段代码没有覆盖所有Markdown边缘情况但它体现了两个关键约定一是以一级/二级标题作为语义块边界二是统一把content压成单行文本减少无关字符对嵌入的影响。实际项目中我还会在解析后增加一步“长度检查”把content少于20个字的section过滤掉这种短块通常是列表项残留留着只会增加噪音。如果原始文档更复杂比如大量混合表格和列表我会直接用LLM抽取。Prompt大致思路是你是一个文档结构化助手。请把以下 Markdown 文档转换为 JSON 数组。 每个数组元素必须包含 - question文档中的提问或主题句 - answer对应的回答或详细内容 - category按业务逻辑归类 - keywords: 提取3-5个关键词 只输出JSON不要输出Markdown代码块标记。如果无法提取返回空数组。注意这个Prompt末尾特别强调“只输出JSON不要输出Markdown代码块标记”这是因为LLM经常把结果包在json代码块里导致下游解析直接报错。这一点我先卖个关子放在后面“常见问题”里细说。4.3 分块与向量化的两个通用套路JSON化之后分块要比Markdown优雅很多。我常用的两套思路按条分块一条记录就是一个块。适合FAQ这种一问一答的结构。嵌入时把question和answer用分隔符拼起来或者分别嵌入再拼接向量。好处是块本身就是完整语义单元不需要再做切分。按组装上下文分块如果一条记录很长就把记录里的字段按优先级拼成一个紧凑上下文再做固定长度分块。比如先把title和keywords拼接成“摘要头”再把content按300字切成片段但每段都带上摘要头。这样即使content被切碎检索到的每块依然带有全局元信息。我当时用的是第二种思路因为FAQ里有些答案很长一条记录切成多个向量块更利于精确定位。伪代码如下def build_embedding_text(record: dict) - str: parts [] if record.get(title): parts.append(f标题: {record[title]}) if record.get(question): parts.append(f问题: {record[question]}) if record.get(keywords): parts.append(f关键词: {, .join(record[keywords])}) if record.get(answer): parts.append(f回答: {record[answer]}) return \n.join(parts) def chunk_record(record: dict, max_len: int 300): base_text build_embedding_text(record) if len(base_text) max_len: return [base_text] # 如果超长先把 answer 部分按句号切成子句再逐段拼回 answer record.get(answer, ) sentences re.split(r[。\n], answer) chunks [] chunk f标题: {record[title]}\n问题: {record[question]}\n关键词: ...\n for sent in sentences: if len(chunk) len(sent) max_len: chunks.append(chunk) chunk f标题: {record[title]}\n问题: {record[question]}\n chunk sent 。 if chunk: chunks.append(chunk) return chunks这个方案的优点是无论怎么切片每一块都保留了“标题问题”作为上下文锚点检索时就算只命中中间某一块LLM也能通过锚点理解它属于哪个问题。5. 实测数据同样的RAG流程JSON把“答非所问”压下去了5.1 我的评测方法和指标迁移完成后我做了一次对比评测目的是搞清楚JSON化到底带来了多大提升。评测方法不复杂挑选20个用户真实问过的问题分别跑Markdown版知识库和JSON版知识库两个版本使用相同的嵌入模型、相同的分块大小策略、相同的检索TopK。评测指标主要看三个Hit5正确的知识片段是否出现在检索结果前5条里。回答忠实度LLM生成的回答是否严格来自检索片段还是自己编造了内容。无效检索占比检索结果中明显不相关片段所占的比例。结果对比如下指标Markdown版JSON版Hit512/2018/20回答忠实度约60%约85%无效检索占比约35%约12%数字不算什么权威基准但趋势非常明显。尤其是“无效检索占比”从35%降到12%说明很多原本靠关键词硬凑出来的噪音片段被结构化字段过滤掉了。我挑了一个典型例子用户问“预充值订单退款到账需要几天”。Markdown版知识库里有很多文档同时包含“预充值”和“退款”检索结果五花八门有第三方支付协议说明、有账户安全提示、还有一次客服日志记录JSON版因为每条FAQ都挂有categoryrefund和keywords[预充值,退款]检索时先按这些字段过滤再排序答案自然精准得多。5.2 典型bug模型给我吐Markdown我硬当成JSON解析迁移过程中遇到过一个非常典型的报错这里单独拿出来讲。当时我让LLM做“文档字段抽取”把长文本的Markdown转成JSON FAQ。推理返回后我直接在代码里调用json.dumps(...)和pydantic做校验结果报了一堆错其中最经典的是failed to deserialize the json body into the target type: input: missing fie后面被截断的其实是“missing field”。意思是返回的内容确实不是合法JSON或者有字段缺失。我把模型返回的原始内容打印出来一看好家伙它返回的是这个json { question: 如何退款, answer: ... } 问题一目了然模型没有返回纯JSON而是在JSON外包了一层Markdown代码块标记。json.loads遇到开头的三个反引号直接解析失败pydantic连字段都还没开始校验就挂了。解决方案分两步第一步解析前做清理去掉代码块包裹import re import json def extract_json_from_response(text: str) - dict: # 去掉可能的代码块标记 text re.sub(r^(?:json)?, , text.strip(), flagsre.MULTILINE) text re.sub(r$, , text.strip(), flagsre.MULTILINE) return json.loads(text)第二步在Prompt里强制要求“只输出合法JSON不要Markdown标记”同时调整解析逻辑如果一次解析失败就在清理后再试一次。这个坑提醒了我两件事一是RAG链路里LLM输出的是“格式”不是你期望的“数据”所有解析代码必须对格式噪声有容忍度二是给LLM加结构化约束时一定要在Prompt和代码两个层面双重把关缺一不可。6. 迁移踩坑实录不是所有场景都适合JSON6.1 JSON嵌套太深会导致prompt上下文膨胀把JSON当救命稻草也不是万能的。我试过把文档转成三层甚至四层嵌套的JSON比如category下面套subcategory再套items每个item又带一堆属性。结构很规整但问题出在向量化时嵌套太深序列化出来的文本长度暴涨一个原本500字的文档JSON序列化后可能要1200字嵌入费成倍增加而且检索到的片段里充斥着一堆括号和层级字段名真正有用的内容被稀释了。后来我把嵌套层级压到两层并且对每个字段做了“哪些参与嵌入、哪些只做元数据不参与嵌入”的区分。比如id和source字段不参与嵌入只作为检索后的结果展示question和answer参与嵌入。这样既保留了结构化能力又不会把整个对象全塞进向量里。6.2 非结构化内容怎么办日志、访谈、长篇小说JSON如此强调结构化那遇到本身就没有结构的原始文本怎么办比如客服聊天记录、产品访谈纪要、长篇小说片段。这些内容没有天然的“问题-答案”结构硬转成JSON容易变成“字段里套一大段文本”的假结构化实际效果和存纯文本没太大区别。我的处理思路是不强行转换保留Markdown或纯文本存储但在入库时额外挂一层元数据JSON。也就是“主体内容保持原文元数据用JSON”。比如一份客服访谈纪要正文按段落存成文本块但每条记录挂上speaker、topic、date、sentiment这些字段。这样既保留了非结构化文本的原有语义又能享受字段化过滤的优势。6.3 需要Markdown渲染的时候怎么办还有一个常见矛盾JSON适合机器解析但用户能读的文档最终往往需要渲染成Markdown或HTML。我在一个项目里先全部转成JSON结果运营同事问我“怎么在网页上把这些内容展示成之前那样漂亮的版式”。这个问题的解法是存储用JSON渲染时再动态把JSON还原成Markdown。比如后端读JSON记录前端用marked.js等库把Markdown渲染成富文本。JSON里可以保留一个render_template字段记录渲染模板的版本避免将来模板升级导致历史数据错乱。我在实际项目中还在JSON模板里加了一个format_version字段。这是一个很小的习惯但非常有用。因为知识库的数据经常要迭代今天定义的字段三个月后可能就要改结构。有了版本号清理历史数据、做字段迁移时能第一时间定位该跑哪些转换流程而不是面对一堆旧数据发愁。6.4 检索策略也要跟着改最后提醒一句JSON化不只是数据格式变了检索逻辑也得跟着变。如果还用之前“把整块文本向量化然后余弦相似度排序”的老办法JSON的优势根本发挥不出来。我当时的检索策略调整了三处字段级过滤前置根据用户问题先用规则或小模型识别出category和product_line过滤掉不相关记录再跑向量检索。字段权重差异化question字段的相似度得分乘以1.2keywords字段乘以1.5answer字段乘以0.8最后加权求和再排序。多样性与边界处理同一title下的多个块最多保留3条结果避免检索结果全是同一篇文章的相邻片段。这些调整全都依赖JSON的字段结构。Markdown版想做这类优化必须先自己解析维护一份“段落到标题”的映射表既繁琐又不稳定。写完这套迁移最大的体会是RAG项目里很多人把精力花在调整embedding模型、换向量库、调prompt上却忽略了上游的文档格式选型。实际上一个合适的结构化格式能帮你解决掉大量分块、过滤、拼接环节的隐藏问题比在模型层苦追零点几个百分点的效果更实在。最后再分享一个小技巧如果你还在犹豫要不要全面换成JSON可以从一个子域开始试点比如只把FAQ类文档结构化观察两周检索效果。一般两周内你就能感受到差异。等到数据量大了再迁移转换成本和校验成本都会指数级上涨早点动手永远比晚点好。
返回列表