大概是大半年前,我在自己的小服务上做本地知识库问答,跑着经典的 RAG 流程:文档拆块、向量化、TopK 检索、拼 Prompt 丢给大模型。一开始觉得挺美好的,可等我把真实的 PDF、表格、甚至一堆截图扔进去之后,问题就全冒出来了。传统的检索增强生成(RAG)思路在中小规模场景下,尤其是本地部署时,根本不像教程里写的那么丝滑。后来我换了个思路,把知识库的“结构”也当作上下文喂给模型,这就是我理解中的 SAG(结构化增强生成),而真正让我把想法落地成一套能用工具链的,是 OpenViking 这个开源项目。
这篇文章不是复读概念,而是记录我自己的完整实践:从 RAG 的痛点出发,讲清楚 SAG 跟 RAG 到底差在哪,再带着你一步步用 OpenViking 搭一个能跑在本地、能处理图片和表格、不会动不动就“答非所问”的知识库问答系统。如果你也在为 RAG 的检索质量、文本拆分、多模态文件处理这些问题头疼,这篇应该能帮你少踩几个坑。
1. RAG 的痛点:为什么经典方案一到本地就翻车
1.1 检索“有点准”但又“不太准”
RAG 的核心逻辑说穿了就一句话:先查资料,再写答案。于是所有问题都集中在这个“查”字上。经典的向量检索,本质是把文本块映射成一个高维向量,然后找相似度最高的几个块。听着没问题,可实际跑起来你会发现,相似不等于相关,相关也不等于有用。
我举个例子你就明白了。假设你的知识库里有一篇公司团建报销制度:开头是“报销标准为每人 300 元”,中间隔了八百字讲流程,结尾又提到“超支部分自理”。如果按固定窗口切块,三个信息点大概率被拆进不同的块里。用户问“团建人均能报多少”,检索系统可能只命中了“超支部分自理”这个块,因为它在字面上跟“人均”“多少”更贴近。于是模型一本正经地告诉你:超支要自理,但没说能报多少。
这就是经典 RAG 在高密度、强关联文本上的结构性硬伤:把一段连续语义切成了碎片,碎片里的人名、数字、关联关系就跟着断了。你问的问题越具体,越依赖上下文关联,翻车概率越高。
1.2 文本拆分的两难困境
几乎所有 RAG 教程都会让你用固定 chunk_size 做切分,什么 256、512、1024,配个 overlap 就算完事。本地跑起来就会发现这事有多坑。文本块太小,一个完整的知识点被拦腰截断;文本块太大,向量化之后特征被平均掉,检索精度直线下降,还会白白堆高 token 消耗。更麻烦的是,PDF 里那些表格、页眉页脚、代码片段,用纯文本切分简直就是灾难。
我印象很深的是切一个带表格的 PDF,固定窗口正好把一个三列的预算表从中切开,上半截是“项目名称”,下半截是“预算金额”,向量化后乱七八糟。模型检索到这一块,给出的答案自然也是东拼西凑。所以我一直觉得,真正决定 RAG 上限的,不是用什么 embedding 模型,而是怎么拆。
1.3 图片和表格:经典方案的盲区
很多热词里问“RAG 知识库能存储图片嘛”,说实话,早期的 RAG 方案对这个问题基本是摇头的。大多数本地知识库脚本,只会用一个 PDF 解析库把文字抠出来,喂给文本切分器。图片直接跳过,表格被当成一段连续字符,运气好能保住换行符号,运气不好整段粘成一坨。
但真实场景里,图片里的流程图、票据、截图,表格里的对照参数,恰恰是用户最想检索的内容。为了解决这个问题,我开始思考一个新的架构:与其把知识库当成一堆扁平碎片,不如把它当成一个有结构的语义网络。这就是我一直关注的 SAG 思路的起点。
2. SAG 的核心思路:知识库不是碎片堆,是语义网
2.1 我理解的 SAG 到底是什么
SAG,即 Structured Augmented Generation,结构化增强生成。它跟经典 RAG 最大的区别在于:RAG 把文档切碎后塞进向量库,再用“相似度”去捞碎片;SAG 则要求在切块之外,额外维护一块“结构信息”,用节点和关系来描述知识之间的关联。检索的时候,模型不光看命中的文本块,还会把这几个文本块在整个语义网里的位置、邻居节点、关系链一并喂给大模型。
这么说可能有点抽象。生活化地理解一下:RAG 像是你去图书馆,按关键词在索引卡片里翻出几页书,然后直接抄书上的句子。SAG 则像是你先根据索引卡片翻到几页书,再顺着这几页所在的章节目录、前后页码、交叉引用,把整个知识脉络带出来。书上的原句是答案的素材,章节结构则告诉模型素材的上下文是什么。
这样一来,模型拿到手的不是孤零零的碎片,而是“这几个信息点是从哪里来、跟什么有关系、在全局里处于什么位置”。我再把流程里面的所有文本块和结构化单元链接起来,模型回答问题时,就相当于同时参考了“拷下来的段落”和“知识大纲”,自然能少犯张冠李戴的毛病。
2.2 从平面检索到立体关联
经典 RAG 的检索是纯平面的:query 向量跟所有 chunk 向量算距离,取 TopK。所有 chunk 之间没有层级关系,没有父子依赖,没有“不在此处但有关联”的概念。SAG 要做的,就是在向量化之前先做一次文档结构化解析,自动识别标题层级、表格结构、段落语义边界,然后把这些关系写成一个可查询的关系图。
举个我在实操中遇到的场景。我手头有一份设备巡检手册,第 2 章写“常见故障代码”,第 5 章写“维修备件清单”,第 8 章写“售后联系方式”。用户问“F-17 报错该联系谁”,传统 RAG 会去第 2 章找“F-17”这个代码对应的解释,但未必能找到第 8 章的联系电话。SAG 的结构化网络里,故障代码节点会关联到维护流程节点,维护流程节点又关联到售后联系方式节点,检索引擎可以顺着关系链把距离稍远、语义却紧密相关的节点找出来。这才是“检索增强”该有的样子。
2.3 面向本地场景的落地开关
可能有人会问:SAG 是不是必须配一个超大的知识图谱引擎才能跑?还真不是。我当时定下的落地原则很简单:能用文档内部结构的地方,就不必追求跨文档复杂图谱;能通过规则解析的标题和段落,就不必上模型抽取。OpenViking 在这点上帮了大忙,它把“结构”拆成了三个粒度:文档结构、块内结构、块间链接。
- 文档结构,就是自动识别 H1/H2/H3 标题,构建一棵目录树。
- 块内结构,是说每个块里如果包含表格或键值对,就保留行列关系。
- 块间链接,是尽可能识别“见上文”“见第几节”这类引用,建立跳转关系。
这三层结构都不需要大模型参与,规则加启发式就能搞定,对本地部署非常友好。用 OpenViking 处理完一份文档,得到的不是一袋杂乱的碎片,而是一张彼此关联的知识网。这就是 SAG 能落地的关键。
3. OpenViking 拆解:一个把 SAG 理念封装好的开源项目
3.1 OpenViking 提供了什么
OpenViking 是我在调研“RAG 框架”时看到的一个开源项目。严格说,它不是一个像 LangChain 那样包罗万象的全栈框架,而是专注于解决“文档进、结构出”这个环节,跑了从解析、拆块、建索引,到查询改写和结构化检索的完整链路。对我来说,它的价值在于把 SAG 里的“结构生成”从手工设计变成了自动化流水线。
它的处理链路大致是:文件解析 → 结构识别 → 语义切块 → 块级索引 → 关系构建 → 混合检索。这里面,结构识别和关系构建是 OpenViking 的强项,也是它跟普通 RAG 工具拉开差距的地方。很多 RAG 框架只管切块和向量化,OpenViking 则花力气去识别每个块的“身份信息”:它属于哪个章节、它前面是谁、后面是谁、它是段落还是表格。把这些结构化信息存进索引之后,检索阶段才能实现前面说的顺藤摸瓜。
3.2 文本拆解在 OpenViking 里的真实玩法
OpenViking 没有用“死固定 chunk_size + overlap”这种一刀切方案,而是提供了一套更贴近文档本身的拆解机制:先把文档解析成“语义单元”,再把小语义单元合并成块。语义单元可以是标题、段落、列表项、表格的一行,也可以是代码块的一部分。合并的时候,不是按字数凑,而是按“语义完整性”来判断。
我再举一个实操里的例子。一份说明书里有这么一段:
注意:启动设备前务必先接通地线,否则可能导致设备损坏。若设备已通电且未接地线,请立即断电,并联系售后。
这段里有“注意”“否则”“请立即”这几重逻辑。传统的按字数切块,很可能在“否则”前面拦腰截断,把一条完整的安全警告拆成两半。OpenViking 的语义单元会把整段判成一个单元,因为它的边界识别里有“语气连贯性”的判断。如果块太长,则按语义转折点再拆,而不是按字符硬切。我实测下来,它对中文长文档的切块质量,明显好于我手写的固定窗口脚本。
3.3 混合检索:向量之外的定海神针
OpenViking 的检索模块也不只依赖向量。它做了一套混合检索:向量相似度负责语义召回,关键词索引负责精确召回,结构路径负责关联召回。三者汇总后过一个轻量级的排序层,把命中结果按“和查询相关 + 上下文完整度高 + 与已命中块有关联”来重新打分。
这招很实用。比如用户问“OpenViking 支持哪些格式”,向量检索能召回“支持 PDF、Word、Markdown”这一段,关键词检索能额外命中“文件格式”相关条目,结构路径则能把“支持的格式”这个标题下的所有子项带出来。三条路互为补充,原本单路检索容易漏掉的信息,在混合召回下基本都能被兜住。
3.4 和老牌框架的协作方式
很多人项目里已经用了 LangChain4j、LlamaIndex 这类框架,担心引入 OpenViking 是不是要把整套链路推翻重写。我的经验是,不需要。OpenViking 可以作为一个独立服务跑在本地,暴露接口返回结构化文本块和关系元数据,你原来的框架依然负责编排、Prompt 拼接和生成。我后端是 Java,直接联在 langchain4j easy rag 的流程里,把原来 DocumentSplitter 那一段替换成 OpenViking 的输出就行,其余逻辑基本不用动。
如果你是 Python 技术栈,更省事,直接把 OpenViking 的索引导出成 JSON 或 SQLite,再用现成的 LangChain 加载器读入即可。这种插件式的协作方式,能让 SAG 思路平滑地嵌入到现有技术栈里,不需要从零开始造轮子。
4. 基于 OpenViking 本地搭建一套 SAG 知识库
4.1 硬件和软件准备
如果你只是想在本机试跑,要求不高。我的老笔记本,8GB 内存、四核 CPU,也能跑得动。它需要一个向量索引组件,我建议先装上 SQLite-VSS 或轻量级向量库,不必一上来就上重型的服务。LLM 我强烈推荐用 Ollama 拉一个 Qwen2.5 7B 或者 Llama 3.1 8B 的小模型,配合 bge-m3 embedding 模型,本地完全够用。这一步算是零基础可复制的本地 RAG 知识库路线里最经济的组合。
安装 OpenViking 也不复杂,直接拉它的源码或者下载发行版二进制。它依赖的主要是 Python 环境和几个解析库,PDF 解析用 PyMuPDF,Word 解析用 python-docx,Markdown 解析是自带的。我建议单独建一个虚拟环境,避免跟系统里的包起冲突。装完之后先跑一次openviking --help,确认 CLI 能正常唤起再往下走。
4.2 第一步:导入文档并观察结构化输出
导入文档的命令大概长这样:
openviking ingest --input /path/to/your/docs --output /path/to/index这里的ingest会把整个目录下的 PDF、Word、Markdown、TXT 全部读一遍,生成一个索引目录。我建议导入之前先清理一下源文件,把页眉页脚、封面页、空白页尽量去掉,否则结构识别会把页脚误判成正文的一部分。
导入完成后,先用一个查看命令检查中间产物:
openviking inspect --index /path/to/index --doc example.pdf你对着一份文档看输出的结构树,能直观发现它有没有把标题层级建对,有没有把表格识别成表格,有没有把重复的页眉当成多个节点。这一步非常重要,因为后续检索质量的上限,完全取决于这一步的结构还原度。我第一次直接导入带复杂表格的 PDF 时,结构树里出现了一些错乱节点,后来才发现是 PDF 本身用图片做的表格,纯文本层里根本没有字,这种情况需要先做 OCR 预处理。
4.3 第二步:配置拆分参数,匹配你的文档类型
OpenViking 的配置是以 YAML 文件形式提供的,核心参数大概有这几个:
splitter: block_size: 800 min_block_size: 200 respect_structure: true include_metadata: true image_placeholder: true retrieval: top_k: 8 score_threshold: 0.45 hybrid_search: trueblock_size不是固定切块的尺寸,而是“语义单元最大化合并的上限”,超过尝试另起一块。min_block_size是低于这个长度就尽量跟相邻块合并,防止出现孤立碎片。respect_structure表示切块不能破坏标题和表格结构。image_placeholder是关键,开启后图片会被抽取出来,生成一个说明占位符,留到下一步处理。
我在实测中发现,中文文本按字符数计算时,block_size设在 600 到 1000 之间效果都不错,具体取决于你的文档是问答式的还是叙述式的。问答式的可以调低,让每个问答对独立成块;叙述式的调高一点,保持段落完整。不过别贪大,超过 1500 之后向量精度明显下降。
4.4 第三步:处理图片和表格,回答“能不能存图片”
这也是很多人关心的实操点。纯 RAG 不能存图片,根本原因是 embedding 模型不认识像素。OpenViking 的解法是:每个图片在导入阶段会被单独抽出来,先用一个多模态小模型生成一段描述文字,例如“图中是网络拓扑,核心交换机连接三台接入交换机,编号分别为 A01、A03、A05”,再把这段描述作为一个文本块嵌入索引。查询时,用户问“A03 接在哪台设备上”,命中的是这段图片描述文本,模型基于它来回答。
这样相当于给图片“配了文字”,既保留了图片里的信息,又绕开了本地多模态模型的性能压力。我试过用 Qwen2-VL 7B 做图片描述,效果尚可,但速度偏慢。后来换成更小的 Florence-2 做纯描述增强,速度快很多,信息完整性也不错。表格的处理则不同,OpenViking 会把表格头、列名、行数据分别抽出来,保留行列对应关系,再把每一行做成一个结构化文本块。查询“预算表中市场部的金额”,能精确命中对应行,而不是把整张表传给模型。
4.5 第四步:集成 Ollama,启动本地问答
这里以 Python 侧为例,你也可以用 langchain4j 完整复刻这套流程。流程大概是:
from openviking import OpenVikingIndex from openviking.retriever import HybridRetriever index = OpenVikingIndex.load("/path/to/index") retriever = HybridRetriever(index, top_k=8, score_threshold=0.45) question = "设备报错 F-17 应该怎么处理" hits = retriever.retrieve(question) context = "\\n\\n".join([h.text + "\\n来源: " + h.metadata["source"] for h in hits]) prompt = f"基于以下资料回答问题:\\n\\n{context}\\n\\n问题:{question}"然后把这个prompt发给 Ollama。要注意的是,OpenViking 返回的metadata里会有chapter、path、relation这几个字段,拼进 prompt 对模型很有帮助。你可以把结构路径直接拼成“来源:第 2 章 常见故障 / 2.3 网络报错”,模型看到上下文归属,会更加规范作答。
启动 Ollama 服务的部分不展开了,网上教程很多,核心就一句:模型选对、上下文窗口开够、别用太小的量化等级。我本地用的是qwen2.5:7b-instruct-q4_K_M,跑知识库问答很稳。
4.5 第五步:跑一个最小可用脚本
上面这些环节,最终组合成一个最小脚本,跑完本地知识库问答流程。我用它做了几组测试,效果比传统 RAG 好很多。同样是问“团建人均报销多少钱”,传统 RAG 只会返回零散句子,SAG 则能顺着“报销制度 → 团建活动 → 标准金额”的结构路径,把完整答案和出处一并找到。哪怕其中一个关键块没有直接命中,结构路径也能把相邻内容捞出来补上,这在实操里非常实用。
5. 常见问题排查与避坑经验
5.1 PDF 解析出来是乱码和空白怎么办
很多 PDF 看着有字,其实文本层是空的,文字都是矢量曲线或者直接嵌了图片。我第一次导入一份产品彩页时,结构化输出里全是空白,完全没法用。解决思路是把 PDF 丢给 OCR 管线预处理一下,生成带文本层的副本,再交给 OpenViking 解析。这里我踩过一个坑:OCR 会识别出页眉页脚、页码这些噪音,导入前最好用--blacklist参数把这些过滤掉。
5.2 检索命中率太低怎么办
先别急着调 embedding 模型,先怀疑拆分是不是够合理。你可以在 OpenViking 的 inspect 输出里核对:核心知识点是不是一个完整块?关键关联是不是被硬生生切断了?如果确实有问题,调整block_size和respect_structure设置,再做一波小样本评估。另外,score_threshold别设太高,本地模型检索阈值在 0.4 到 0.5 之间比较安全,太高容易漏召回。
5.3 别把知识库当成无脑垃圾桶
这句话我得放前面说。OpenViking 能识别文档结构,但不代表它能在垃圾输入里变出金子。导入之前先筛一遍资料:重复文案、过时版本、无关杂谈,能清理就先清掉。知识库越大,噪音越多,检索排序越容易被带偏。我自己的原则是“宁缺毋滥”,一份高质量的产品手册,好过一百份来源不明的零散笔记。
5.4 本地部署的资源占用优化
如果你在配置比较低的机器上跑,建议给向量索引做瘦身,只保留必要的元数据字段;生成图片描述时,可以限制图片尺寸和最大识别数量;检索时也可以把top_k从默认值调低一点。实际调整效果很可观,我用 8GB 老笔记本跑完全没压力。小尺寸模型加合理参数,即使普通办公机能稳定输出结果。
6. 最后给你的一点实操建议
我个人折腾这套方案大半年的体会是:RAG 的上限取决于结构,而不只是模型本身。单纯堆更多的向量和更大的模型,解决不了切块碎片化和上下文丢失的问题。SAG 的思路给我的启发是,知识库应该被看作一个有层次的结构体系,文本块之间要建立关系,检索才能走得深、找得准。
如果你也是被经典 RAG 的切块和召回折腾到头秃,建议先拿一份你手头最有代表性的文档,用 OpenViking 跑一遍导入,再看看生成的结构树是否合理。多试几次不同参数,再对照着看问答效果,你会对“结构化增强生成”有更直观的感受。等到结构关过了,后面接什么框架、用什么模型,自然都会顺手很多。