跳转至

crates\nfarms-core\src\kb.rs

源码路径:crates\nfarms-core\src\kb.rs · 554 行

//! 本地知识库:把 `wiki\` 下的文档当作对话的参考资料。
//!
//! # 为什么是关键词检索,不是向量
//!
//! 项目里没有 embedding 模型,也不该为了这个功能去下一个(那是几百 MB 起步,
//! 而它只在桌宠对话里用)。所以用**关键词打分**:
//!
//! * 中文按 **2-gram** 切(不分词器也能用:把「知识库」切成 `知识`/`识库`);
//! * 英文/数字按非字母数字切,转小写;
//! * 每个词在每个文档里的**出现次数**加起来当分数。
//!
//! 这比向量差,但有几个好处:**零依赖、可解释、可测** —— 命中哪一段能直接
//! 看出来,而不是一个 0.83 的相似度。
//!
//! # 目录
//!
//! 读的是**程序根下的 `wiki\`**(`paths.wiki()`)。取不到的常见原因是
//! 源码里根本没有这个目录 —— 它由构建脚本创建,内容由用户自己放。
//! 所以 [`load`] 拿不到东西时返回空 `Vec`,**不是错误**:
//! 没有知识库,对话照样该能用。
//!
//! # 这个目录实测有多大
//!
//! `dist\wiki\` 下 200 多个文件、合计好几 MB,而且**有大量重复**
//! (同一份 `开发过程记录.md` / `CONVERSATION-LOG` 出现在多个深度)。
//! 所以有两条约束,都是量出来的而不是猜的:
//!
//! * [`MAX_FILE_BYTES`] —— 单篇超过 512 KB 整篇跳过(实测最大一篇 716 KB);
//! * [`context_from`] —— 文档由调用方**读一次留着**,不每条消息重读。

use std::path::Path;

/// 一篇文档。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Doc {
    /// 相对 `wiki\` 的路径,用来在提示里注明出处
    pub name: String,
    pub text: String,
}

/// 一条命中。
///
/// **没有 `Eq`** —— 里面有个 `f32`(`norm`),而浮点不满足全等。
/// `PartialEq` 够了:这个类型只用来排序和展示,不做键。
#[derive(Debug, Clone, PartialEq)]
pub struct Hit {
    pub name: String,
    /// 命中处附近的一小段原文
    pub snippet: String,
    /// 原始命中次数(各词次数之和,单词上限 [`MAX_TF`])。**人能看懂的量的**。
    pub score: u32,
    /// 按文档长度归一后的分数。**只用于排序,不对外展示** ——
    /// 它是 `score / sqrt(字符数/1000)`,报出去没人知道怎么读。
    norm: f32,
}

/// 单篇文档最多取多少字符进上下文。
///
/// 不是"越小越好":截太短会把关键句切掉。但也不能不截 ——
/// wiki 下有一份 23 KB 的记录,整篇塞进 32 K 上下文的模型里,
/// 一次就把预算吃掉四分之一。
const MAX_DOC_CHARS: usize = 2400;

/// 单个文件超过这个大就**整篇跳过**。
///
/// 不是洁癖:实测 `wiki\` 下有 716 KB 的单篇教程和 440 KB 的清单 ——
/// 它们进去只会把检索结果淹掉(几万个词命中同一篇),而且读一次要几十毫秒。
/// 512 KB 已经能装下任何"正常文档",超出的多半是导出/拼接产物。
const MAX_FILE_BYTES: u64 = 512 * 1024;

/// 递归读 `dir` 下的 `.md`/`.txt`。目录不存在或读不到都返回空 —— 见模块说明。
pub fn load(dir: &Path) -> Vec<Doc> {
    let mut out = Vec::new();
    walk(dir, dir, &mut out, 0);
    // 名字排序:同一份目录每次读出来的顺序不该变,
    // 否则同一句话两次检索出来的上下文不一样,排查时无从对比。
    out.sort_by(|a, b| a.name.cmp(&b.name));
    out
}

fn walk(root: &Path, dir: &Path, out: &mut Vec<Doc>, depth: u32) {
    // 深度上限:防止有人在 wiki 下放了个指向上级的软链接,把整块盘读进来
    if depth > 4 {
        return;
    }
    let Ok(rd) = std::fs::read_dir(dir) else {
        return;
    };
    for e in rd.flatten() {
        let p = e.path();
        if p.is_dir() {
            walk(root, &p, out, depth + 1);
            continue;
        }
        let ext = p
            .extension()
            .map(|x| x.to_string_lossy().to_ascii_lowercase())
            .unwrap_or_default();
        if ext != "md" && ext != "txt" {
            continue;
        }
        // 先看大小再读 —— 716 KB 那篇读进来再丢掉是白花的 I/O
        if e.metadata().map(|m| m.len()).unwrap_or(0) > MAX_FILE_BYTES {
            continue;
        }
        let Ok(text) = std::fs::read_to_string(&p) else {
            continue;
        };
        let name = p
            .strip_prefix(root)
            .unwrap_or(&p)
            .to_string_lossy()
            .replace('\\', "/");
        out.push(Doc { name, text });
    }
}

