ARTICLE DETAIL

资讯详情

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

RAG结果如何沉淀为可维护的知识资产:Markdown+TypeScript+MCP实践

RAG结果如何沉淀为可维护的知识资产:Markdown+TypeScript+MCP实践 1. 为什么“RAG 结果”需要变成“知识资产”1.1 从“能查到”到“能维护”的断层做过 RAG 项目的人大概都有过这种体验向量库搭起来了文档切块也跑通了问一个问题模型能吐出看起来挺像样的答案。但过了一两个月你回头想改一个事实、补一个版本号、纠正一段过时的描述整个人就懵了——原始内容散在几十个 chunk 里chunk 之间的边界是机器切的语义被切得七零八落你根本不知道哪一块对应哪一句原文。这就是 RAG 最尴尬的地方检索效果可以调但知识本身不可维护。LLM Wiki 这个项目要解决的正是这个断层。它的核心主张很朴素把 RAG 检索出来的、散落在向量库里的碎片重新沉淀成一份人类可读、可编辑、可版本管理的 Markdown 知识库。换句话说它不满足于让模型“查到”而是要让知识“留下来、改得动、传得下去”。这个定位和 Karpathy 提过的 llm wiki 思路是一脉相承的——知识应该以纯文本的形式存在模型只是读写它的工具而不是知识的唯一容器。我一开始也觉得这不过是又一个“RAG 套壳”但真正动手跑了一遍之后发现它踩中的是一个被大多数人忽略的痛点RAG 的输出是消耗品而知识资产是耐用品。前者用完即弃后者需要长期维护。这两者的工程要求完全不同。1.2 谁适合参考这个项目这个项目不是给纯小白准备的“一键部署”玩具。它更适合这几类人一是已经在跑 RAG 项目、但被知识维护问题折磨的工程师二是想给自己的团队搭一个内部知识库、又不想被某个 SaaS 平台锁死的技术负责人三是对 MCP 协议、TypeScript 工程化感兴趣想找一个真实项目练手的开发者。如果你只是想让模型回答几个问题那用现成的对话工具就够了没必要上这套东西。它的技术栈选得也很有代表性TypeScript 打底Markdown 作为知识载体MCP 作为对外接口。这三个选择背后都有明确的工程考量后面我会逐个拆开讲。2. 整体设计思路为什么是 Markdown TypeScript MCP2.1 用 Markdown 当知识载体而不是数据库第一个关键决策是知识存储格式。很多 RAG 项目会把切好的 chunk 直接塞进向量库原文只作为“来源”存在甚至干脆不保留。LLM Wiki 反其道而行把Markdown 文件作为唯一的事实来源source of truth向量库只是它的一个索引副本。这个选择的好处用过 Git 的人秒懂。Markdown 是纯文本天然支持 diff、blame、merge可以进版本控制可以 code review可以回滚。你改了一个事实git log 里清清楚楚。而向量库里的 chunk 是二进制向量你没法 review 它也没法 merge 两个版本的 chunk。把 Markdown 当源头等于把知识资产纳入了软件工程的那套成熟流程。另一个隐性好处是可移植性。Markdown 不依赖任何特定平台今天用这个工具明天换那个工具文件还是那些文件。向量库可以随时重建但知识本身不会丢。这一点在选型时经常被低估等到你想迁移平台的时候才知道有多重要。提示如果你的知识里有大量表格、公式、图片Markdown 的表达能力会吃紧。这时候要么用 Markdown 的扩展语法比如表格、脚注要么把复杂内容拆成“Markdown 描述 附件”的组合别硬塞。2.2 TypeScript 打底类型安全在知识工程里的价值第二个决策是语言。用 TypeScript 写这类工具很多人第一反应是“没必要Python 生态更成熟”。但 LLM Wiki 的场景里TypeScript 有几个实打实的优势。首先是前后端同构。知识库大概率需要一个 Web 界面来浏览和编辑前端用 TS后端也用 TS类型定义可以共享。一个Document接口前端拿到的和后端返回的是同一份定义改字段的时候编译器直接报错不用靠人肉对齐。这在知识结构频繁演化的项目里省下的调试时间非常可观。其次是MCP 生态的亲和性。MCPModel Context Protocol的官方 SDK 对 TypeScript 支持很好写一个 MCP server 暴露知识库的读写能力用 TS 是最顺手的路径。MCP 和 RAG 的区别在这里也体现出来了RAG 是“你问我检索”单向的MCP 是“模型可以主动调用工具去读、去写、去改”是双向的。把知识库通过 MCP 暴露出去模型就不只是消费者还能成为维护者。不过要提醒一句TypeScript 最近的版本迭代里有一些配置项在弃用比如baseUrl、moduleResolutionnode10这些官方说会在 TS 7.0 停止支持。新项目起步时最好直接用bundler或node16这类现代解析策略别抄老模板的 tsconfig否则升级时会踩一堆坑。2.3 MCP 作为接口层让模型能读也能写第三个决策是把 MCP 作为对外接口。这是整个项目最有想象力的地方。传统 RAG 的接口是“检索”输入 query输出 chunks。而 MCP 暴露的是一组工具tools模型可以调用read_document、search_knowledge、update_section这样的能力。这意味着什么意味着你可以让模型在回答问题的同时顺手把新学到的知识写回知识库。比如用户问“我们的 API 限流策略是什么”模型检索后发现知识库里没有它可以调用写入工具把这次对话里确认的信息补进去。知识库因此是活的会随着使用不断生长。当然写入权限要谨慎。我的做法是读操作直接开放写操作走一个“草稿区”模型写进去的内容先落到drafts/目录人工 review 后再合并到主库。这样既享受了自动化的便利又不会让模型把错误信息直接污染主知识库。3. 核心细节解析知识库的目录结构与切块策略3.1 目录结构怎么设计才经得起折腾知识库的目录结构是长期维护的地基一开始图省事后面改起来就是灾难。LLM Wiki 这类项目通常采用“领域 文档”的两级结构我实测下来比较稳的一种组织方式是这样的knowledge/ api/ authentication.md rate-limiting.md error-codes.md product/ pricing.md roadmap.md ops/ deployment.md monitoring.md drafts/ .index/ vectors.json metadata.jsonknowledge/下按领域分目录每个领域里是独立的 Markdown 文件。drafts/放模型生成的草稿.index/放向量索引和元数据这两个目录都进.gitignore因为它们是可以重建的派生数据。这里有个容易踩的坑别按“文档来源”分目录要按“知识主题”分。我见过有人按“来自 Confluence”“来自 Notion”来分结果同一个主题的知识散在好几个目录里检索时反而更难聚合。知识库的组织逻辑应该服务于“人怎么找”而不是“数据从哪来”。3.2 切块策略Markdown 结构就是天然的切块边界RAG 切块是个老生常谈的问题固定长度切、按句子切、按语义切各有各的毛病。LLM Wiki 的思路很聪明既然知识是 Markdown那就用 Markdown 的标题层级来切。一个##标题下的内容天然就是一个语义完整的块。###子标题下的内容是更细的块。这样切出来的 chunk边界是作者自己划的不是机器猜的语义完整性有保障。而且每个 chunk 都能追溯到“哪个文件的哪个标题下”检索结果可以直接定位到原文位置用户点一下就能跳过去看上下文。具体实现上解析 Markdown 的 AST遍历标题节点把每个标题到下一个同级或更高级标题之间的内容作为一个 chunk。代码大致是这样import { unified } from unified; import remarkParse from remark-parse; interface Chunk { filePath: string; headingPath: string[]; content: string; } function splitByHeading(markdown: string, filePath: string): Chunk[] { const tree unified().use(remarkParse).parse(markdown); const chunks: Chunk[] []; let current: Chunk | null null; for (const node of tree.children) { if (node.type heading) { if (current) chunks.push(current); const title extractText(node); current { filePath, headingPath: [title], content: }; } else if (current) { current.content serialize(node); } } if (current) chunks.push(current); return chunks; }这段代码的关键在于headingPath它记录了 chunk 的标题路径比如[API, Rate Limiting, Per-user limits]。检索时把这个路径拼进 chunk 的文本里一起做 embedding能显著提升召回的相关性因为标题本身就是高度浓缩的语义信息。注意如果某个##下的内容特别长超过 1000 字最好再按段落做二次切分否则单个 chunk 太大embedding 会稀释掉细节。我的经验是单 chunk 控制在 300 到 800 字之间比较合适。3.3 元数据设计让每个 chunk 都能被追溯光有内容还不够每个 chunk 还得带上足够的元数据才能在检索和展示时用得上。我一般会保留这几类字段字段类型用途filePathstring定位源文件headingPathstring[]标题路径用于展示和加权lastModifiedstring最后修改时间用于时效性排序tagsstring[]主题标签用于过滤hashstring内容哈希用于增量更新hash这个字段特别有用。重建索引时先算每个 chunk 的 hash和上次索引里的对比只对变化的 chunk 重新做 embedding。一个几千文档的知识库全量重建可能要几分钟增量更新往往几秒就搞定。这个优化在开发阶段体验差别巨大。4. 实操过程从零搭一个可维护的知识库4.1 环境准备与依赖安装先把项目骨架搭起来。Node 版本建议 20 以上TypeScript 用 5.3 之后的版本。初始化mkdir llm-wiki cd llm-wiki npm init -y npm install typescript tsx types/node --save-dev npm install unified remark-parse remark-stringify npm install modelcontextprotocol/sdk npx tsc --inittsconfig.json里重点改这几项避开前面提到的弃用坑{ compilerOptions: { target: ES2022, module: ESNext, moduleResolution: bundler, strict: true, outDir: dist, rootDir: src } }注意这里用的是bundler而不是老的node10baseUrl也没配路径别名改用paths配合moduleResolution: bundler来实现。这样配置在 TS 7.0 到来时不会突然报弃用警告。4.2 索引构建把 Markdown 变成可检索的向量索引构建分三步扫描文件、切块、生成 embedding。扫描用fs递归遍历knowledge/目录过滤掉.index/和drafts/。切块用上一节的splitByHeading。生成 embedding 这一步可以接任意一个 embedding 服务本地模型或者云端 API 都行。async function buildIndex(knowledgeDir: string) { const files await scanMarkdown(knowledgeDir); const allChunks: Chunk[] []; for (const file of files) { const content await fs.readFile(file, utf-8); const chunks splitByHeading(content, file); allChunks.push(...chunks); } const withEmbeddings await Promise.all( allChunks.map(async (chunk) ({ ...chunk, hash: sha256(chunk.content), embedding: await embed(chunk.headingPath.join( ) \n chunk.content), })) ); await fs.writeFile(.index/vectors.json, JSON.stringify(withEmbeddings)); }这里有个细节值得说embedding 的输入我把headingPath拼在了内容前面。实测下来这样做的召回准确率比只 embed 正文要高出一截因为标题里的关键词密度大能帮模型更快锁定主题。4.3 通过 MCP 暴露读写能力MCP server 的核心是注册工具。读操作注册search_knowledge和read_document写操作注册create_draft。用官方 SDK 写起来很直接import { Server } from modelcontextprotocol/sdk/server/index.js; const server new Server({ name: llm-wiki, version: 1.0.0 }); server.tool(search_knowledge, { query: { type: string } }, async ({ query }) { const results await search(query, { topK: 5 }); return { content: results.map((r) ({ type: text, text: [${r.headingPath.join( )}]\n${r.content}, })), }; }); server.tool(create_draft, { title: { type: string }, body: { type: string }, }, async ({ title, body }) { const path drafts/${slugify(title)}.md; await fs.writeFile(path, # ${title}\n\n${body}); return { content: [{ type: text, text: Draft saved to ${path} }] }; });search_knowledge是只读的模型可以随便调。create_draft只往drafts/写不碰主库。这个权限边界是整个设计里最关键的一环它让自动化写入变得安全可控。4.4 增量更新与索引重建知识库是活的文件会变。每次启动时跑一次增量更新对比 hash只重建变化的 chunkasync function incrementalUpdate() { const oldIndex await loadIndex(); const oldHashes new Map(oldIndex.map((c) [c.filePath c.headingPath.join(/), c.hash])); const newChunks await collectChunks(); const changed newChunks.filter((c) { const key c.filePath c.headingPath.join(/); return oldHashes.get(key) ! c.hash; }); if (changed.length 0) return oldIndex; const updated await Promise.all( changed.map(async (c) ({ ...c, embedding: await embed(c.headingPath.join( ) \n c.content) })) ); const changedKeys new Set(changed.map((c) c.filePath c.headingPath.join(/))); const merged [...oldIndex.filter((c) !changedKeys.has(c.filePath c.headingPath.join(/))), ...updated]; await saveIndex(merged); return merged; }这套逻辑跑下来日常改几个文件索引更新基本是秒级的。只有首次全量构建或者大规模重构时才需要等一会儿。5. 常见问题与排查技巧实录5.1 检索结果不相关先查这三处RAG 检索不准原因往往不在模型而在数据。我整理了一个排查顺序按这个走基本能定位到问题现象可能原因排查方法召回内容完全不相关切块边界错误打印 chunk 内容看是否被切碎相关但排序靠后embedding 输入缺标题检查是否拼了 headingPath时好时坏索引未增量更新对比文件 hash 和索引 hash中文检索差embedding 模型不适配换多语言模型测试最常见的是第一个。Markdown 里如果有大量列表、代码块按标题切出来的 chunk 可能包含一堆无关内容。这时候要么在切块时过滤掉代码块要么给代码块单独建索引。5.2 Markdown 语法踩坑换行、表格、图片路径Markdown 看着简单实际用起来坑不少。换行是最典型的很多编辑器里敲一个回车渲染出来还是同一段得敲两个回车或者行尾加两个空格。知识库里的内容如果依赖单换行来分隔渲染和解析结果会不一致切块时也会出问题。我的建议是统一用空行分段别依赖单换行。表格在 Markdown 里表达力有限复杂的合并单元格做不了。如果知识里有大量表格考虑用 HTML 表格嵌在 Markdown 里或者把表格转成结构化数据单独存。热词里提到的“markdown 表格转换 excel”其实就是这个痛点的延伸很多团队需要把知识库里的表格导出做分析。图片路径要用相对路径别用绝对路径。知识库一旦迁移绝对路径全废。相对路径配合统一的assets/目录走到哪都能正常显示。5.3 MCP 和 RAG 到底怎么配合这是被问得最多的问题。简单说RAG 是检索策略MCP 是调用协议两者不是替代关系。RAG 解决“怎么从大量内容里找到相关的”MCP 解决“模型怎么调用外部能力”。在 LLM Wiki 里RAG 是 MCP 工具search_knowledge的内部实现模型通过 MCP 调用检索检索内部用 RAG 完成。所以“rag 和 mcp 区别”这个问题的答案是它们在不同层。RAG 在数据层MCP 在接口层。一个知识库可以同时用 RAG 做检索、用 MCP 做暴露两者配合得很好。真正要区分的是“agentic rag”和传统 RAG——前者让模型自己决定检索几次、怎么改写 query后者是一次性检索。LLM Wiki 更偏向 agentic 那一侧因为模型可以通过 MCP 多次调用检索工具。5.4 几个我踩过的坑第一个坑是草稿区没做清理。模型写进drafts/的内容如果长期不 review会越积越多最后没人看。我的做法是给草稿加时间戳超过两周没处理的自动归档到drafts/archive/眼不见心不烦但也不丢。第二个坑是embedding 模型换版本。换了模型之后旧索引和新 query 不在同一个向量空间检索结果会莫名其妙地差。换模型必须全量重建索引这个没有捷径。第三个坑是文件编码。Windows 上编辑的 Markdown 可能是 GBK 编码读进来是乱码。统一用 UTF-8在读取时显式指定编码能省掉很多诡异问题。6. 知识资产的长期维护心得把 RAG 结果变成知识资产最难的不是技术是习惯。技术方案再优雅如果没人愿意维护知识库照样会烂掉。我自己的做法是定几条简单的规矩每个知识文件开头写一段“最后更新时间”和“负责人”改内容必须改这两项每周花十分钟过一遍drafts/该合并的合并该删的删每季度做一次全量 review把过时的内容标记出来。这些规矩听起来很土但比任何自动化工具都管用。工具能帮你把知识变成 Markdown能帮你建索引、暴露 MCP 接口但它没法替你判断哪条知识还有效、哪条已经过时。这部分永远需要人。LLM Wiki 这个项目给我的最大启发不是它用了多先进的技术而是它把“知识”重新放回了它该在的位置——一份可以被人读懂、被人修改、被人传承的文本。模型是工具向量库是索引MCP 是接口但知识本身始终是那些 Markdown 文件里的字。想清楚这一点很多工程决策就顺了。
返回列表