ARTICLE DETAIL

资讯详情

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

RAG检索效果差?从Markdown到JSON,格式选型让准确率大幅提升

RAG检索效果差?从Markdown到JSON,格式选型让准确率大幅提升 1. 从一次检索翻车说起Markdown在RAG里的隐性坑先讲个我实际项目里遇到的事。今年上半年给一家企业做内部知识库问答系统技术栈是当时主流的FastAPI LangChain RAG pgvector那一套。语料是几十份产品文档原始格式是Markdown处理流程也是常规操作读取文件、按标题切分、向量化、存pgvector、检索问答。结果上线一测问题来了——问“这个接口的鉴权方式是什么”系统返回的片段不是接口文档里的鉴权章节而是把整篇文章里所有出现“token”、“key”字样的段落全都召回来了有的段落甚至来自“常见问题FAQ”这种八竿子打不着的章节。一开始我以为是embedding模型选得不够好或者是chunk切分策略有问题。调了chunk_size换了BGE和OpenAI的embedding效果有改善但始终不理想。后来做了一次对照实验同样的知识库我把一部分文档手工转成JSON格式再喂进去结果检索准确率直接上了一个台阶。这个现象让我开始重新审视一个在此之前很少认真思考的问题——在RAG场景下信息的载体格式到底意味着什么先说结论Markdown是一种面向人类阅读的文本格式它的“语义”是软性的、依赖上下文的而JSON是一种面向程序处理的数据格式它的“语义”是硬性的、自描述的。这两者之间的差异在普通文档展示场景里无伤大雅但一旦进入RAG的“解析→切分→向量化→检索”流水线就会被成倍放大。这篇文章我想用实际踩坑的经验完整梳理一下为什么在RAG项目里JSON往往比Markdown更可靠、更好用以及JSON格式在RAG技术栈里到底怎么落地。不是为了鼓吹“JSON万能”而是让大家在做技术选型时能把这个维度纳入考量少走我走过的弯路。2. Markdown在RAG流水线里的四个“原罪”要理解JSON为何胜出先得搞清楚Markdown在RAG处理链路里到底哪里出了问题。这四个问题不是偶然的而是Markdown这种格式的固有属性决定的。2.1 标题层级是“显示语义”而非“逻辑语义”Markdown里的##、###、####本质上是排版标记告诉渲染器“这段文字应该显示成几级标题”。它没有强制性的结构约束——你可以把###直接挂在一级标题下也可以在####下面再嵌套一个###Markdown解析器不会报错渲染出来人类也能看。但对于RAG来说问题就大了。LangChain的MarkdownHeaderTextSplitter在做分块时是根据标题层级来推断内容归属的。如果原文的层级关系混乱分块就会随之错乱。我处理的那批文档里有人喜欢用####做章节标题有人用加粗当标题还有人直接用“一、二、三”这种纯文本编号。Markdown解析器自己没法判断哪个是对的它会按顺序逐个匹配结果就是检索的时候经常跨章节捞到不该出现的内容。JSON不存在这个问题。JSON的层级是硬性的一个对象嵌套在另一个对象里这种嵌套关系就是数据的逻辑归属关系不存在二义性。数组就是数组对象就是对象程序拿到之后不需要“推断”结构直接按路径取值就行。2.2 检索单元与语义单元错位RAG的核心环节是分块chunking。理想状态下每个chunk应该是一个完整的语义单元——比如一个章节讲完了一个接口的全部用法那这个章节就该整体作为一个chunk。但Markdown的分块规则是“按标题切”标题之间的内容会被硬切成一整块不管这块内容里有没有讲多个不同的主题。最典型的场景一个###标题下有两张表格、三段说明文字、一个代码示例这几部分内容如果混在一个chunk里向量化之后彼此稀释检索时匹配度会被拉低。反之如果一块内容跨了多个主题检索时匹配到的是“包含关键词”而不是“语义相关”。JSON的嵌套结构天然提供了语义边界。一个对象里的所有字段天然属于同一个主题该主题的上层对象又定义了更宏观的归属关系。按对象边界分块chunk的语义纯度比按Markdown标题分明显高得多。2.3 上下文依赖性强单独切片难自洽RAG有个经常被忽视的痛点检索出来的chunk是要独立送到大模型里做生成的模型只能看到这一个chunk看不到全文。这就要求chunk本身必须“自洽”——即使脱离全文也能被理解。Markdown在这方面非常吃亏。比如一个文档里有这样一段Markdown| 参数名 | 类型 | 必填 | | --- | --- | --- | | user_id | string | 是 | | fields | array | 否 |这个表格如果被单独切成chunk模型根本不知道这是在描述哪个接口的参数因为上下文的“接口名”和“接口描述”在表格上方的标题里。你得把整个标题链一起切进去才能让chunk自洽。但标题链一长chunk就冗余向量化的信息密度又被稀释了。JSON则不然{ endpoint: /api/get_user_info, parameters: [ {name: user_id, type: string, required: true, description: 用户ID}, {name: fields, type: array, required: false, description: 需要返回的字段列表} ] }每个参数对象里自带字段名和描述一个chunk切出来大模型一看就知道这是“获取用户信息接口的user_id参数”无需外部上下文。这种自洽性在RAG场景里是硬通货。2.4 嵌入向量的“语义混杂”稀释问题这个点属于我实测中慢慢总结出来的。embedding模型在向量化文本时会把整段文本的所有语义信息压缩到一个高维向量里。Markdown文档中常见的混合内容——穿杂着正文、表格、代码、链接、图片引用的段落——会让embedding模型不知道该把注意力放在哪里。举个例子一段Markdown正文里同时有“API密钥”的说明和一个指向“申请密钥页面”的链接向量化之后这两个语义点在向量空间里各占一部分维度结果就是检索“API密钥怎么申请”时相关度得分反而不如一个只讲申请流程的纯文本段落。JSON格式因为字段划分明确每个字段的值都是单一语义embedding模型可以更“专注”地编码。实测下来同样的文本内容JSON字段单独编码与段落整体编码前者的语义纯度明显更高。3. 同一份文档、两种格式检索效果对照实验光讲理论容易空我直接贴一次实际对照实验的数据。这个实验是在上述项目中做的语料选了产品文档里比较典型的“用户管理模块”章节内容包括接口列表、参数说明、错误码表、调用示例四类信息。3.1 实验配置文档A原始Markdown标题层级为# 用户管理→## get_user_info→### 参数说明文档B同样的内容手工转成JSON结构为{module: 用户管理, endpoints: [{name: get_user_info, parameters: [], errors: [], example: }]}切分方式Markdown用LangChain的MarkdownHeaderTextSplitterJSON用自定义的按对象切分向量模型BGE-large-zh-v1.5测试问题10个覆盖参数查询、错误码含义、调用示例等类型3.2 结果对比指标Markdown切分JSON切分检索命中正确章节的比例60%90%平均相似度得分0.6820.754首轮回答完全正确的比例50%80%平均每轮retrieval的chunk数4.22.8数据很直观JSON格式在命中率、相似度、回答正确率三项指标上都有明显优势。首轮回答正确率从50%提到80%这个提升幅度在RAG项目里已经属于“质变”了。3.3 结果分析差异究竟来自哪里有一说一这个实验并不算严格的学术对照因为在切分策略上Markdown和JSON本来就不可能完全对等——这是两种格式的固有差异没法消除。但这也正是我想表达的核心观点在RAG里选择了一种数据格式就等于选择了一套默认的语义切分逻辑。Markdown那道文档必须得把标题链# 用户管理 → ## get_user_info → ### 参数说明写进chunk里的前缀模型才能理解参数内容是哪个接口的。这会导致chunk的有效信息密度下降一个chunk里可能一半是标题前缀、一半是参数表格。JSON则不同{endpoint: get_user_info, parameters: [...]}这个结构把接口名和参数绑定在一起不存在“前缀冗余”模型一眼就能对应上。另外值得注意的一点是JSON格式向量化的文本更加规整没有Markdown语法符号#、|、**等的干扰。这些符号在embedding模型眼里是文本的一部分会占用注意力资源甚至可能被编码成无意义的噪音维度。去掉它们之后语义向量更“纯”了。4. JSON在RAG技术栈中的落地路径既然JSON这么好那具体怎么在项目里用起来这一步比想象中复杂。不能简单地把Markdown文件后缀改成.json就完事而是要重新设计文档结构适配RAG流水线的各个环节。4.1 文档解析从非结构化到结构化第一步是把原始文档Word、PDF、Markdown等解析成JSON结构。这一步的关键是为你的知识库设计一套合理的JSON Schema。Schema设计得好不好直接决定后续RAG的效果上限。以接口文档举例一个合理的Schema可能是这样{ doc_type: api_reference, module: 用户管理, version: v2.1, endpoints: [ { name: get_user_info, method: GET, path: /api/get_user_info, description: 查询用户详细信息, parameters: [ { name: user_id, type: string, required: true, description: 用户唯一ID } ], response: { success: {code: 0, message: 成功}, error_codes: [ {code: 1001, message: 用户不存在}, {code: 1002, message: 无访问权限} ] }, example: GET /api/get_user_info?user_id12345 } ] }这里有几个设计要点doc_type字段标记文档类型后续可以按类型做元数据过滤比如只检索api_reference类型的内容嵌套层级endpoints数组里套parameters和error_codes天然形成语义分组description字段每个对象都要带上这是给retrieval用的“自洽上下文”注意控制深度JSON嵌套层级过深超过4层会导致切分逻辑复杂化建议控制在3-4层以内4.2 分块策略按对象为单位切分JSON的分块策略和Markdown完全不同。Markdown是按标题切JSON是按对象边界切。我用的方式是写一个递归函数遍历JSON树碰到叶子对象就作为一个chunk单元import json def json_to_chunks(data, prefix, depth0, max_depth4): 将JSON对象递归转换为RAG chunk列表 chunks [] # 达到最大深度或遇到叶子节点打包为一个chunk if depth max_depth or not isinstance(data, (dict, list)): text f{prefix}: {data} if prefix else str(data) chunks.append({text: text, metadata: {depth: depth}}) return chunks if isinstance(data, dict): # 将description字段作为chunk的前缀上下文 desc data.get(description, ) for key, value in data.items(): if key description: continue new_prefix f{prefix} {key} if desc else f{prefix} {key} # 如果当前是描述类字段直接作为chunk if isinstance(value, str): chunk_text f{new_prefix}: {value} chunks.append({text: chunk_text, metadata: {depth: depth 1}}) else: chunks.extend(json_to_chunks(value, new_prefix, depth 1, max_depth)) elif isinstance(data, list): for item in data: item_desc item.get(description, ) if isinstance(item, dict) else chunks.extend(json_to_chunks(item, f{prefix}[{item_desc}], depth 1, max_depth)) return chunks这个函数的核心思路是每个JSON对象生成一个chunk字段路径自动成为chunk的上下文前缀。比如用户管理 get_user_info parameters user_id: 用户唯一ID整个字符串自带完备的上下文模型无需外部信息就能理解。4.3 元数据注入让检索过滤更精准JSON结构化的一个隐形红利是——你可以顺手把chunk的元数据也结构化。在向量化存储到pgvector的时候每个chunk可以附带JSON路径、文档类型、所属模块等信息这样在做检索时就能用元数据过滤来缩小范围。我的存储代码大致长这样from pgvector.sqlalchemy import Vector from sqlalchemy import Column, String, JSON class DocumentChunk(Base): __tablename__ document_chunks id Column(String, primary_keyTrue) content Column(String, nullableFalse) # chunk文本 embedding Column(Vector(1024)) # 向量维度根据模型调整 metadata Column(JSON, nullableFalse) # 结构化元数据 doc_type Column(String) # 文档类型 module Column(String) # 所属模块 json_path Column(String) # 原始JSON路径检索的时候可以先把doc_type和module作为过滤条件再在过滤后的子集里做向量相似度匹配。实测下来召回率更精准而且过滤后的子集向量数量少检索耗时也降了大约30%。4.4 与LangChain等框架的整合如果你用的还是LangChain整合起来也不难。LangChain的Document对象本身的metadata字段就是dict类型可以直接塞JSON元数据。自定义切分函数生成的chunks转成LangChain的Document对象即可from langchain_core.documents import Document documents [] for chunk in json_to_chunks(json_data): doc Document( page_contentchunk[text], metadata{ doc_type: api_reference, module: 用户管理, json_path: chunk[metadata][depth], } ) documents.append(doc)剩下的事就交给LangChain的标准流程了向量化、入库、检索。5. 结构化不是万能的JSON方案的边界与代价讲完了JSON的诸多好处得泼点冷水。JSON格式在RAG里并非没有缺点有些场景下甚至不如Markdown。5.1 JSON Overload结构过度设计反而降低检索效果我见过一些团队在“结构化”的浪潮里走火入魔——恨不得把所有知识都套进JSON里每个字段都加description嵌套七八层深。这样做的问题在于字段层级过深导致chunk切分后碎片化严重一个简单问题需要拼凑多个碎片才能回答为每个字段写冗长的description会让chunk总字符数暴增向量化时特征互相干扰维护成本上升文档更新时改一处嵌套要连带改好几层的结构JSON是给程序看的过度设计是画蛇添足。知识的价值在于检索时能被命中而不是结构好看。5.2 哪些场景Markdown依然更适合纯叙事性内容比如操作流程、步骤指南、方案说明等本质上是线性叙事Markdown的段落结构已经足够JSON化反而显得生硬内容以长文为主的知识库如法律条文、产品新闻稿等通常是大段连续文本JSON的字段嵌套没有用武之地嵌入模型对长文本友好时如果你用的embedding模型对长文本的语义编码能力很强比如OpenAI的text-embedding-3-largeMarkdown的“语义混杂”问题会被模型的能力抵消一部分5.3 推荐混合格式方案基于这几次实战经验我的建议是不要非此即彼按文档类型做混合路由。RAG知识库里通常有多种类型的文档。接口文档、配置说明、数据字典这类结构化内容强烈建议用JSON操作指南、FAQ、公告类内容Markdown就够用了。在RAG的检索入口加一个文档类型分类器不同类型走不同的处理管道结构化文档 → JSON解析 → 按对象切分非结构化文档 → Markdown切分 → 按标题切分检索时可以根据query意图自动路由到合适的知识子集或者干脆同时检索两边再合并排序。这个混合方案的好处在于让格式适配内容本质而不是让内容去迁就格式。实测下来混合方案相比纯Markdown方案检索准确率提升了大约25%。6. 落地时最容易忽略的几个工程细节除了格式选型本身几个工程细节也值得单独拎出来说一说都是我踩过的坑。6.1 JSON的解析兼容性LangChain或其他框架自带的JSON解析器有时会对非法JSON报错——最常见的就是控制字符、尾逗号、注释。我的建议是入库前做一次严格的JSON Schema校验不合规的数据直接拦截不进向量库。否则会在运行时碰到那种failed to deserialize the JSON body into the target type的报错排查起来非常痛苦。校验工具我用的是jsonschema库import jsonschema from jsonschema import validate schema { type: object, properties: { doc_type: {type: string}, module: {type: string}, endpoints: {type: array} }, required: [doc_type, module, endpoints] } def validate_json_doc(data): try: validate(instancedata, schemaschema) return True, None except jsonschema.exceptions.ValidationError as e: return False, str(e)6.2 分块大小的重新权衡JSON切分的分块大小需要考虑两种字段类型的差异短字符串字段如参数名、错误消息通常只有几个到几十个字符单独作为chunk太短向量化信息量不足长文本字段如接口描述、调用示例可能几百上千字符又需要适当的截断。我建议把短字段做打包——相邻的几个短字段合并成一个chunk比如把“用户管理 get_user_info parameters”下的所有参数对象打包成一个chunk这样既保留结构化语义又不至于让chunk过碎。def pack_parameters(params_list): 将参数列表打包成一个语义单元 lines [] for p in params_list: lines.append(f- {p[name]} ({p[type]}, {必填 if p[required] else 选填}): {p[description]}) return \n.join(lines)6.3 与Agent框架配合时的格式约束如果你的RAG系统上面接的是Agent框架比如LangGraph那么JSON化的意义就更大了。因为Agent在决策时需要对检索结果做结构化理解——比如判断“这个错误码是否存在于文档中”这种动作。JSON格式的结果天然带schemaAgent可以精确判断省去了从非结构化文本中抽取信息的环节。我这边的Agentic RAG系统里检索结果直接以JSON结构返回给LLM让LLM基于JSON里的字段路径做推理。效果比给一段Markdown让LLM自己找答案稳定得多尤其在处理多轮对话中的指代消解时JSON的字段名可以起到“记忆锚点”的作用。6.4 版本管理与增量更新知识库是不断更新的。JSON格式带来的一个好处是支持细粒度增量更新——你只需要更新变化的那个对象不需要把整个文档重新向量化。Markdown则通常需要整文件重切。这在知识库规模大了之后维护成本差异非常明显。我实现了一个简单的做法每个JSON对象对应一个独立的doc_id更新时只删除旧的doc_id对应的chunk再向量化新对象入库。用pgvector的DELETE WHERE id ?就能搞定。7. 关于格式选型的一点个人经验总结回头看这个项目最值得复盘的不是具体技术方案而是一个思维习惯在做RAG时我们往往把精力花在模型选择、参数调优上却很少停下来重新审视数据的初始形态。但数据格式其实是RAG流水线的最上游它对最终效果的影响可能比任何单点调参都大。JSON和Markdown不是谁取代谁的关系它们面向的场景天然不同。Markdown为人类阅读而生JSON为程序消费而生。而RAG系统本质上是一个“程序先读文档、再组织语言给人看”的过程。这就决定了在“程序读文档”这个环节JSON天然更契合。如果你正在为RAG效果不稳定而头疼不妨先检查一下你的语料格式——是不是还在用Markdown强切结构化的接口文档有没有可能转成JSON试试这个改动本身成本不高但换来的效果提升可能远超你花一周时间调embedding模型参数的效果。做知识库没有银弹但从Markdown到JSON这一步是我实测下来性价比最高的改动之一。
返回列表