/// 把查询切成检索词。
///
/// 中文走 2-gram:**不分词器也能用**。代价是会有一些无意义的组合
/// (「的我」),但它们在任何文档里出现次数都差不多,排序时自然被摊平。
pub fn terms(q: &str) -> Vec<String> {
    let mut out = Vec::new();
    let mut cjk: Vec<char> = Vec::new();
    let mut word = String::new();

    fn flush_word(word: &mut String, out: &mut Vec<String>) {
        if word.chars().count() >= 2 {
            out.push(word.to_lowercase());
        }
        word.clear();
    }
    fn flush_cjk(cjk: &mut Vec<char>, out: &mut Vec<String>) {
        if cjk.len() >= 2 {
            for w in cjk.windows(2) {
                out.push(w.iter().collect());
            }
        }
        else if cjk.len() == 1 {
            out.push(cjk[0].to_string());
        }
        cjk.clear();
    }

    for ch in q.chars() {
        if is_cjk(ch) {
            flush_word(&mut word, &mut out);
            cjk.push(ch);
        }
        else if ch.is_alphanumeric() {
            flush_cjk(&mut cjk, &mut out);
            word.push(ch);
        }
        else {
            flush_word(&mut word, &mut out);
            flush_cjk(&mut cjk, &mut out);
        }
    }
    flush_word(&mut word, &mut out);
    flush_cjk(&mut cjk, &mut out);
    out
}

fn is_cjk(c: char) -> bool {
    matches!(c as u32, 0x4E00..=0x9FFF | 0x3400..=0x4DBF)
}

/// 检索。返回按分数降序的前 `k` 条。
pub fn search(docs: &[Doc], query: &str, k: usize) -> Vec<Hit> {
    let ts = terms(query);
    if ts.is_empty() || docs.is_empty() || k == 0 {
        return Vec::new();
    }
    let mut hits: Vec<Hit> = Vec::new();
    for d in docs {
        let lower = d.text.to_lowercase();
        let mut score = 0u32;
        let mut best_at = 0usize;
        let mut best_n = 0u32;
        // **文件名也参与检索** —— 理由见 [`NAME_BONUS`]
        let name_lower = d.name.to_lowercase();
        for t in &ts {
            if name_lower.contains(t.as_str()) {
                score = score.saturating_add(NAME_BONUS);
            }
            // 每个词只记一次出现位置,但**次数要全算** —— 出现 20 次的
            // 那个词比出现 1 次的更能说明这篇文档在讲它。
            let mut n = 0u32;
            let mut from = 0usize;
            while let Some(rel) = lower.get(from..).and_then(|s| s.find(t.as_str())) {
                let at = from + rel;
                n += 1;
                if n > best_n {
                    best_n = n;
                    best_at = at;
                }
                from = at + t.len().max(1);
                // 到上限就不用再数了,**顺便省掉长文档里的全文扫描**
                if n >= MAX_TF {
                    break;
                }
            }
            score = score.saturating_add(n.min(MAX_TF));
        }
        if score == 0 {
            continue;
        }
        hits.push(Hit {
            name: d.name.clone(),
            snippet: snippet(&d.text, best_at),
            score,
            // 长度归一后的分数**只用来排序**,不往外报 ——
            // 报出去的是原始命中次数,那是人能看懂的量
            norm: score as f32 / ((d.text.chars().count() as f32 / LEN_UNIT).max(1.0)).sqrt(),
        });
    }
    // 分数相同的按名字排:结果**可复现**,不然同一句话两次检索顺序不同
    hits.sort_by(|a, b| {
        b.norm
            .partial_cmp(&a.norm)
            .unwrap_or(std::cmp::Ordering::Equal)
            .then_with(|| b.score.cmp(&a.score))
            .then_with(|| a.name.cmp(&b.name))
    });
    hits.truncate(k);
    hits
}

