实战教程:用 Ollama 嵌入 + SQLite 构建本地 RAG,解决审计报告关键词搜索失效问题,无 API 成本、支持离线查询。展示同义表述下的跨文档知识关联。
上个月,我在审查一份金库合约时,突然产生了一种强烈的熟悉感:这个舍入 bug 我肯定见过,就在两三年前的某份审计报告里,应该和 ERC4626 的份额计算有关。我花了四十分钟,在一堆 PDF 和 Markdown 文件里反复 grep,却始终没找到。那些知识明明就在我的磁盘上,我却无法查询它们。
公开的审计报告,是这个领域里最未被充分利用的资源之一。Sherlock 竞赛报告、Code4rena 漏洞发现、Trail of Bits 发布的报告、OpenZeppelin 审计报告:其中记录了成千上万个真实漏洞,由发现这些漏洞的人亲自描述,还附有导致漏洞的确切代码模式。但它们散落在 PDF、GitHub 仓库和评审平台中;关键词搜索也常常无能为力,因为同一种 bug 可能有十种不同的表述方式。“舍入方向有利于攻击者”“份额价格膨胀”“首位存款人攻击”“捐赠攻击”:四种说法,指向的是同一类 bug。
这正是 embeddings 擅长解决的问题。于是,我基于自己的报告集合构建了一套本地 RAG:使用 Ollama 生成 embeddings,使用 SQLite 存储,全部离线运行。没有 API 成本,没有速率限制,甚至坐在飞机上也能查询。下面是具体做法,其中有一个决策,比其他所有决策都更重要。
几乎每篇 RAG 教程都会告诉你,把文档拆成固定大小、彼此重叠的 chunk。但对于审计报告来说,这种做法反而会造成伤害。一个 500-token 的窗口,很可能直接把某条漏洞发现从中间切开,再把“H-02:withdraw 中的重入漏洞”的后半段,和“H-03:未检查预言机数据是否过期”的开头粘在一起。这样生成的 embedding 就成了一个四不像,无法很好地匹配任何内容。
审计报告天然存在一种最小完整单元:漏洞发现。一条漏洞发现包含一个标题、一个严重级别、一段描述、一段代码和一个修复方案。绝大多数漏洞发现都足够短,可以整体生成 embedding;而且作为搜索结果返回时,这也恰好是你想要的粒度。因此,chunker 的任务就是检测每条漏洞发现的边界。审计报告的格式通常相当固定,所以这种方法效果很好:
interface Finding {
id: string; // "H-01", "M-07", "TOB-XYZ-3"
severity: string; // parsed from the id or heading
title: string;
body: string; // full finding text incl. code blocks
source: string; // report file path
protocol: string; // from report metadata
date: string;
}
const FINDING_HEADING = /^#{1,4}\s*\[?((?:H|M|L|QA|G)-\d+|TOB-[A-Z0-9-]+-\d+|C4-\d+)\]?[:.\s-]+(.+)$/;
export function chunkReport(markdown: string, meta: ReportMeta): Finding[] {
const lines = markdown.split("\n");
const findings: Finding[] = [];
let current: { id: string; title: string; start: number } | null = null;
const flush = (end: number) => {
if (!current) return;
const body = lines.slice(current.start, end).join("\n").trim();
if (body.length < 200) return; // skip stubs and withdrawn findings
findings.push({
id: current.id,
severity: severityFromId(current.id),
title: current.title,
body,
source: meta.path,
protocol: meta.protocol,
date: meta.date,
});
};
lines.forEach((line, i) => {
const m = line.match(FINDING_HEADING);
if (m?.[1] && m[2]) {
flush(i);
current = { id: m[1], title: m[2].trim(), start: i };
}
});
flush(lines.length);
return findings;
}
这个正则表达式覆盖了 Code4rena 和 Sherlock 的格式约定,也支持 Trail of Bits 的 ID。到了真实场景中,还需要再补充几种格式变体:有些公司使用“Finding 3:”这样的写法,有些则直接用严重级别单词作为标题。PDF 还需要先经过一次文本提取——我使用一个 CLI 转换器,并接受转换结果会有些混乱。它不必做到完美。一个能够正确隔离 90% 漏洞发现的 chunker,效果也远胜固定大小的窗口。
还有一个技巧:我会把元数据添加到用于生成 embedding 的文本开头,让“protocol: , severity: High”也成为向量所表达内容的一部分。这样一来,在查询中提到严重级别或协议类型时,无需额外处理就能得到相应效果。
你不需要为此部署向量数据库。我的整个语料库只有几千条漏洞发现,对几千个向量进行暴力余弦相似度计算只需要几毫秒。所有数据都存放在 SQLite 中:
import Database from "better-sqlite3";
const db = new Database("audits.db");
db.exec(`
CREATE TABLE IF NOT EXISTS findings (
id INTEGER PRIMARY KEY,
finding_id TEXT, severity TEXT, title TEXT, body TEXT,
protocol TEXT, source TEXT, date TEXT,
embedding BLOB
)
`);
async function embed(text: string): Promise<Float32Array> {
const res = await fetch("http://localhost:11434/api/embed", {
method: "POST",
body: JSON.stringify({ model: "nomic-embed-text", input: text }),
});
const json = await res.json();
return new Float32Array(json.embeddings[0]);
}
export async function indexFinding(f: Finding): Promise<void> {
const text = `protocol: ${f.protocol}\nseverity: ${f.severity}\n${f.title}\n\n${f.body}`;
const vec = await embed(text.slice(0, 8000));
db.prepare(
`INSERT INTO findings
(finding_id, severity, title, body, protocol, source, date, embedding)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
).run(f.id, f.severity, f.title, f.body, f.protocol, f.source, f.date,
Buffer.from(vec.buffer));
}
查询过程使用同样的 embed 调用,再加一次扫描:
function cosine(a: Float32Array, b: Float32Array): number {
let dot = 0, na = 0, nb = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i]! * b[i]!; na += a[i]! * a[i]!; nb += b[i]! * b[i]!;
}
return dot / (Math.sqrt(na) * Math.sqrt(nb));
}
export async function search(query: string, k = 10) {
const qv = await embed(query);
const rows = db.prepare("SELECT * FROM findings").all() as StoredFinding[];
return rows
.map(r => ({ ...r, score: cosine(qv, new Float32Array(r.embedding.buffer)) }))
.sort((a, b) => b.score - a.score)
.slice(0, k);
}
如果你的语料库增长到数万条漏洞发现以上,sqlite-vec 可以让你在同一个文件中进行带索引的向量搜索。不过到目前为止,我还没用上它。
促使我开始做这件事的查询是:“哪些协议曾因 ERC4626 舍入问题吃过亏?”在同一个语料库上进行关键词搜索,只会返回那些正文里明确包含“ERC4626”的漏洞发现。RAG 不仅能返回这些内容,还能找到从未提及该标准名称的首位存款人份额膨胀问题,以及某个完全被描述为“资产到份额的转换以错误方向截断”的金库 bug。最后这一类结果才是真正的回报:它能找出那些采用了你根本想不到要搜索的措辞来描述的 bug。
还有一些很值得使用的查询:“跨链签名重放”“直接转入 token 导致奖励记账异常”“导致提款功能彻底失效的 pausable 函数”。现在,每次开始审计或参加 Sherlock 竞赛之前,我都会针对相应协议类别查询语料库,把排名前二十的漏洞发现当作热身材料。它能把恰到好处的先验知识装进我的脑子里。
我还在搜索之后接了一个小型回答步骤:取排名前五的漏洞发现,连同问题一起塞进 qwen2.5-coder:7b,让它生成一份带报告引用的综合答案。坦率地说,这部分并不是必需的。对我而言,带有来源、按相关性排序的原始漏洞发现,通常比模型生成的摘要更有用;真正的价值几乎全都来自检索。后来,这套管道中的一部分也被用于为 spectr-ai 的分析引擎提供已知漏洞上下文。不过,独立版本只是一个周末项目,总共大概两百行代码。
这里真正的护城河是语料库,而且它会不断积累。现在,我每读完一份报告,都会把它丢进文件夹并建立索引。只需花费三十秒,就能永久获得一份可搜索的记忆,保存我研究过的每一个漏洞。
你的书签或下载文件夹里,有哪些内容一旦真正支持搜索,你每周都会去查询?
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。