Локальный семантический поиск файлов на Rust: аналог Windows Recall | AiManual
AiManual Logo Ai / Manual.
17 Фев 2026 Инструмент

Как собрать локальный семантический поиск по файлам на Rust: туториал по созданию приватного аналога Windows Recall

Пошаговый туториал по созданию локального семантического поиска по файлам на Rust и Tauri. Приватная альтернатива Windows Recall без облака.

Windows Recall шпионит? Пора сделать свой поиск

Помните тот скандал, когда выяснилось, что Windows Recall тихонько скриншотит ваш экран и отправляет куда-то в облако? Даже если нет - суть ясна: доверять свои файлы кому попало - плохая идея. Особенно если эти файлы - ваши заметки, код, документы и прочий цифровой хлам, который иногда нужно найти не по имени, а по смыслу.

Вот и я подумал: а что если собрать свой приватный поиск, который работает только на моем компьютере, не требует интернета и понимает, что я имею в виду? Не "найди файл с названием отчет", а "найди тот документ, где мы обсуждали интеграцию с API в прошлом месяце". Звучит как магия, но на самом деле это просто эмбеддинги и косинусная близость. И Rust.

На момент 17 февраля 2026 года, самые эффективные модели для эмбеддингов текста - это дообученные версии E5-v3 и BGE-M3. Но для локального запуска на слабом железе лучше взять квантованную all-MiniLM-L12-v2 - она до сих пор отлично работает и весит всего 90 МБ.

1 Готовим инструменты: Rust, Tauri и модель

Первое - ставим Rust. Если у вас его нет, запускаете в терминале curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh и ждете. Никаких волшебных кнопок, только командная строка.

Дальше - Tauri. Это фреймворк для создания десктопных приложений на Rust с интерфейсом на веб-технологиях. Устанавливаем его глобально: cargo install tauri-cli. Пока грузится, можно почитать про Tandem - там похожий подход, но для другого.

Теперь самая важная часть - модель для эмбеддингов. Мы будем использовать квантованную версию all-MiniLM-L12-v2 через библиотеку Candle. Почему? Потому что она работает быстрее чем черепаха и не жрет всю оперативку. Добавляем в Cargo.toml:

[dependencies]
tauri = { version = "2.0", features = ["shell-open"] }
candle-core = "0.4"
candle-transformers = "0.4"
tokio = { version = "1.0", features = ["full"] }
walkdir = "2.5"
serde_json = "1.0"
anyhow = "1.0"

Модель качаем заранее и кладем в папку assets. Или используем candle-transformers для загрузки на лету - но тогда первый запуск будет долгим. Я предпочитаю качать сразу.

2 Пишем код: от файла до вектора

Создаем новое Tauri-приложение: cargo tauri init. Выбираем простой шаблон, без изысков.

Теперь нужно написать функцию, которая берет текстовый файл и превращает его в вектор. Вот как это выглядит (упрощенно):

use candle_core::{Device, Tensor};
use candle_transformers::models::bert::{BertModel, Config, DTYPE};
use tokenizers::Tokenizer;

async fn text_to_embedding(text: &str) -> anyhow::Result> {
    let device = Device::Cpu;
    let config = Config::mini_lm_l12_v2();
    let model = BertModel::load(&config, "assets/model.safetensors", &device)?;
    let tokenizer = Tokenizer::from_file("assets/tokenizer.json").unwrap();
    
    let encoding = tokenizer.encode(text, true).unwrap();
    let token_ids = Tensor::new(encoding.get_ids(), &device)?;
    let attention_mask = Tensor::new(encoding.get_attention_mask(), &device)?;
    
    let output = model.forward(&token_ids, &attention_mask)?;
    let embedding = output.last_hidden_state.mean(1)?;
    
    Ok(embedding.to_vec1::()?)
}

Этот код загружает модель, токенизирует текст, пропускает через BERT и усредняет выходы последнего слоя. Получается вектор из 384 чисел - это и есть эмбеддинг. Если хотите понять, как это работает под капотом, посмотрите статью про запуск BERT в браузере на Rust - там те же принципы.

💡
Не пытайтесь обрабатывать гигабайтные файлы целиком. Разбивайте на чанки по 512 токенов. Иначе модель просто сломается. Проверено на горьком опыте.

3 Индексация: обходим файлы и строим базу векторов

Теперь нужно просканировать папку с документами. Используем walkdir для рекурсивного обхода. Для каждого файла (пока только .txt, .md, .rs - но можно добавить парсеры для PDF и DOCX) читаем содержимое, генерируем эмбеддинг и сохраняем в SQLite.

use walkdir::WalkDir;
use rusqlite::{Connection, params};

async fn index_folder(path: &str, conn: &Connection) -> anyhow::Result {
    let mut count = 0;
    for entry in WalkDir::new(path).into_iter().filter_map(|e| e.ok()) {
        if entry.file_type().is_file() {
            let ext = entry.path().extension().and_then(|s| s.to_str());
            if let Some("txt" | "md" | "rs") = ext {
                let content = std::fs::read_to_string(entry.path())?;
                let embedding = text_to_embedding(&content).await?;
                
                conn.execute(
                    "INSERT INTO files (path, content, embedding) VALUES (?, ?, ?)",
                    params![
                        entry.path().to_string_lossy(),
                        content,
                        serde_json::to_string(&embedding)?
                    ],
                )?;
                count += 1;
            }
        }
    }
    Ok(count)
}