/// 取命中位置附近的一段原文,并按字符边界截断。
///
/// 用 `chars()` 而不是字节切片 —— 中文文档里按字节切几乎必然切在
/// 多字节字符中间,那会 panic。
fn snippet(text: &str, at: usize) -> String {
    let at = at.min(text.len());
    let at = (0..=at)
        .rev()
        .find(|k| text.is_char_boundary(*k))
        .unwrap_or(0);
    let head: String = text[..at]
        .chars()
        .rev()
        .take(40)
        .collect::<String>()
        .chars()
        .rev()
        .collect();
    let tail: String = text[at..].chars().take(160).collect();
    format!("{head}{tail}").replace(['\n', '\r'], " ")
}

/// 把命中拼成给模型的上下文块。
///
/// 每段都写明**来自哪个文件** —— 模型据此可以说「按 wiki 里的记录」,
/// 而不是把内容当自己的记忆说出来。出处对排查很重要。
pub fn context_block(hits: &[Hit]) -> String {
    if hits.is_empty() {
        return String::new();
    }
    let mut s = String::from(
        "以下是本机 wiki 目录里与问题相关的片段,请优先依据它们回答;\
如果里面没有答案,就直说不知道,不要编。\n",
    );
    for h in hits {
        s.push_str(&format!("\n--- {} ---\n{}\n", h.name, h.snippet));
    }
    s
}

/// 单个检索词在一篇文档里最多计几次。
///
/// **这是 TF 饱和,不是省事。** 不设上限的话,一篇把某个词重复 500 次的
/// 长文档会压倒性地赢 —— 而"重复 500 次"和"重复 20 次"在相关性上
/// 几乎没有区别。实测:既有账本 244 KB,新写的 15 KB,不设上限时
/// 新文档完全浮不上来。
const MAX_TF: u32 = 5;

/// 检索词出现在**文件名**里时的加成(每个词)。
///
/// **文件名原来根本没参与检索** —— 于是"按名字问一份文档"反而找不到它。
/// 实测:本人刚写进 wiki 的 `变更账本-2026-10-14.md`,问「变更账本」
/// 命中不了自己。这很反直觉:用户说一个词,往往**就是那份文档的名字**。
///
/// 给固定加成而不是按次数:名字只有一份,出现一次就是"全中",
/// 没有"出现多次"这回事。
const NAME_BONUS: u32 = 20;

/// 长度归一的基准(字符)。分数会除以 `sqrt(字符数 / 这个数)`。
///
/// 理由和 [`MAX_TF`] 是同一个:**长文档不该因为字多就赢**。
/// 200 KB 的文档是 15 KB 的 13 倍,开方后仍差 3.6 倍 —— 只靠 TF 饱和
/// 还不够,得再按长度摊一次。
const LEN_UNIT: f32 = 1000.0;

/// 一次检索最多带几篇进上下文。
pub const TOP_K: usize = 3;

/// 给对话用的一步到位:读目录 → 检索 → 拼块。拿不到就返回空串。
pub fn context_for(dir: &Path, query: &str) -> String {
    context_from(&load(dir), query)
}

/// 和 [`context_for`] 一样,但**文档由调用方持有**。
///
/// 分开的理由是性能:`wiki\` 下有 200 多个文件、合计好几 MB。每条消息
/// 都重读一遍是几百毫秒的无谓 I/O,而知识库**几乎不变** ——
/// 所以调用方读一次、留着,每条消息只做检索。
pub fn context_from(docs: &[Doc], query: &str) -> String {
    if docs.is_empty() {
        return String::new();
    }
    let hits = search(docs, query, TOP_K);
    let mut block = context_block(&hits);
    // 总长度也要兜一下:3 篇 × 2400 字符对 32 K 上下文还好,
    // 但 MAX_DOC_CHARS 要是被人调大,这里能挡住。
    let cap = MAX_DOC_CHARS * TOP_K;
    if block.chars().count() > cap {
        block = block.chars().take(cap).collect();
    }
    block
}

#[cfg(test)]
mod tests {
    use super::*;

    fn doc(name: &str, text: &str) -> Doc {
        Doc {
            name: name.to_string(),
            text: text.to_string(),
        }
    }

    #[test]
    fn cjk_is_split_into_bigrams_and_ascii_into_words() {
        let t = terms("本地知识库 llama.cpp");
        assert!(t.contains(&"本地".to_string()), "实际: {t:?}");
        assert!(t.contains(&"知识".to_string()), "实际: {t:?}");
        assert!(t.contains(&"识库".to_string()), "实际: {t:?}");
        assert!(t.contains(&"llama".to_string()), "实际: {t:?}");
        assert!(t.contains(&"cpp".to_string()), "实际: {t:?}");
        // 单字母不该成为检索词(噪音太大)
        assert!(!t.iter().any(|x| x == "c"), "实际: {t:?}");
    }

