為什么你的RAGFlow需要一個Markdown預(yù)覽器(油猴腳本方案)
前幾天知識星球中有個提問,關(guān)于 RAGFlow 的聊天助手回答引用文件是 markdown 格式時,如何跳轉(zhuǎn)進(jìn)行預(yù)覽的實(shí)現(xiàn)問題。
目前在 RAGFlow 的聊天助手中,如果引用文件格式是 pdf 和 docx,是可以直接點(diǎn)擊跳轉(zhuǎn)打開新的標(biāo)簽頁進(jìn)行預(yù)覽的,不過暫時確實(shí)不支持 markdown 格式。早在今年 2 月份,在 RAGFlow 的 Github issue 上,就有一個關(guān)于“回答中引用的 MD 文檔無法原生預(yù)覽”的討論(#4979)。但是目前并沒有官方或者其他開發(fā)者給出解決方案。不過既然是前端顯示問題,那就沒什么是一個油猴腳本解決不了的。
這篇試圖說清楚:
如何通過利用油猴腳本的腳本注入和跨域請求能力,攔截用戶對 .md 鏈接的點(diǎn)擊,通過調(diào)用 RAGFlow 的內(nèi)部 API 獲取原始數(shù)據(jù),并動態(tài)生成一個預(yù)覽頁面;以及在實(shí)現(xiàn)上述過程中踩到的坑與迭代方向參考。
以下,enjoy:
1、需求場景分析
在追求高精度問答的實(shí)際落地場景中,對于企業(yè)知識庫中充斥的格式各異、布局復(fù)雜的文檔,比如跨越多欄的 PDF 報告、包含復(fù)雜嵌套表格的 Word 文檔、或是圖文混排的產(chǎn)品手冊。直接把這些原始文檔投喂給任何 RAG 系統(tǒng),都可能面臨“Garbage In, Garbage Out”的問題。
而 Ragflow 自帶的 DeepDoc 或者使用 MinerU 解析器雖然強(qiáng)大,但面對這些“臟數(shù)據(jù)”的時候,也難免會產(chǎn)生語義不連貫、上下文割裂的文本塊,這會直接影響檢索的準(zhǔn)確性和最終生成答案的質(zhì)量。
1.1預(yù)處理的主要做法
為了確保送入 RAG 管道的數(shù)據(jù)是高質(zhì)量的,文檔預(yù)處理幾乎是所有嚴(yán)肅 RAG 應(yīng)用中不可或缺的一步。這一步不只是簡單的格式轉(zhuǎn)換,更是一個結(jié)合文檔特點(diǎn)進(jìn)行結(jié)構(gòu)化重塑過程。核心目標(biāo)包括:
- 消除噪音:剔除頁眉、頁腳、頁碼等無關(guān)信息。
- 保留結(jié)構(gòu):正確識別標(biāo)題層級、列表、表格和代碼塊,將視覺結(jié)構(gòu)轉(zhuǎn)化為語義結(jié)構(gòu)。
- 確保連貫性:將被物理分頁或分欄打斷的完整段落重新連接起來。
通過定制化的腳本(如 Python 腳本結(jié)合 PyMuPDF, python-docx 等庫)進(jìn)行預(yù)處理,可以把一份原始復(fù)雜的文檔,清洗并轉(zhuǎn)化為一份干凈結(jié)構(gòu)化的中間格式。
1.2為什么選擇 Markdown 作為中間格式?
為了更加直觀的對比常見的四種中間格式的適用情形,下面結(jié)合這個表格來一起看一下:
特性維度 | TXT (.txt) | Markdown (.md) | HTML (.html) | JSON (.json) |
語義結(jié)構(gòu)保留 | 極低。丟失所有標(biāo)題、列表、表格等結(jié)構(gòu),退化為純文本流。 | 良好。能通過簡單標(biāo)記保留標(biāo)題層級、列表、代碼塊等核心語義。 | 極高。可以像素級地保留所有原始結(jié)構(gòu)和樣式。 | 靈活可定義。可以將內(nèi)容和元數(shù)據(jù)以任意復(fù)雜的結(jié)構(gòu)進(jìn)行組織。 |
LLM 親和度 | 中等。文本干凈,但缺乏結(jié)構(gòu)會影響 LLM 對上下文層次的理解。 | 高。LLM 對 Markdown 有天生的理解力,結(jié)構(gòu)標(biāo)記有助于理解上下文。 | 中等。標(biāo)簽本身是噪音,若不經(jīng)清洗會嚴(yán)重干擾嵌入質(zhì)量。需要額外處理。 | 高(指 JSON 中的文本值)。需要程序解析后將干凈文本喂給模型。 |
人類可讀性 | 高。非常直觀,所見即所得。 | 極高。既保留了結(jié)構(gòu),又非常易于人類閱讀和調(diào)試。 | 低。充斥著標(biāo)簽,不便于直接閱讀原始內(nèi)容。 | 低。是為機(jī)器設(shè)計的格式,人類難以直接閱讀長篇內(nèi)容。 |
實(shí)現(xiàn)復(fù)雜度 | 低。幾乎所有解析庫都能輕松輸出純文本。 | 中等。需要編寫邏輯將文檔結(jié)構(gòu)(如 Word 的 Heading 1)映射到 MD 標(biāo)記(#)。 | 高。要生成干凈、有效的 HTML,需要復(fù)雜的轉(zhuǎn)換邏輯和嚴(yán)格的 XSS 過濾。 | 高。需要為數(shù)據(jù)定義清晰的 schema(模式),并進(jìn)行序列化。 |
元數(shù)據(jù)處理 | 無。無法內(nèi)嵌元數(shù)據(jù)(如來源頁碼)。 | 有限。可以通過 YAML Front Matter 注入,但不是原生標(biāo)準(zhǔn)。 | 優(yōu)秀。可以通過 data-* 屬性將元數(shù)據(jù)與具體元素綁定。 | 極佳。天生就是為組織“數(shù)據(jù)和元數(shù)據(jù)”而設(shè)計的。 |
一句話總結(jié) | 適用簡單場景:用于處理純散文、無結(jié)構(gòu)的文章,追求極簡。 | 平衡之選:最適合需要保留核心結(jié)構(gòu)且兼顧人機(jī)可讀性的場景。 | 追求高保真:當(dāng)需要復(fù)現(xiàn)復(fù)雜表格或布局,且不惜處理成本時使用。 | 系統(tǒng)化管道:用于需要精細(xì)控制、攜帶大量元數(shù)據(jù)的自動化、工程化 RAG 流程。 |
從上表可以看出來,其實(shí)并不存在“最好”的格式,只有“最適合”的場景。而之所以在許多實(shí)踐中傾向于選擇 Markdown,是因?yàn)樗?“保留關(guān)鍵結(jié)構(gòu)”、“模型友好度”和“人類可讀性(易于調(diào)試)” 這三個對維度上取得了很好的平衡。
注:針對更為復(fù)雜的的表格或頁面布局建議還是使用 html 格式,這部分內(nèi)容預(yù)計 7 月初我會在結(jié)合歷史文章中提到的 IBM 的 RAG 冠軍賽項目復(fù)現(xiàn)文章中進(jìn)行具體介紹。
2、實(shí)現(xiàn)原理解析
這個流程圖展示了 RAGFlow 的聊天助手中,引用文件是 Markdown 時的預(yù)覽功能完整實(shí)現(xiàn)機(jī)制。通過 MutationObserver 實(shí)時監(jiān)聽聊天界面的 DOM 變化,自動為 .md 文件鏈接綁定自定義點(diǎn)擊事件,當(dāng)用戶點(diǎn)擊時攔截默認(rèn)跳轉(zhuǎn)行為并創(chuàng)建新標(biāo)簽頁,同時從鏈接中提取文檔 ID 并讀取認(rèn)證憑據(jù),通過 GM_xmlhttpRequest 向后端 /v1/chunk/list 接口請求文檔數(shù)據(jù),最后利用 marked.js 庫解析 JSON 響應(yīng)中的內(nèi)容塊,動態(tài)構(gòu)建并渲染格式化的 HTML 頁面,實(shí)現(xiàn)了無侵入式的文檔預(yù)覽功能增強(qiáng)。整個流程采用異步處理和錯誤處理機(jī)制,確保使用的流暢性和系統(tǒng)穩(wěn)定性。
3、核心模塊拆解
3.1動態(tài)事件監(jiān)聽與觸發(fā)
由于 Ragflow 是一個現(xiàn)代化的單頁應(yīng)用 (SPA),其聊天內(nèi)容是動態(tài)加載到頁面中的。傳統(tǒng)的頁面加載事件無法監(jiān)聽到這些后續(xù)出現(xiàn)的內(nèi)容。因此,腳本的核心入口采用了 MutationObserver API。
實(shí)現(xiàn)邏輯
初始化一個 MutationObserver 實(shí)例,配置它來監(jiān)視 document.body 及其所有后代節(jié)點(diǎn) (subtree: true) 的添加或刪除 (childList: true)。
當(dāng)監(jiān)聽到有新節(jié)點(diǎn)被添加到頁面時,腳本會遍歷這些節(jié)點(diǎn),并使用 querySelectorAll 高效地查找其中所有指向 .md 文件的 (這里有個東西)鏈接。
為了避免重復(fù)綁定,腳本為每個處理過的鏈接添加了一個 data-md-preview-handled 屬性作為標(biāo)記。如果鏈接未被標(biāo)記,則為其 click 事件綁定核心處理函數(shù) processMarkdownLink。
關(guān)鍵函數(shù)說明
new MutationObserver(callback): 創(chuàng)建一個觀察者對象,當(dāng)指定的 DOM 變化發(fā)生時,執(zhí)行回調(diào)函數(shù)。這是應(yīng)對動態(tài)網(wǎng)頁內(nèi)容變化的不二之選。
observer.observe(targetNode, options): 啟動觀察者。{ childList: true, subtree: true } 是性能和功能之間的完美平衡,確保了不會錯過任何動態(tài)添加的鏈接。
3.2核心處理流程
當(dāng)用戶點(diǎn)擊目標(biāo)鏈接后,這個函數(shù)會被觸發(fā),并按序執(zhí)行一系列操作。
實(shí)現(xiàn)邏輯
即時響應(yīng)與阻止默認(rèn):立刻調(diào)用 event.preventDefault() 和 event.stopPropagation(),阻止瀏覽器執(zhí)行默認(rèn)的、無法預(yù)覽的跳轉(zhuǎn)行為。同時,window.open() 打開一個新標(biāo)簽頁并顯示“加載中”,給予用戶即時反饋。
信息采集:通過正則表達(dá)式從鏈接的 href 屬性中精確提取出 doc_id。隨后,從瀏覽器的 localStorage 中獲取 Authorization Token,這是后續(xù)與后端 API 進(jìn)行認(rèn)證通信的關(guān)鍵憑證。
關(guān)鍵函數(shù)說明
event.preventDefault(): 阻止事件的默認(rèn)動作,此處即阻止鏈接跳轉(zhuǎn)。
window.open('', '_blank'): 打開一個新窗口,并保留其句柄(tempWindow),以便后續(xù)向其寫入內(nèi)容。
localStorage.getItem('Authorization'): 從瀏覽器本地存儲中獲取身份驗(yàn)證令牌。腳本的運(yùn)行環(huán)境與 Ragflow 頁面相同,因此可以直接訪問。
3.3后端數(shù)據(jù)安全交互
獲取到必要信息后,腳本需要與 Ragflow 的后端進(jìn)行通信,以拉取文檔的完整內(nèi)容。
實(shí)現(xiàn)邏輯
利用油猴提供的 GM_xmlhttpRequest 發(fā)起一個 POST 請求到 Ragflow 的 /v1/chunk/list API 端點(diǎn)。
請求頭中必須包含 Content-Type 和從 localStorage 中獲取的 Authorization Token。
請求體是一個 JSON 對象,包含了需要查詢的 doc_id 和一個較大的 limit 值,以確保能一次性獲取所有內(nèi)容塊 (chunks)。
通過設(shè)置 onload、onerror 和 ontimeout 回調(diào)函數(shù),對請求的成功、失敗和超時情況進(jìn)行全面處理。
關(guān)鍵函數(shù)說明
GM_xmlhttpRequest(details): 油猴的特權(quán) API,可以突破同源策略 (CORS) 的限制,是實(shí)現(xiàn)該功能的核心。腳本頭部的 @connect localhost 和 @connect 127.0.0.1 就是在為此 API 授權(quán)。
JSON.stringify(payload): 將 JavaScript 對象序列化為 JSON 字符串,作為請求體發(fā)送。
3.4前端動態(tài)渲染與呈現(xiàn)
這是把枯燥的數(shù)據(jù)轉(zhuǎn)化為美觀頁面的最后一步,也是用戶最終能感知到的部分。
實(shí)現(xiàn)邏輯
數(shù)據(jù)解析:在 onload 回調(diào)中,腳本首先解析 API 返回的 JSON 數(shù)據(jù),分離出文檔的元數(shù)據(jù) (doc) 和內(nèi)容塊數(shù)組 (chunks)。
HTML 結(jié)構(gòu)構(gòu)建:腳本使用模板字符串動態(tài)生成一個完整的 HTML 頁面結(jié)構(gòu)。這包括:
一個美觀的頭部,包含文檔標(biāo)題。
一個展示文檔 ID、創(chuàng)建時間等信息的“元數(shù)據(jù)卡片”。
遍歷 chunks 數(shù)組,為每個內(nèi)容塊創(chuàng)建一個獨(dú)立的“內(nèi)容卡片”。一個巧妙的設(shè)計是,每個塊的原始 Markdown 內(nèi)容被存儲在一個隱藏的 <textarea>中,這可以防止瀏覽器錯誤地解析其中的特殊字符。
集成 Markdown 解析器:在生成的 HTML 的<head>部分,通過 CDN 引入了輕量且強(qiáng)大的 marked.js 庫。
最終渲染:頁面主體包含一段內(nèi)聯(lián)的<script>。它在 DOMContentLoaded 事件觸發(fā)后執(zhí)行,遍歷所有的內(nèi)容卡片,讀取隱藏<textarea>中的 Markdown 原文,使用 marked.parse() 將其轉(zhuǎn)換為 HTML,最后注入到對應(yīng)的容器中,完成最終的渲染。
寫入頁面:調(diào)用 tempWindow.document.write(),將整個拼接好的 HTML 字符串寫入之前打開的新標(biāo)簽頁中,瀏覽器會解析并展示這個頁面。
關(guān)鍵函數(shù)說明
marked.parse(markdownString): marked.js 庫的核心函數(shù),將傳入的 Markdown 格式字符串轉(zhuǎn)換為 HTML 字符串。
tempWindow.document.write(html): 向指定窗口的文檔流中寫入數(shù)據(jù)。這是動態(tài)創(chuàng)建整個頁面的關(guān)鍵。
4、填過的這些坑
4.1鏈接與彈窗的直接對抗
我最開始的想法是攔截鏈接的點(diǎn)擊事件,直接使用 fetch 請求鏈接地址,然后將獲取到的文本內(nèi)容放入一個自己創(chuàng)建的彈窗中顯示。但是發(fā)現(xiàn)彈窗成功彈出,但里面是空的。
通過 F12 開發(fā)者工具的“控制臺”和“網(wǎng)絡(luò)”面板發(fā)現(xiàn),請求鏈接返回的是整個 Ragflow 應(yīng)用的 HTML 主頁面。這是第一個碰到的障礙:單頁應(yīng)用(SPA)的路由機(jī)制。就是服務(wù)器被配置為將所有無法直接匹配的路徑都重定向到應(yīng)用的入口 index.html,由前端 JavaScript 來接管后續(xù)的路由和數(shù)據(jù)加載。這說明最初的腳本從一開始就敲錯了門。
4.2尋找真正的 API
既然直接請求鏈接行不通,那一定有一個隱藏的、真正用來獲取文件內(nèi)容的 API 接口,這就要通過監(jiān)視 Ragflow 自身的網(wǎng)絡(luò)請求來找到它。
通過在知識庫頁面操作,成功的在“網(wǎng)絡(luò)”面板捕獲到了一個形如 /v1/chunk/list 的 API 請求。但是當(dāng)腳本去請求這個新發(fā)現(xiàn)的 API 時,服務(wù)器卻返回了“401 未授權(quán)”的錯誤。測試下來發(fā)現(xiàn)其實(shí)是兩層防御問題需要克服下:
第一層防御:HttpOnly Cookie
最初嘗試用 document.cookie 手動附加 Cookie,但失敗了。這意味著最關(guān)鍵的登錄憑證被存儲在 HttpOnly Cookie 中,JavaScript 無法讀取。
第二層防御:授權(quán)令牌 (Authorization Token)
即使讓油猴腳本自動攜帶 Cookie,請求依然失敗。最后發(fā)現(xiàn),Ragflow 使用了更安全的 Token 認(rèn)證機(jī)制。真正的“通行證”不是 Cookie,而是一個存儲在 localStorage 中的、名為 Authorization 的令牌,它必須被放在請求頭中發(fā)送。
4.3與前端框架的終極博弈
在擁有了所有正確的鑰匙(API 地址、請求方法、認(rèn)證令牌、請求參數(shù)),成功獲取到了包含 Markdown 內(nèi)容的 JSON 數(shù)據(jù)。現(xiàn)在只需要將它顯示出來即可。但是彈窗就是無法穩(wěn)定地顯示在頁面上。有時會閃現(xiàn)一下,有時干脆沒有任何反應(yīng),控制臺也沒有任何錯誤。
通過使用 debugger 凍結(jié)時間的方式發(fā)現(xiàn),彈窗確實(shí)被成功添加到了頁面上,但幾乎在同一瞬間就被 Ragflow 的前端框架(React)給“凈化”或移除了,因?yàn)樗粚儆诳蚣芄芾淼摹疤摂M DOM”結(jié)構(gòu)。這個算是碰到了現(xiàn)代前端框架最核心的壁壘——絕對的 DOM 控制權(quán)。任何試圖在框架“管轄范圍”之外直接操作 DOM 的行為,都注定會失敗。
4.4放棄對抗,另辟蹊徑
最后我放棄了在原頁面顯示彈窗的方案,選擇和 RAGFlow 的原生文件預(yù)覽方式一樣,在新標(biāo)簽頁中渲染。也就是在腳本攔截點(diǎn)擊后,在后臺完成所有正確的數(shù)據(jù)請求。然后,它將獲取到的元數(shù)據(jù)和 Markdown 內(nèi)容,動態(tài)地構(gòu)建成一個完整的、獨(dú)立的 HTML 頁面。最后,通過打開一個新標(biāo)簽頁來展示這個頁面。
最后展示的效果中,進(jìn)一步提取了文檔的元數(shù)據(jù)(如 ID、創(chuàng)建時間等),并采用 iOS 扁平化設(shè)計風(fēng)格,將所有信息以清晰的卡片式布局呈現(xiàn)出來。
5、寫在最后
5.1關(guān)于油猴腳本
單頁應(yīng)用路由、Token 身份認(rèn)證、前端框架的 DOM 控制權(quán),幾乎是每一個試圖對現(xiàn)代 Web 應(yīng)用進(jìn)行個性化增強(qiáng)的開發(fā)者都會遇到的“三座大山”。而在 RAG 流程乃至更廣泛的 Web 應(yīng)用生態(tài)中,油猴腳本所代表的客戶端注入模式,積極意義遠(yuǎn)超“小打小鬧”的范疇:
敏捷與個性化
官方產(chǎn)品迭代有其固定的節(jié)奏和優(yōu)先級。而作為一線用戶的痛點(diǎn)往往是即時且個性化的。油猴腳本可以快速實(shí)現(xiàn)特定工作流中的功能補(bǔ)完,將產(chǎn)品打磨成最適合自己或團(tuán)隊的形態(tài)。
低風(fēng)險與高兼容性
這種增強(qiáng)方式是非侵入式的,不用修改任何服務(wù)端代碼,也不觸碰核心前端應(yīng)用的文件。這意味著它幾乎不會對原系統(tǒng)的穩(wěn)定性造成任何風(fēng)險。只要 Ragflow 的核心 API 保持穩(wěn)定,即使前端界面升級,腳本大概率也能繼續(xù)工作,維護(hù)成本極低。
5.2從 UI 增強(qiáng)到工作流自動化
這個 Markdown 預(yù)覽腳本只是拋磚引玉,各位可以解決自己在使用 Ragflow 或其他工具時遇到的個性化問題。值得探索的方向還有:
知識庫管理增強(qiáng)
編寫腳本,在知識庫文檔列表頁面,為每個文檔增加“計算預(yù)估 Token 數(shù)”、“快速查看分塊摘要”等按鈕,在上傳和管理階段提供更多決策支持。
聊天交互的自動化
在聊天輸入框旁,增加一個“常用 Prompt 模板”面板,一鍵發(fā)送復(fù)雜的指令。或者增加一個“導(dǎo)出對話”按鈕,將當(dāng)前問答流程以特定格式保存到本地。
跨系統(tǒng)工作流集成
更高階的玩法,是讓腳本成為連接器。例如,在預(yù)覽頁面增加一個按鈕,可以將某個重要的 Chunk 內(nèi)容,連同其元數(shù)據(jù),一鍵發(fā)送到 Notion、Obsidian 等筆記軟件中。