В базе создаем таблицу с полями: id, path, content, embedding. Эмбеддинг храним как JSON-массив. Да, это не оптимально, но для тысячи файлов сойдет. Если нужно больше - смотрите в сторону специализированных векторных баз, но для локального поиска по личным файлам SQLite - идеальный вариант. Кстати, про векторный поиск для базы знаний есть отдельная статья.

4 Поиск: от запроса до результата

Пользователь вводит запрос. Мы превращаем его в эмбеддинг точно так же, как и файлы. Потом вычисляем косинусную близость между запросом и всеми эмбеддингами в базе. Самые близкие - самые релевантные.

fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
    let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
    let norm_a: f32 = a.iter().map(|x| x * x).sum::().sqrt();
    let norm_b: f32 = b.iter().map(|x| x * x).sum::().sqrt();
    dot / (norm_a * norm_b)
}

async fn search(query: &str, conn: &Connection, limit: i64) -> anyhow::Result> {
    let query_embedding = text_to_embedding(query).await?;
    let mut stmt = conn.prepare("SELECT path, embedding FROM files")?;
    let rows = stmt.query_map([], |row| {
        let path: String = row.get(0)?;
        let embedding_json: String = row.get(1)?;
        Ok((path, embedding_json))
    })?;
    
    let mut results = Vec::new();
    for row in rows {
        let (path, embedding_json) = row?;
        let file_embedding: Vec = serde_json::from_str(&embedding_json)?;
        let similarity = cosine_similarity(&query_embedding, &file_embedding);
        results.push((path, similarity));
    }
    
    results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
    results.truncate(limit as usize);
    Ok(results)
}

Да, это наивный поиск по всем векторам. Для 10 тысяч файлов он будет работать медленно. Но для личного использования с парой тысяч документов - вполне сносно. Если нужно быстрее, добавьте индекс HNSW через библиотеку hnsw-rs. Или посмотрите на Ragex, где семантический поиск по коду сделан через графы.

Чем этот подход лучше облачных аналогов?

Критерий Наш Rust-поиск Windows Recall Облачные RAG-сервисы
Приватность 100% локально, данные никуда не уходят Отправляет в облако Microsoft Зависит от провайдера, обычно данные на их серверах
Скорость Мгновенно после индексации, нет сетевых задержек Зависит от интернета и серверов Microsoft Задержки от 100 мс до нескольких секунд
Стоимость Бесплатно (кроме электричества) Включено в Windows, но вы платите данными От $10/месяц за API-вызовы
Кастомизация Можете поменять любую часть кода Закрытая система, настройки минимальны Ограничено API и настройками провайдера

Главное преимущество - контроль. Вы решаете, какие файлы индексировать, где хранить базу, как часто обновлять индекс. Никаких внезапных обновлений, которые ломают функциональность. Никаких соглашений о конфиденциальности, которые вы не читали.

Где это можно использовать?

  • Поиск в заметках. У меня их несколько тысяч в Markdown. Обычный текстовый поиск не справляется, когда нужно найти "ту идею про кэширование". Семантический - находит сразу. Похожий подход используется в Yttri.
  • Поиск в архиве писем. Экспортируйте почту в текстовые файлы и индексируйте. Намного эффективнее, чем встроенный поиск в почтовике.
  • Поиск по кодовой базе. Особенно если нужно найти не конкретную функцию, а "где у нас обрабатываются платежи". Для этого есть и более специализированные инструменты вроде srag.
  • Персональная база знаний. Собираете все документы, статьи, сохраненные посты в одном месте и ищете по смыслу. Почти как RAG-чатбот для корпоративных знаний, но только для себя.

Если вы только начинаете изучать Rust и машинное обучение, рекомендую книгу "Machine Learning with Rust" (партнерская ссылка). Там разобраны не только эмбеддинги, но и классификация, кластеризация и другие алгоритмы. И курс "Practical ML in Rust" - с живыми примерами и проектами.

А что насчет производительности?

На моем ноутбуке с Intel i5 и 16 ГБ ОЗУ индексация 1000 текстовых файлов (в среднем по 10 КБ каждый) занимает около 5 минут. Поиск - менее 100 мс. Потребление памяти - около 300 МБ в пике. Модель all-MiniLM-L12-v2 квантована до 8-бит, так что работает даже на Raspberry Pi.

Если нужно обрабатывать PDF или DOCX, добавьте библиотеки pdf-extract и calamine. Для веб-страниц - html2text. Главное - не пытайтесь запихнуть все в один эмбеддинг. Разбивайте документы на логические части: для книги - по главам, для кода - по модулям, для длинных статей - по секциям.

И последний совет: не делайте интерфейс слишком сложным. Tauri позволяет использовать любые фронтенд-фреймворки, но для такого проекта достаточно простого HTML с полем ввода и списком результатов. Весь смысл в том, чтобы поиск работал быстро и не мешал. Ведь вы делаете его для себя, а не для выставки в App Store.

Теперь у вас есть свой приватный Windows Recall. Только без шпионажа, без облака и с открытым исходным кодом. И если захотите расширить функциональность - например, добавить чат-интерфейс как в промптах для RAG - код уже у вас в руках. Меняйте, улучшайте, делитесь. Или не делитесь - это же ваш приватный поиск.