    #[test]
    fn search_finds_the_document_that_talks_about_it() {
        let docs = vec![
            doc("a.md", "这篇讲的是天气和温度"),
            doc("b.md", "这篇讲的是本地知识库怎么接进对话"),
        ];
        let hits = search(&docs, "知识库怎么用", 3);
        assert_eq!(hits.len(), 1, "只有 b 该命中: {hits:?}");
        assert_eq!(hits[0].name, "b.md");
    }

    /// 出现次数多的文档应当排在前面 —— 这是打分的核心假设,
    /// 退化成"命中就同分"的话排序就没意义了。
    #[test]
    fn more_occurrences_rank_higher() {
        let docs = vec![
            doc("once.md", "油价 其他别的内容"),
            doc("many.md", "油价 油价 油价 还有别的"),
        ];
        let hits = search(&docs, "油价", 3);
        assert_eq!(hits.len(), 2);
        assert_eq!(hits[0].name, "many.md", "实际: {hits:?}");
        assert!(hits[0].score > hits[1].score);
    }

    #[test]
    fn no_query_or_no_docs_yields_nothing() {
        let docs = vec![doc("a.md", "内容")];
        assert!(search(&docs, "", 3).is_empty());
        assert!(search(&docs, "   ", 3).is_empty());
        assert!(search(&[], "内容", 3).is_empty());
        // k = 0 也要能挡住,别 panic
        assert!(search(&docs, "内容", 0).is_empty());
    }

    /// 中文文档里取片段**不能 panic** —— 按字节切会切在字符中间。
    #[test]
    fn snippet_does_not_panic_on_multibyte_text() {
        let docs = vec![doc(
            "中文.md",
            "这是一段很长的中文内容用来测试片段提取不会崩掉真的不会崩",
        )];
        let hits = search(&docs, "中文内容", 3);
        assert!(!hits.is_empty());
        assert!(hits[0].snippet.contains("中文"));
    }

    #[test]
    fn context_block_names_each_source() {
        let hits = vec![Hit {
            name: "设计.md".to_string(),
            snippet: "某段内容".to_string(),
            score: 1,
            norm: 1.0,
        }];
        let b = context_block(&hits);
        assert!(b.contains("设计.md"), "出处必须写进去: {b}");
        assert!(b.contains("不要编"), "要明确禁止编造: {b}");
        assert!(context_block(&[]).is_empty());
    }

    /// 目录不存在 = **没有知识库**,不是错误。对话照样要能用。
    #[test]
    fn a_missing_directory_is_not_an_error() {
        let d = std::env::temp_dir().join("nfarms-kb-肯定不存在");
        assert!(load(&d).is_empty());
        assert!(context_for(&d, "随便问").is_empty());
        assert!(context_from(&[], "随便问").is_empty());
    }

