Lume工作原理:检索原语

Hacker News Top 工具

摘要

深入技术探讨Lume,一个Rust混合搜索引擎,结合BM25、稠密向量和实体图,实现可审计的本地优先检索。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/06/23 07:41

# Lume 的工作原理:检索原语 — 信号日志 来源:https://deepbluedynamics.com/blog/lume-retrieval-primitives Lume 检索流水线 — BM25、密集向量和实体图 Lume(https://github.com/DeepBlueDynamics/lume)是一个用 Rust 编写的混合搜索引擎,由 Steve Harris(https://github.com/jsclosures)和我在 `github.com/DeepBlueDynamics/lume` 开源构建。它是一个小型 CLI 加一个 MCP 服务器,采用 BSD-3 许可证,其设计围绕一个固执的理念:当智能体提出问题时,从查询到证据的每一步都应该是可检查的。Lume 索引 Markdown、源代码和 PDF(通过一个小型 Python 提取器),并使用三种独立的原语对它们进行排序 — 字段感知的 BM25、通过 Shivvr(https://shivvr.nuts.services/)提供的密集 GTR-T5 向量,以及一个基于显著性评分的实体图。词法核心和图形完全在本地机器上运行;只有密集向量需要调用外部服务,并且该端点的默认值为 `localhost`。没有不透明的“返回排序结果的搜索框” — 每个分数都有一个名称、一个文件和一个调节旋钮。本文自上而下地介绍 Lume 的检索核心,并引用当前代码库中的具体行号。如果你正在构建智能体系统,并且厌倦了将检索视为一个黑箱步骤,那么这篇文章就是为你准备的。 首先阐述几个原则,因为它们解释了设计思路: - **本地优先。** 词法搜索和实体图完全在本地机器上运行。密集向量通过 `SHIVVR_BASE_URL` 从 Shivvr 获取,该 URL 默认指向本地端点。 - **分层,而非单体。** BM25、语义和图形是独立的信号,各有各的分数。混合是一行代码;每个输入都是可替换的。 - **可审计。** 引擎会打印它剪枝了什么、排序了什么以及为什么拒绝了其余部分。 ## 0. 检索单元:Section Lume 索引 Markdown,并在 `#` 标题处(`src/bm25.rs:211` 中的 `parse_markdown`)将其切割成 **sections**。一个 `Section`(`src/bm25.rs:106`)是所有排序操作的基本原子: ``` pub struct Section { pub title: String, pub body: String, pub line_number: usize, pub filename: Option<String>, pub entities: Vec<String>, // 解析出的命名实体,用于图形 } ``` 标题和正文是 **分开的字段**,具有独立的统计数据 — 这种区分会立即体现在评分中。整个索引作为 `Bm25Index`(`src/bm25.rs:147`)存储在内存中:包括每个字段的词频映射、文档频率、字段长度、**roaring位图倒排索引**、素数/哥德尔签名过滤器,以及为图形提供输入的实体倒排索引。 ## 1. 原语:字段感知的 BM25 词法核心是一个字段感知的 BM25,具有三种可选的变体。调优默认值(`src/bm25.rs:125` 中的 `Bm25Params`)故意采用经典配置: ``` Self { k1: 1.2, b: 0.75, delta: 1.0, title_weight: 2.0, body_weight: 1.0 } ``` `k1` 控制词频饱和度;`b` 控制长度归一化。唯一有主见的选择是 **`title_weight: 2.0`**:在应用协调因子之前,标题中的命中贡献是正文命中的两倍。这很有用,但当查询词条过于宽泛时,可能会过度加权章节标题。将其视为一个可调节的旋钮,而非固定规则。 IDF 采用标准的平滑形式,下限为零,每个词条对每个字段的贡献分别计算,然后与字段权重求和(`src/bm25.rs:728` 中的 `calculate_bm25_term_score`): ``` let len_normalization = 1.0 - b + b * (doc_len / avgdl); match variant { SearchVariant::Classic => idf * (tf * (k1 + 1.0)) / (tf + k1 * len_normalization), SearchVariant::Plus => idf * ((tf*(k1+1.0))/(tf + k1*len_normalization) + params.delta), SearchVariant::L => { let s = tf / len_normalization; idf * (s*(k1+1.0))/(s + k1) }, } // total_score += title_weight * title_score + body_weight * body_score; (src/bm25.rs:635) ``` - **Classic** 是纯正的 BM25。 - **Plus** 添加了一个 `delta` 下限,使得匹配的词条永远不会贡献 *零*,从而抵消了 BM25 对长文档的过度惩罚。 - **L** 将长度归一化置于饱和度函数内部,从而平滑了超长文档的评分。 Lume 默认运行 `Classic` 变体(`src/main.rs:1430`)。 ## 2. 两阶段剪枝:roaring取并集,然后哥德尔签名 你不想为每个查询都对一本书的所有 1926 个 sections 进行 BM25 评分。Lume 的 `search`(`src/bm25.rs:445`)是 **两阶段** 的。 **阶段 1 — 候选收集。** 对查询词条的 roaring位图倒排索引进行并集操作。这只需要进行一小部分位集 OR 运算,并能立即将语料库缩小到包含 *任一* 查询词条的 sections: ``` // src/bm25.rs:460 let mut candidate_set = MiniRoaring::new(); let mut first = true; for q_tok in &query_tokens { if let Some(list) = self.posting_lists.get(&q_tok.bytes) { if first { candidate_set = list.clone(); first = false; } else { candidate_set = candidate_set.union(list); } } } ``` **阶段 1b — 哥德尔标签签名剪枝。** 如果查询标签识别器识别出实体,则每个候选 section 都会通过一个 **素数分解签名过滤器**(`src/fast_retrieval.rs:449` 中的 `PrimeFilter::test_tag_prime`,在 `src/bm25.rs:538` 中评估)进行验证。每个已知的标签输出都映射到一个素数;一个 section 的标签签名是其标签素数的乘积,因此通过可整除性检查来确认包含关系。未知的查询标签故意接收一个虚拟素数,并导致检查失败。在进入更重的评分阶段之前,失败的候选者会被标记为 `TagSignatureMismatch` 并丢弃。 **阶段 2 — 重度评分** 仅对幸存者运行。并且引擎会 *告诉你* 漏斗的形状(stderr,`src/bm25.rs:557`): ``` [两阶段剪枝] 将候选空间从 1926 剪枝到 302 个 sections(roaring生成:609 个),耗时 54.70μs 候选:609 已排序:302 已拒绝:TagSignatureMismatch:307 ... ``` 这种统计不是装饰 — 当查询返回错误结果时,它是你首先阅读的信息。 ## 3. 查询卫生:停用词和协调因子 两个小原语对质量有巨大影响。 **查询端停用词过滤**(`src/bm25.rs:98` 中的 `filter_query_stopwords`)。函数词和疑问词仅从 **查询中** 剥离,绝不会从索引中移除。如果没有它,查询 "how does Dantès know Mercédès" 的主要匹配项将会是 *how/does/know*,这些词会匹配到不相关的章节(比如一个章节的标题恰好是 *"How a Gardener..."*)。安全网:如果 *每个* 词条都是停用词(例如 "how are you"),则会保留原始词条,这样你仍然能得到结果。 **协调因子**(`src/bm25.rs:638`)。匹配更多 *不同* 查询词条的文档应该胜过重复单个常见词条的文档。Lume 通过一个基于覆盖率的因子来乘以分数: ``` let coverage = matched_terms.len() as f64 / num_distinct as f64; let coord = COORD_FLOOR + (1.0 - COORD_FLOOR) * coverage; // COORD_FLOOR = 0.5 total_score *= coord; ``` 因此,匹配所有三个查询词条的 section 保留 100% 的分数;而仅匹配三个词条中的一个的 section 大约保留 2/3。对于单词查询,`coverage == 1.0`,所以普通查找不受影响。这是一种温和的推动,而非硬性的 AND 约束。 ## 4. 原语:密集向量(本地 GTR-T5) 词法搜索无法弥合词汇鸿沟 — 例如 "starved to death" 与 "gastroenteritis"。这就是语义原语的任务。Lume 通过 **Shivvr** 将文本嵌入到 **768维的 GTR-T5** 向量中。默认的基础 URL 是 `http://localhost:8085`(`src/hybrid.rs:777`),并且请求仍然需要服务令牌(`src/hybrid.rs:784`)。没有专用的嵌入端点,因此 `embed_text`(`src/hybrid.rs:43`)将文本摄入到一个一次性的临时存储中,并直接从响应中读取向量: ``` // 768维 GTR-T5("organize")向量,在返回时断言: if emb.len() != 768 { return Err(format!("期望 768 维 GTR-T5 向量,得到 {}", emb.len())); } ``` 在索引时,sections 被推入一个 **语义会话**(`src/hybrid.rs:581` 中的 `ensure_semantic_session`)。巧妙之处在于增量更新:每个 section 都有一个内容哈希(`src/hybrid.rs:407` 中的 `section_hash`),用作远程块 ID,并且 *故意排除行号* — 因此 **移动** 一个 section 不会强制重新嵌入。重新索引时通过哈希进行差异比较,仅更新发生更改的部分;如果语料库指纹匹配,则无需任何操作。查询结果也会被缓存(`.lume-semantic-cache.json`)。 在查询时,`query_semantic_search`(`src/hybrid.rs:637`)要求 Shivvr 返回 `n=60` 个邻居。如果索引在构建时没有包含语义向量,或者令牌缺失,搜索会优雅地降级为纯词法 BM25,并且 *明确告知*:使用 `alpha > 0` 参数请求语义搜索但索引仅支持词法时,会被告知当前运行的是纯词法模式(`src/main.rs:1389`)。 ## 5. 原语:语义知识图(显著性,而非共现计数) 第三个信号是结构性的。Lume 通过成对的 roaring位集交集构建一个 **实体共现图** — 即“计算事物出现的次数”(`src/graph_search.rs:1`)。单独来看,这是一个只导出的输出;`graph_search` 将其转化为一个 **查询时的排序信号**,完全在本地运行。 微妙且重要的是 **边的加权方式**。原始的共现计数甚至 Jaccard 系数都会奖励 *高连接度的枢纽节点* — 一个到处出现的实体与所有事物共现。Lume 默认使用一个 **显著性** 分数(`src/semantic_mesh.rs:558` 中的 `cooccurrence_relatedness`):在 `n` 个文档中,A 出现在 `a` 个中,B 出现在 `b` 个中,两者同时出现在 `k` 个中;将观察到的 `k` 与独立性期望值 `E = a·b/n` 进行比较,计算 z 分数,**对数压缩** 以保留动态范围,然后使用 `tanh` 进行压缩: ``` let expected = a * b / n; let variance = expected * (1.0 - a/n) * (1.0 - b/n); let z = (k - expected) / variance.sqrt(); let compressed = z.signum() * (1.0 + z.abs()).ln(); // 保持 z=10 和 z=100 的区分度 (compressed / 3.0).tanh() // -> [-1, 1]; 负值表示回避 ``` 结果位于 `[-1, 1]` 范围内:正值表示真实的关联,**负值表示回避**(实体共现 *低于* 随机水平)。这是 Trey Grainger 的前景与背景 SKG 相关度计算方法在成对情况下的精简实现。一个回归测试锁定了其行为:一个真实的配对(edmond–dantès)胜过一个高连接度的枢纽节点,即使该枢纽节点具有完美的 Jaccard 重叠度(`src/graph_search.rs:305`)。你仍然可以使用 `--scoring jaccard` 选择传统的 Jaccard 方法;边的 `weight()`(`src/semantic_mesh.rs:532`)会根据选择进行调整,并将负的显著性值钳位到 `0`,这样回避永远不会 *提升* 分数。 图遍历本身(`src/graph_search.rs:154` 中的 `compute_skg_scores`): 1. **解析** 查询的实体,通过滑动 n-gram 并以最长优先匹配(因此 "edmond dantes" 能匹配存储的 "edmond dantès"),`max_ngram = 4`。 2. **播种** 这些实体,权重为 `1.0`;**向外走一步** 到最强的 `k`(8)个邻居,每条边携带 `decay * weight`(`decay = 0.5`),并取跨播种实体的 **最大值** — 因此一个共享的枢纽邻居不能被累加成为主导。 3. **累积** 每个 section 的图质量分数,来自实体倒排索引,并 **归一化到 `[0, 1]`**。 ## 6. 混合:一行乘法运算 三种信号 — 词法、语义、图 — 在 `blend_hybrid_scores`(`src/hybrid.rs:673`)中融合。默认方式是 **乘法**,因此词法匹配为主导,另外两个信号 *提升* 它: ``` hybrid = bm25 * (1.0 + alpha * semantic + beta * skg); ``` 这可以将强词法匹配保持在顶部,同时让语义和图信号提升它们。召回扩展来自两个路径:纯语义命中的文档被允许进入,但 `bm25_score = 0`;仅通过 SKG 匹配的 sections 只有在归一化图质量达到 `SKG_EXPAND_MIN = 0.5`(`src/graph_search.rs:22`)时才会被接纳。在纯词法路径中,这些仅靠 SKG 的 sections 的得分为 `beta * skg`,低于可比的词法匹配(`src/graph_search.rs:218` 中的 `apply_skg_boost`)。设置 `beta = 0` 即可移除图信号项。 还有一种替代模式,用于当你希望语义/图能够 *超越* 词法时,由 `LUME_BLEND_NORM=1` 启用,该模式将所有三个信号置于可比的 `[0,1]` 尺度上(`src/hybrid.rs:745`): ``` // src/hybrid.rs:745 let hybrid_score = if normalize { (bm25_score / bm25_max) + alpha * sem_score + beta * skg_score } else if bm25_score > 0.0 { bm25_score * (1.0 + alpha * sem_score + beta * skg_score) } else { sem_score + beta * skg_score // 纯语义召回扩展的回退 }; ``` ## 7. 调优界面:每个旋钮的实际作用 以上所有内容都暴露在 `lume search` 命令中(`src/main.rs:1295` 中的 `handle_search`)。以下是实用指南: | 旋钮 | 标志 / 环境变量 | 默认值 | 作用 | |---|---|---|---| | 词法 ↔ 语义 | `-a, --alpha` / `ALPHA` | `0.5` | `0.0` = 纯 BM25;值越高,GTR-T5 项的权重越大。当答案使用的词语与查询不同时,提高此值。 | | 图信号提升 | `-g, --graph` / `GRAPH_ALPHA` | `0.4` | SKG 项的权重。提高以拉取实体相关的 sections;`0` 禁用图信号(纯词法+语义)。 | | 边评分 | `--scoring` | `relatedness` | `relatedness`(显著性,抵抗枢纽节点) vs `jaccard`(原始重叠度)。除非你在重现旧数字,否则使用显著性。 | | 拼写纠正 | `-c, --spell-check` | 关闭 | 在搜索之前,针对索引词汇表纠正每个查询词(`src/main.rs:1273` 中的 `correct_query`)。 | | BM25 长度/饱和度 | `Bm25Params` | `k1=1.2, b=0.75` | 对于 section 长度差异极大的语料库,降低 `b`;提高 `k1` 以更大幅度地奖励重复词条。 | | 字段权重 | `title_weight` | `2.0` | 标题命中相对于正文命中的权重。如果章节/节标题劫持了结果,请降低此值。 | | 查询反转(调试) | `LUME_QUERY_INVERSION=1` | 关闭 | 将查询的 GTR-T5 向量往返转换回文本(`src/inversion.rs:30` 中的 `invert_vector`),以便检查嵌入。需要额外的嵌入和反转请求,不影响排序。 | 反转技巧的一个工作示例:如果语义搜索一直找不到结果,设置 `LUME_QUERY_INVERSION=1`,然后读取你的查询 *反向翻译* 回什么。如果 "how does the prisoner escape" 反向翻译成关于花园的内容,说明你的嵌入没有锚定在 "escape" 上 — 需要重新措辞,或者使用更低的 `alpha` 来依靠词法搜索。 ## 8. 案例研究:导致智能体混淆的检索错误 为了理解这些原语如何相互作用,考虑一个在测试期间对 *基督山伯爵* 全文运行的真实查询: **“How does Edmond Dantès’s father die?”**(爱德蒙·邓蒂斯的父亲是怎么死的?) 在 Lume 的初次运行中,回答智能体返回了:*“提供的段落中不包含关于爱德蒙·邓蒂斯父亲如何去世的信息。”* 然而,死亡细节在语料库中有完整描述。我们将这个失败追溯到查询和检索参数边界上的三个复合问题: 1. **专有名词偏差。** 查询规划器生成了像 `"Dantès father death"` 这样的关键字查询。在第 26 章中,父亲被称为“老人”或“老邓蒂斯”,他的死亡描述中没有出现“死亡”这个词(*“饿死了”*)。同时,字面章节标题 *“父与子”* 以及对 `Dantès` 的宽泛实体匹配将检索结果拉向父亲还在世时的段落,将目标场景推离了证据窗口。 2. **硬编码的检索深度。** 提供给 LLM 的段落数量(`n_feed`)被硬编码为 10。当目标段落因专有名词偏差而在排序中被推后时,它在模型能够评估之前就被截断了。 3. **上下文压力。** 本地模型调用没有显式设置 `num_ctx`,因此包含多个段落的提示词可能会被模型运行时截断。 ### 修复方法 我们通过调整原语的边界来修正这一行为: - **查询多样化。** 我们更新了规划器(`src/answer.rs:49` 中的 `plan_queries`),使其生成多个查询角度:一个针对显式实体,一个使用事件同义词(例如 `"died of starvation grief"`),另一个使用次要角色或细节。 - **动态反馈深度。** 我们根据请求的候选数量动态扩展 `n_feed`:在 `src/main.rs:1797` 中,使用 `candidates.clamp(10, 20)`。 - **显式的上下文界限。** 我们在 `ollama_chat`(`src/answer.rs:21`)中显式设置了 `"num_ctx": 16384`,并将片段大小扩大到 180 个词。 通过这些更改,规划器的查询 `"died of starvation grief"` 成功将死亡场景展示在检索结果集中。智能体现

相似文章

我们尝试了向量、抽象语法树(AST)以及粗暴地堆砌上下文以进行代码检索。带有大语言模型(LLM)生成语义的图结构效果最佳。以下是我们的经验总结。

Reddit r/LocalLLaMA

作者们详细描述了他们在构建代码索引系统的经验,最终得出结论:使用大语言模型(LLM)生成语义的图检索方式在性能上优于向量嵌入和纯抽象语法树(AST)解析。他们将该系统开源,命名为 Bytebell,它利用 Neo4j 存储语义上下文,以实现高效且精确的代码检索。