news 2026/10/3 6:01:49

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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的MarkdownHeaderTextSplitter;JSON用自定义的按对象切分
  • 向量模型: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_id=12345" } ] }

这里有几个设计要点:

  • 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="", depth=0, max_depth=4): """将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_key=True) content = Column(String, nullable=False) # chunk文本 embedding = Column(Vector(1024)) # 向量维度根据模型调整 metadata = Column(JSON, nullable=False) # 结构化元数据 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_content=chunk["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-large),Markdown的“语义混杂”问题会被模型的能力抵消一部分

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(instance=data, schema=schema) 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格式的结果天然带schema,Agent可以精确判断,省去了从非结构化文本中抽取信息的环节。

我这边的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这一步,是我实测下来性价比最高的改动之一。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 6:00:52

Paperclip 实战:Node.js 与 React 驱动 AI Agent 的会话锁与通信优化

1. 从“paperclip”这个名字说起:它到底想解决什么问题第一次看到“paperclip”这个项目名,我脑子里蹦出来的画面是那个经典的办公小物件——回形针。它不起眼,但几乎每个人的桌上都有一两个,用来把散落的纸张别在一起。放到技术语…

作者头像 李华
网站建设 2026/10/3 6:00:19

C++配合libxlsxwriter向Excel批量插入图片的实践与踩坑

做报表自动化久了,你会发现一个很尴尬的中间地带:数据、公式、格式都好说,文本一填、样式一刷就完事;一旦需求里出现“把现场照片塞进Excel对应行”,常规套路基本全哑火。我最近在手写一个设备点检报告生成工具&#x…

作者头像 李华
网站建设 2026/10/3 6:00:17

基于Dify和RAG构建智能复盘助手,自动化项目复盘实践

项目概述与核心思路1.1 “hindsight”到底是个什么东西先说结论:hindsight 不是一个模型、不是一套算法,而是一个基于 Dify 平台搭建的“智能复盘助手”原型项目。它的名字取自英文“事后聪明”——我们常说“回头看,一切都清晰”&#xff0c…

作者头像 李华
网站建设 2026/10/3 6:00:17

从零开始AI工程化:数据、训练、部署、监控全链路实战

2022年我给自己定了一个目标:搞一个叫ai-engineering-from-scratch的长期项目,从零开始把 AI 应用真正做出来,而不是一直停留在"看论文、刷榜单、跑通别人代码"的阶段。两年前我还是一个只会调库的脚本小子,看着 Huggin…

作者头像 李华
网站建设 2026/10/3 5:59:46

HardFault调试实战:从异常机制到栈回溯,彻底定位Cortex-M崩溃根因

做嵌入式开发这些年,如果说有什么问题让我又爱又恨,HardFault绝对排第一。爱是因为它总能告诉我程序出事了,恨是因为它经常只丢下一句"出事了"就什么线索都不给。尤其项目到了联调阶段,设备跑着跑着突然一头扎进HardFau…

作者头像 李华
网站建设 2026/10/3 5:59:04

从零构建 AI 工程:手写 Transformer 与训练调参实战

干这行这几年,经常被人问到一个问题:想入门 AI 工程,是不是必须先把数学啃穿、把论文读透?我的答案一直都很明确:不用,但你必须亲手把一个东西从零造出来。不是说非得去复现一篇顶会论文,而是说…

作者头像 李华