    /// 真的读一次磁盘:临时目录放两份 .md,应当都能读到。
    #[test]
    fn loads_markdown_from_a_real_directory() {
        let d = std::env::temp_dir().join(format!("nfarms-kb-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&d);
        std::fs::create_dir_all(d.join("sub")).unwrap();
        std::fs::write(d.join("一.md"), "关于端口统一管理的记录").unwrap();
        std::fs::write(d.join("sub/二.md"), "关于桌宠透明背景的记录").unwrap();
        // 非 md/txt 不该被读
        std::fs::write(d.join("跳过.png"), "不是文本").unwrap();

        let docs = load(&d);
        assert_eq!(
            docs.len(),
            2,
            "实际: {:?}",
            docs.iter().map(|x| &x.name).collect::<Vec<_>>()
        );
        assert!(docs.iter().any(|x| x.name == "sub/二.md"), "子目录也要读");

        let ctx = context_for(&d, "透明背景怎么做的");
        assert!(ctx.contains("二.md"), "应当命中讲透明背景那篇: {ctx}");
        let _ = std::fs::remove_dir_all(&d);
    }

/// **对真实 `dist\wiki` 跑一次** —— 手动执行。
    ///
    /// 单元测试用的是临时目录里造的几份小文档,验的是**逻辑**;
    /// 这一条验的是**真实语料**:文件数、跳过的大文件、以及
    /// 「问一个这一轮刚写进 wiki 的问题,能不能命中对应文档」。
    ///
    ///     cargo test -p nfarms-core kb::tests::probe_real_wiki -- --ignored --nocapture
    ///
    /// 为什么值得单独留一条:`wiki\` 有几个 GB 的潜力(它是用户自己放的),
    /// 而"读到了但检索不出来"和"根本没读到"在界面上长得一模一样 ——
    /// **都只是没有知识库片段**。
    /// **按文件名问,要能找到那份文档。**
    ///
    /// 原来文件名不参与检索,于是 `变更账本-2026-10-14.md` 被问「变更账本」
    /// 时命中不了自己 —— 而用户说一个词,往往**就是那份文档的名字**。
    #[test]
    fn a_documents_own_name_finds_it() {
        let docs = vec![
            doc("变更账本-2026-10-14.md", "这一轮改了打印代理页和知识库"),
            doc("别的.md", "变更 变更 变更 变更 变更 变更 变更 变更"),
        ];
        let hits = search(&docs, "变更账本", 3);
        assert_eq!(hits[0].name, "变更账本-2026-10-14.md", "实际: {hits:?}");
    }

    /// **长文档不该因为字多就赢。** 同样的命中次数,短的那篇该排前面。
    ///
    /// 这条是拿真实语料量出来的:`dist\wiki` 里既有 244 KB 的账本,
    /// 也有 15 KB 的 —— 不做长度归一,新的那份**永远浮不上来**。
    #[test]
    fn a_short_document_beats_a_long_one_with_the_same_hits() {
        let mut long = String::from("桌宠透明背景");
        long.push_str(&"无关内容".repeat(20000));
        let docs = vec![
            doc("短.md", "桌宠透明背景就是这么做出来的"),
            doc("长.md", &long),
        ];
        let hits = search(&docs, "桌宠透明背景", 3);
        assert_eq!(hits[0].name, "短.md", "实际: {hits:?}");
    }

    #[test]
    #[ignore = "读真实 dist\\wiki,手动跑"]
    fn probe_real_wiki() {
        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .join("..")
            .join("..")
            .join("dist")
            .join("wiki");
        if !dir.exists() {
            println!("[跳过] {} 不存在", dir.display());
            return;
        }
        let t0 = std::time::Instant::now();
        let docs = load(&dir);
        let took = t0.elapsed();
        let chars: usize = docs.iter().map(|d| d.text.chars().count()).sum();
        println!(
            "读到 {} 篇,合计 {} 字符({} KB),用时 {:?}",
            docs.len(),
            chars,
            chars / 1024,
            took
        );
        // 512 KB 上限挡掉了哪些,值得看一眼 —— 它们是真的读不到
        let skipped: Vec<String> = std::fs::read_dir(&dir)
            .into_iter()
            .flatten()
            .flatten()
            .filter(|e| e.path().is_dir())
            .flat_map(|e| std::fs::read_dir(e.path()).into_iter().flatten().flatten())
            .filter(|e| e.metadata().map(|m| m.len()).unwrap_or(0) > MAX_FILE_BYTES)
            .map(|e| e.file_name().to_string_lossy().to_string())
            .collect();
        println!("跳过(超过 {} KB)的文件: {:?}", MAX_FILE_BYTES / 1024, skipped);

        // **这一轮刚写进去的文档,问得出来吗**
        //
        // 最后一问是**知识库新鲜度探针**:`分片累积解码` 这个词只出现在移动端
        // 那一轮的代码注释与开发记录里。只要它命中,就说明 `dist\wiki\code`
        // 已经被重新生成过;如果它没命中而那几份老文档都命中,则说明知识库是旧的 ——
        // 这种"能读、但读的是旧的那一版"在界面上和"一切正常"完全一样。
        for q in [
            "打印代理页为什么重复",
            "桌宠透明背景怎么做的",
            "变更账本",
            "分片累积解码",
        ] {
            let block = context_from(&docs, q);
            let first = block
                .lines()
                .find(|l| l.starts_with("--- "))
                .unwrap_or("(没有命中)");
            println!("问「{q}」→ 首条命中: {first}");
        }
    }
    /// **单篇过大的要整篇跳过。** 实测 wiki 下有 716 KB 的单篇教程 ——
    /// 它进去只会把检索结果淹掉,而读一次要几十毫秒。
    #[test]
    fn an_oversized_file_is_skipped_whole() {
        let d = std::env::temp_dir().join(format!("nfarms-kb-big-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&d);
        std::fs::create_dir_all(&d).unwrap();
        std::fs::write(d.join("小.md"), "正常内容").unwrap();
        // 造一个刚过线的:512 KB + 1 字节
        let big = "x".repeat((MAX_FILE_BYTES + 1) as usize);
        std::fs::write(d.join("巨大.md"), big).unwrap();

        let docs = load(&d);
        assert_eq!(docs.len(), 1, "超限的那篇不该进来");
        assert_eq!(docs[0].name, "小.md");
        let _ = std::fs::remove_dir_all(&d);
    }
}