feat: integrate Google semantic search and branded desktop launcher
Core CI / core (ubuntu-latest) (push) Failing after 3m27s
Core CI / core (macos-latest) (push) Canceled after 0s
Core CI / core (windows-latest) (push) Canceled after 0s
Core CI / desktop (ubuntu-latest) (push) Successful in 5m45s
Core CI / desktop (macos-latest) (push) Canceled after 0s
Core CI / desktop (windows-latest) (push) Canceled after 0s

This commit is contained in:
karl committed 2026-10-12 17:44:56 +02:00
1 parent a90df15503
commit caf6a2f82e
80 files changed
+14933 -799

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
# USearch's NumKong feature links its kernels statically. On MSVC, the upstream
# header otherwise marks declarations as DLL imports. Keep plain extern symbols.
# Existing developer CXXFLAGS are respected; include this define in overrides.
[env]
CXXFLAGS_x86_64_pc_windows_msvc = { value = "/DNK_DYNAMIC=", force = false }
CXXFLAGS_aarch64_pc_windows_msvc = { value = "/DNK_DYNAMIC=", force = false }
+4 -1
View File
@@ -1,5 +1,7 @@
name: Core CI
on: [push, pull_request]
env:
SEARCHI_FORCE_CPU: "1"
jobs:
core:
strategy:
@@ -31,11 +33,12 @@ jobs:
components: rustfmt, clippy
- uses: actions/setup-node@v4
with:
node-version: 22
node-version: 24
- if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential libssl-dev librsvg2-dev libayatana-appindicator3-dev
- run: npm ci
- run: npm run format:check
- run: npm test
- run: npm run build
- run: cargo fmt --manifest-path src-tauri/Cargo.toml -- --check
- run: cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets --locked -- -D warnings
+4 -1
View File
@@ -1,5 +1,7 @@
name: Core CI
on: [push, pull_request]
env:
SEARCHI_FORCE_CPU: "1"
jobs:
core:
strategy:
@@ -31,11 +33,12 @@ jobs:
components: rustfmt, clippy
- uses: actions/setup-node@v4
with:
node-version: 22
node-version: 24
- if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential libssl-dev librsvg2-dev libayatana-appindicator3-dev
- run: npm ci
- run: npm run format:check
- run: npm test
- run: npm run build
- run: cargo fmt --manifest-path src-tauri/Cargo.toml -- --check
- run: cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets --locked -- -D warnings
+5
View File
@@ -5,4 +5,9 @@ dist/
*.db-shm
*.db-wal
.searchi/
__pycache__/
/apps/desktop/src-tauri/gen/
/apps/desktop/src-tauri/resources/searchi-gemma-worker*
/apps/desktop/src-tauri/resources/embeddinggemma2/
/apps/desktop/src-tauri/icons/android/
/apps/desktop/src-tauri/icons/ios/
Generated
+1208 -7
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1,5 +1,5 @@
[workspace]
members = ["crates/searchi-core", "crates/searchi-cli", "crates/searchi-semantic"]
members = ["crates/searchi-core", "crates/searchi-cli", "crates/searchi-semantic", "crates/searchi-service", "crates/searchi-assets"]
resolver = "2"
[workspace.package]
+31 -2
View File
@@ -1,6 +1,6 @@
# Searchi desktop
This Tauri 2 shell calls the same Rust core as the CLI. It provides real folder indexing, native folder selection, filename/content search, folder/extension/size/date filters, keyboard navigation, escaped text previews, and native open/reveal actions. There are no mocked results or embeddings. Semantic retrieval, a global shortcut, watchers, and release installers remain roadmap work.
This Tauri 2 shell calls the same Rust core as the CLI and the real Google EmbeddingGemma 2 service. It provides folder indexing, native folder selection, progressive filename/content/semantic search, folder/extension/size/date filters, keyboard navigation, escaped text previews, native open/reveal actions, persisted selected folders/resource settings, and a global shortcut. There are no mocked results or embeddings. Watchers and release installers remain roadmap work.
## Develop
@@ -15,15 +15,44 @@ npm run tauri dev
`npm run dev` previews layout in a browser but cannot index or search files. The app reports this limitation explicitly. Press Ctrl+K (Cmd+K on macOS) to focus search; arrow keys navigate results. Choose a folder through the native dialog or enter an absolute path. Open/reveal commands canonicalize the path, verify it is a current file, and require exact membership in the index. The webview has no general opener or filesystem capability. Page/paragraph source locations are displayed when supplied by the core; opening at that exact location is future work.
The desktop index is stored in the platform app-data directory under `dev.searchi.desktop`. Search opens independent core connections while indexing runs on a blocking worker; a guard limits this shell to one indexing job at a time. All model testing on the Windows VM must remain CPU only. This shell configures no GPU provider.
Ctrl+Shift+Space shows and focuses the running app on Windows/Linux; Shift+Cmd+Space does so on macOS. Registration failures appear under Folders & resources rather than preventing startup. Wayland sessions explicitly report the shortcut as unsupported; launcher access remains available. Registration uses the [official Tauri global shortcut plugin](https://v2.tauri.app/plugin/global-shortcut/). Closing the app exits it; background tray lifecycle is not implemented.
Selected roots and the CPU thread budget are stored in versioned `settings.json` beside the index. Writes use an atomic temporary-file replacement; invalid values never overwrite previous settings. Missing files use defaults, while corrupted/unsupported settings fail explicitly. Selected roots may remain listed while disconnected and are reindexed only on user request. The CPU budget defaults to one and is limited to eight. Indexing uses one worker; CPU inference respects the saved budget.
The desktop index is stored in the platform app-data directory under `dev.searchi.desktop`. Search uses a cached core reader while indexing runs on a separate blocking worker; a guard limits this shell to one indexing job at a time. Production inference automatically selects supported acceleration with CPU fallback. All model testing on the Windows VM must remain CPU only.
## Automatic local semantic search
On first launch, the native app downloads and verifies the pinned Google model artifacts (310 MB text model plus 555 MB media projector, approximately 865 MB total) into app-managed storage and prepares the packaged embedding worker. No model paths, runtime paths, or terminal setup are required. The UI shows native download bytes, verification/warming states, the actual worker device, and explicit errors with a retry control. The first download requires network access; file extraction, embeddings, and queries stay local.
After a folder is indexed, semantic indexing starts automatically when the model is ready. Existing keyword results stay searchable during setup/indexing. Progress is indeterminate during embedding because the native command does not expose a chunk completion count. Failed automatic builds retry only after a changed index/status revision or an explicit rebuild, avoiding repeated failure loops. Status polls run once per second while setup/indexing/warming is active and stop when idle.
Each query captures one immutable filter snapshot. Keyword results appear first; real hybrid results follow after the query settles. `src/search-state.ts` bounds each lane to one active native request and one latest pending request, discards old revisions, and retries a busy request at most once. Escape clears both pending lanes and their timers; already-running native work finishes without changing cleared results. Hybrid updates preserve the selected file and keyboard focus where possible.
Run the async-state tests with Node.js 24 or newer:
```sh
node --test tests/search-state.test.mjs
```
For Windows VM UI testing, disable WebView2 GPU rendering as well, before starting Tauri in PowerShell:
```powershell
$env:WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS = '--disable-gpu'
$env:SEARCHI_FORCE_CPU = '1'
npm.cmd run tauri dev
```
## Desktop design
The compact launcher follows the selected first visual direction with the searchi brand palette: blue `#2563FF`, dark `#0B0F14`, light `#F8FAFC`, lowercase wordmark, and “Find what matters. Locally.” Native operating-system window chrome remains native. The right pane shows real extracted passages and source locations, without invented PDF pagination or confidence scores.
Inter Variable is bundled locally with system font fallbacks. All interface icons come from Microsoft Fluent UI System Icons; no runtime CDN requests are made. Light/dark themes follow the operating system. Brief 120–160 ms opacity/transform motion respects reduced-motion settings, and forced-colors mode retains borders and focus visibility. Keyed result rows preserve selected files, keyboard focus, and scroll position during hybrid updates. Settings, filters, and model details use compact disclosures; errors open model details so recovery controls stay accessible.
Distributed notices are included in `public/licenses/` and `src-tauri/resources/licenses/`: [Inter SIL Open Font License](public/licenses/Inter-OFL.txt), [Microsoft Fluent icons MIT license](public/licenses/Fluent-MIT.txt). These are the actual upstream notices, not summaries.
Visual fidelity remains subject to native screenshot QA. Compilation and state tests alone do not verify visual appearance.
## Verification and packaging
`npm run build` checks strict TypeScript and creates production frontend assets. `npm run tauri build -- --no-bundle` compiles the native shell when platform prerequisites are installed. Installer bundling is intentionally disabled until release icons, signing and native platform checks are complete. Native Windows, macOS, and Linux checks must be recorded separately; a frontend build does not prove the native shell works.
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Searchi</title>
<title>searchi</title>
</head>
<body>
<div id="app"></div>
+17
View File
@@ -8,6 +8,8 @@
"name": "searchi-desktop",
"version": "0.1.0",
"dependencies": {
"@fluentui/svg-icons": "1.1.344",
"@fontsource-variable/inter": "5.3.0",
"@tauri-apps/api": "^2.0.0"
},
"devDependencies": {
@@ -459,6 +461,21 @@
"node": ">=18"
}
},
"node_modules/@fluentui/svg-icons": {
"version": "1.1.344",
"resolved": "https://registry.npmjs.org/@fluentui/svg-icons/-/svg-icons-1.1.344.tgz",
"integrity": "sha512-rasjaq4hBXzRS25pSY9gaqwY7NdvZJCz7G/rtpwF6mCmg2R+9TnySLhHbR+sOpkMP/IDDG28XvxRcNEQ/lJYoQ==",
"license": "MIT"
},
"node_modules/@fontsource-variable/inter": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
"integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+3
View File
@@ -6,11 +6,14 @@
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "tsc --noEmit && vite build",
"test": "node --test tests/*.test.mjs",
"tauri": "tauri",
"format": "prettier --write src index.html vite.config.ts tsconfig.json src-tauri/tauri.conf.json src-tauri/capabilities/default.json",
"format:check": "prettier --check src index.html vite.config.ts tsconfig.json src-tauri/tauri.conf.json src-tauri/capabilities/default.json"
},
"dependencies": {
"@fluentui/svg-icons": "1.1.344",
"@fontsource-variable/inter": "5.3.0",
"@tauri-apps/api": "^2.0.0"
},
"devDependencies": {
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Microsoft Corporation
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,93 @@
Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

+630 -3
View File
@@ -79,6 +79,12 @@ dependencies = [
"libc",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anyhow"
version = "1.0.104"
@@ -535,6 +541,12 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "chacha20"
version = "0.10.2"
@@ -568,6 +580,43 @@ dependencies = [
"inout",
]
[[package]]
name = "clap"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
dependencies = [
"clap_builder",
]
[[package]]
name = "clap_builder"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
dependencies = [
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_lex"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
[[package]]
name = "codespan-reporting"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
dependencies = [
"serde",
"termcolor",
"unicode-width",
]
[[package]]
name = "combine"
version = "4.6.8"
@@ -745,6 +794,68 @@ version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d"
[[package]]
name = "cxx"
version = "1.0.205"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8a02bfeba9074b2bbb39492c9b0908ec854f455a3cccdcde7a1de55e2315e40"
dependencies = [
"cc",
"cxx-build",
"cxxbridge-cmd",
"cxxbridge-flags",
"cxxbridge-macro",
"foldhash",
"link-cplusplus",
]
[[package]]
name = "cxx-build"
version = "1.0.205"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbc2ac76997619d2d888b58ad79488f1c835de41ca4dd3dd7082acf4ecb0a50f"
dependencies = [
"cc",
"codespan-reporting",
"indexmap 2.14.2",
"proc-macro2",
"quote",
"scratch",
"syn 3.0.8",
]
[[package]]
name = "cxxbridge-cmd"
version = "1.0.205"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54a486ed16a3f2a053fe7f5b98db4c1a59846a23e8cb9e49f9d1bd48830d51a2"
dependencies = [
"clap",
"codespan-reporting",
"indexmap 2.14.2",
"proc-macro2",
"quote",
"syn 3.0.8",
]
[[package]]
name = "cxxbridge-flags"
version = "1.0.205"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c72569be94e2283764d334cd285980b747dbe221e27e315484eb6bcd93fa808a"
[[package]]
name = "cxxbridge-macro"
version = "1.0.205"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "423e795284a7f7b3f843c956a5322c4bc497b60c2dd1a6339da10313cb18d575"
dependencies = [
"indexmap 2.14.2",
"proc-macro2",
"quote",
"syn 3.0.8",
]
[[package]]
name = "darling"
version = "0.24.1"
@@ -872,6 +983,15 @@ dependencies = [
"crypto-common 0.2.2",
]
[[package]]
name = "directories"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs"
version = "7.0.0"
@@ -1209,6 +1329,16 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "futures-channel"
version = "0.3.34"
@@ -1216,6 +1346,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
@@ -1402,6 +1533,29 @@ dependencies = [
"version_check",
]
[[package]]
name = "gethostname"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
dependencies = [
"rustix",
"windows-link",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
name = "getrandom"
version = "0.3.4"
@@ -1421,9 +1575,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 6.0.0",
"rand_core",
"wasm-bindgen",
]
[[package]]
@@ -1511,6 +1667,24 @@ version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
[[package]]
name = "global-hotkey"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c386b0a4a70cb2d39fffd74480f985b6f0bfbcb934b6a6b6b7e630e448f242e"
dependencies = [
"crossbeam-channel",
"keyboard-types 0.7.0",
"objc2",
"objc2-app-kit",
"once_cell",
"serde",
"thiserror 2.0.21",
"windows-sys 0.59.0",
"x11rb",
"xkeysym",
]
[[package]]
name = "gobject-sys"
version = "0.18.0"
@@ -1705,6 +1879,22 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
name = "hyper-util"
version = "0.1.21"
@@ -2100,6 +2290,17 @@ dependencies = [
"serde_json",
]
[[package]]
name = "keyboard-types"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
dependencies = [
"bitflags 2.13.2",
"serde",
"unicode-segmentation",
]
[[package]]
name = "keyboard-types"
version = "0.8.3"
@@ -2179,6 +2380,15 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "link-cplusplus"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f78c730aaa7d0b9336a299029ea49f9ee53b0ed06e9202e8cb7db9bae7b8c82"
dependencies = [
"cc",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@@ -2233,6 +2443,12 @@ dependencies = [
"weezl",
]
[[package]]
name = "lru-slab"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
[[package]]
name = "markup5ever"
version = "0.39.0"
@@ -2315,7 +2531,7 @@ dependencies = [
"crossbeam-channel",
"dpi",
"gtk",
"keyboard-types",
"keyboard-types 0.8.3",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
@@ -2415,6 +2631,15 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "numkong"
version = "7.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53bcc7469814b82ddffa194dbf3418fc28fa49015392261926f85c5f1cae0625"
dependencies = [
"cc",
]
[[package]]
name = "objc2"
version = "0.6.5"
@@ -2941,6 +3166,62 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.21",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
dependencies = [
"bytes",
"getrandom 0.4.3",
"lru-slab",
"rand",
"rand_pcg",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.21",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.61.2",
]
[[package]]
name = "quote"
version = "1.0.47"
@@ -2979,6 +3260,15 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core",
]
[[package]]
name = "rangemap"
version = "1.8.0"
@@ -3059,6 +3349,46 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
]
[[package]]
name = "reqwest"
version = "0.13.5"
@@ -3117,6 +3447,20 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rusqlite"
version = "0.32.1"
@@ -3159,12 +3503,53 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
version = "1.0.6"
@@ -3231,6 +3616,25 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "scratch"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d68f2ec51b097e4c1a75b681a8bec621909b5e91f15bb7b840c4f2f7b01148b2"
[[package]]
name = "searchi-assets"
version = "0.1.0"
dependencies = [
"anyhow",
"directories",
"reqwest 0.12.28",
"serde",
"serde_json",
"sha2 0.10.9",
"tempfile",
]
[[package]]
name = "searchi-core"
version = "0.1.0"
@@ -3248,15 +3652,43 @@ dependencies = [
name = "searchi-desktop"
version = "0.1.0"
dependencies = [
"searchi-assets",
"searchi-core",
"searchi-service",
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-global-shortcut",
"tauri-plugin-opener",
"tempfile",
]
[[package]]
name = "searchi-semantic"
version = "0.1.0"
dependencies = [
"anyhow",
"serde",
"serde_json",
"sha2 0.10.9",
]
[[package]]
name = "searchi-service"
version = "0.1.0"
dependencies = [
"anyhow",
"fs2",
"rusqlite",
"searchi-core",
"searchi-semantic",
"serde",
"sha2 0.10.9",
"usearch",
]
[[package]]
name = "selectors"
version = "0.38.0"
@@ -3381,6 +3813,18 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "serde_with"
version = "3.24.0"
@@ -3618,6 +4062,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.9"
@@ -3781,7 +4231,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest",
"reqwest 0.13.5",
"serde",
"serde_json",
"serde_repr",
@@ -3922,6 +4372,21 @@ dependencies = [
"url",
]
[[package]]
name = "tauri-plugin-global-shortcut"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93ff17919fe09852d269bd37b1d3d2e993b9dbb514afe7acbf3346c1d3627e2d"
dependencies = [
"global-hotkey",
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.21",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.7.1"
@@ -4066,6 +4531,15 @@ dependencies = [
"new_debug_unreachable",
]
[[package]]
name = "termcolor"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
dependencies = [
"winapi-util",
]
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -4166,6 +4640,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-rustls"
version = "0.26.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.20"
@@ -4441,6 +4925,18 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@@ -4466,6 +4962,17 @@ dependencies = [
"url",
]
[[package]]
name = "usearch"
version = "2.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1086fc5e6b4ee87a5bac4fd80fcf66670526ca21b77d5f95b68de01b15329c67"
dependencies = [
"cxx",
"cxx-build",
"numkong",
]
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -4701,6 +5208,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.39.1"
@@ -4898,6 +5414,24 @@ dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
@@ -4931,6 +5465,22 @@ dependencies = [
"windows_x86_64_msvc 0.42.2",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
@@ -4941,7 +5491,7 @@ dependencies = [
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
@@ -4972,6 +5522,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
@@ -4984,6 +5540,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
@@ -4996,12 +5558,24 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
@@ -5014,6 +5588,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
@@ -5026,6 +5606,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
@@ -5038,6 +5624,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
@@ -5050,6 +5642,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
@@ -5161,6 +5759,29 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "x11rb"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
dependencies = [
"gethostname",
"rustix",
"x11rb-protocol",
]
[[package]]
name = "x11rb-protocol"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "xkeysym"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
name = "yoke"
version = "0.8.3"
@@ -5295,6 +5916,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
[[package]]
name = "zerotrie"
version = "0.2.5"
+5 -3
View File
@@ -17,8 +17,10 @@ tauri-build = { version = "2", features = [] }
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
tauri-plugin-opener = "2"
tauri-plugin-global-shortcut = "2"
serde = { version = "1", features = ["derive"] }
searchi-core = { path = "../../../crates/searchi-core" }
[dev-dependencies]
serde_json = "1"
tempfile = "3"
searchi-core = { path = "../../../crates/searchi-core" }
searchi-service = { path = "../../../crates/searchi-service" }
searchi-assets = { path = "../../../crates/searchi-assets" }
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023-2026 The ggml authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,3 @@
Searchi's native Google embedding worker is staged here during application builds.
The installer ships that worker; Google model files are automatically verified
and downloaded to the local model cache on first launch when not bundled.
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Microsoft Corporation
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,93 @@
Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+149 -13
View File
@@ -1,21 +1,75 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod model_download;
mod semantic_commands;
mod settings;
use searchi_core::{IndexReport, IndexStats, SearchIndex, SearchOptions, SearchResult};
use serde::Deserialize;
use semantic_commands::{
hybrid_search, index_embeddings, semantic_status, setup_model, SemanticState,
};
use serde::{Deserialize, Serialize};
use settings::{Settings, SettingsStore};
use std::{
path::PathBuf,
sync::{
atomic::{AtomicBool, Ordering},
Arc,
Arc, Mutex,
},
};
use tauri::{Manager, State};
use tauri::{Emitter, Manager, State};
use tauri_plugin_dialog::DialogExt;
use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState};
use tauri_plugin_opener::OpenerExt;
struct AppState {
database: PathBuf,
lexical: Arc<Mutex<SearchIndex>>,
indexing: Arc<AtomicBool>,
settings: Arc<Mutex<SettingsStore>>,
shortcut_status: String,
semantic: Arc<SemanticState>,
}
#[derive(Serialize)]
struct DesktopStatus {
settings: Settings,
indexing: bool,
shortcut_status: String,
}
#[tauri::command]
fn desktop_status(state: State<'_, AppState>) -> Result<DesktopStatus, String> {
Ok(DesktopStatus {
settings: state
.settings
.lock()
.map_err(|_| "Settings are unavailable.")?
.value
.clone(),
indexing: state.indexing.load(Ordering::Acquire),
shortcut_status: state.shortcut_status.clone(),
})
}
#[tauri::command]
fn set_cpu_threads(threads: usize, state: State<'_, AppState>) -> Result<Settings, String> {
if state.indexing.load(Ordering::Acquire) {
return Err("Wait for indexing to finish before changing resource settings.".into());
}
let mut store = state
.settings
.lock()
.map_err(|_| "Settings are unavailable.")?;
let mut next = store.value.clone();
next.cpu_threads = threads;
next.validate()?;
state.semantic.reset_session()?;
store.save(next)?;
state
.semantic
.begin_automatic_setup(state.database.clone(), threads);
Ok(store.value.clone())
}
#[derive(Default, Deserialize)]
@@ -48,12 +102,12 @@ async fn search(
filters: Option<Filters>,
state: State<'_, AppState>,
) -> Result<Vec<SearchResult>, String> {
let database = state.database.clone();
let lexical = state.lexical.clone();
tauri::async_runtime::spawn_blocking(move || {
SearchIndex::open(database)
.and_then(|index| {
index.search_with_options(&query, 50, &filters.unwrap_or_default().into())
})
lexical
.try_lock()
.map_err(|_| "Keyword worker is busy; try the latest query again.".to_owned())?
.search_with_options(&query, 50, &filters.unwrap_or_default().into())
.map_err(|e| e.to_string())
})
.await
@@ -150,11 +204,23 @@ async fn index_folder(root: String, state: State<'_, AppState>) -> Result<IndexR
}
let guard = IndexGuard(state.indexing.clone());
let database = state.database.clone();
let settings = state.settings.clone();
let lexical = state.lexical.clone();
tauri::async_runtime::spawn_blocking(move || {
let _guard = guard;
SearchIndex::open(database)
.and_then(|mut index| index.index_folder(root))
.map_err(|e| e.to_string())
settings
.lock()
.map_err(|_| "Settings are unavailable.")?
.add_root(std::path::Path::new(&root))?;
let mut index = SearchIndex::open(database).map_err(|error| error.to_string())?;
let report = index
.index_folder(root)
.map_err(|error| error.to_string())?;
index.prewarm().map_err(|error| error.to_string())?;
*lexical
.lock()
.map_err(|_| "Keyword engine is unavailable.")? = index;
Ok(report)
})
.await
.map_err(|e| e.to_string())?
@@ -164,14 +230,78 @@ fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().with_handler(|app, _, event| {
if event.state == ShortcutState::Pressed {
if let Some(window) = app.get_webview_window("main") {
let result = window.show().and_then(|_| window.unminimize()).and_then(|_| window.set_focus());
if let Err(error) = result { eprintln!("Could not focus Searchi: {error}"); }
let _ = window.emit("focus-search", ());
}
}
}).build())
.setup(|app| {
let directory = app.path().app_data_dir()?;
std::fs::create_dir_all(&directory)?;
let database = directory.join("searchi.sqlite");
SearchIndex::open(&database)?;
let lexical = Arc::new(Mutex::new(SearchIndex::open(&database)?));
let warm_database = database.clone();
let warm_target = lexical.clone();
tauri::async_runtime::spawn_blocking(move || {
// Prepare a separate reader and swap it in briefly; foreground
// queries never wait on the long statistics-preparation lock.
if let Ok(mut index) = SearchIndex::open(warm_database) {
if index.prewarm().is_ok() {
if let Ok(mut current) = warm_target.lock() { *current = index; }
}
}
});
let settings = SettingsStore::load(directory.join("settings.json")).map_err(std::io::Error::other)?;
let development_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../..");
let development_models = development_root.join(".searchi/models/embeddinggemma2");
let bundled_models = app.path().resource_dir()?.join("embeddinggemma2");
let model_directory = settings.value.model_directory.clone().unwrap_or_else(|| {
if cfg!(debug_assertions) && development_models.is_dir() {
development_models.canonicalize().unwrap_or(development_models)
} else if bundled_models.is_dir() {
bundled_models
} else {
searchi_assets::default_model_directory().unwrap_or_else(|_| directory.join("Models"))
}
});
let worker_name = if cfg!(windows) { "searchi-gemma-worker.exe" } else { "searchi-gemma-worker" };
let development_worker = development_root.join(".searchi/runtime/cpu")
.join(if cfg!(windows) { "Release" } else { "" }).join(worker_name);
let worker = std::env::var_os("SEARCHI_EMBEDDING_WORKER").map(PathBuf::from)
.unwrap_or_else(|| {
if cfg!(debug_assertions) && development_worker.is_file() {
development_worker.canonicalize().unwrap_or(development_worker)
} else {
app.path().resource_dir().unwrap_or_else(|_| directory.clone()).join(worker_name)
}
});
let semantic = Arc::new(SemanticState::new(model_directory, worker));
semantic.begin_automatic_setup(database.clone(), settings.value.cpu_threads);
#[cfg(target_os = "macos")]
let shortcut = "Shift+Super+Space";
#[cfg(not(target_os = "macos"))]
let shortcut = "Control+Shift+Space";
let wayland = cfg!(target_os = "linux") &&
(std::env::var("XDG_SESSION_TYPE").is_ok_and(|value| value == "wayland") || std::env::var_os("WAYLAND_DISPLAY").is_some());
let shortcut_status = if wayland {
"Global shortcut unavailable on Wayland. Open Searchi from your application launcher.".into()
} else {
match app.global_shortcut().register(shortcut) {
Ok(()) => format!("Global shortcut: {shortcut}"),
Err(error) => format!("Global shortcut unavailable: {error}"),
}
};
app.manage(AppState {
database,
lexical,
indexing: Arc::new(AtomicBool::new(false)),
settings: Arc::new(Mutex::new(settings)),
shortcut_status,
semantic,
});
Ok(())
})
@@ -180,7 +310,13 @@ fn main() {
stats,
index_folder,
pick_folder,
open_result
open_result,
desktop_status,
set_cpu_threads,
semantic_status,
setup_model,
index_embeddings,
hybrid_search
])
.run(tauri::generate_context!())
.expect("Searchi desktop could not start");
@@ -0,0 +1,7 @@
//! Thin desktop adapter over the GUI-free, shared model provisioner.
pub use searchi_assets::TOTAL_BYTES;
use std::{path::Path, sync::atomic::AtomicU64};
pub fn provision(directory: &Path, progress: &AtomicU64) -> Result<(), String> {
searchi_assets::provision(directory, progress).map_err(|error| format!("{error:#}"))
}
@@ -0,0 +1,285 @@
//! Desktop-owned orchestration: managed model setup and persistent hybrid queries.
use crate::{model_download, AppState, Filters};
use searchi_core::SearchResult;
use searchi_service::{EmbeddingReport, HybridSearch};
use serde::Serialize;
use std::{
path::PathBuf,
sync::{
atomic::{AtomicBool, AtomicU64, Ordering},
Mutex,
},
};
use tauri::State;
pub struct SemanticState {
pub model_directory: PathBuf,
pub worker: PathBuf,
verified: AtomicBool,
downloading: AtomicBool,
embedding: AtomicBool,
querying: AtomicBool,
warmed: AtomicBool,
warming: AtomicBool,
downloaded_bytes: AtomicU64,
error: Mutex<Option<String>>,
service: Mutex<Option<HybridSearch>>,
}
impl SemanticState {
pub fn new(model_directory: PathBuf, worker: PathBuf) -> Self {
Self {
model_directory,
worker,
verified: AtomicBool::new(false),
downloading: AtomicBool::new(false),
embedding: AtomicBool::new(false),
querying: AtomicBool::new(false),
warmed: AtomicBool::new(false),
warming: AtomicBool::new(false),
downloaded_bytes: AtomicU64::new(0),
error: Mutex::new(None),
service: Mutex::new(None),
}
}
pub fn reset_session(&self) -> Result<(), String> {
*self
.service
.try_lock()
.map_err(|_| "Wait for the current semantic query before changing resources.")? = None;
self.warmed.store(false, Ordering::Release);
Ok(())
}
fn prepare_query_session(&self, database: PathBuf, threads: usize) -> Result<(), String> {
self.warming.store(true, Ordering::Release);
let _guard = WorkGuard(&self.warming);
if !self.worker.is_file() {
return Err(
"The installed embedding worker is missing. Repair or reinstall Searchi.".into(),
);
}
let mut service =
HybridSearch::open(database, &self.model_directory, &self.worker, threads)
.map_err(|error| error.to_string())?;
service.warmup().map_err(|error| error.to_string())?;
*self
.service
.lock()
.map_err(|_| "Semantic session is unavailable.")? = Some(service);
self.warmed.store(true, Ordering::Release);
Ok(())
}
/// First-launch setup is authorized product behavior; inference remains offline.
pub fn begin_automatic_setup(self: &std::sync::Arc<Self>, database: PathBuf, threads: usize) {
if self
.downloading
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
return;
}
let semantic = self.clone();
tauri::async_runtime::spawn_blocking(move || {
let _guard = WorkGuard(&semantic.downloading);
semantic.record_error(None);
let result =
model_download::provision(&semantic.model_directory, &semantic.downloaded_bytes);
semantic.verified.store(result.is_ok(), Ordering::Release);
let result = result.and_then(|_| semantic.prepare_query_session(database, threads));
semantic.record_error(result.err());
});
}
fn record_error(&self, value: Option<String>) {
if let Ok(mut error) = self.error.lock() {
*error = value;
}
}
}
#[derive(Serialize)]
pub struct SemanticStatus {
model: &'static str,
ready: bool,
model_directory: String,
worker_ready: bool,
downloading: bool,
embedding: bool,
device_policy: &'static str,
error: Option<String>,
downloaded_bytes: u64,
total_bytes: u64,
generation: Option<i64>,
chunks: usize,
stale: bool,
pending: usize,
warming: bool,
worker_info: Option<searchi_service::WorkerInfo>,
}
struct WorkGuard<'a>(&'a AtomicBool);
impl Drop for WorkGuard<'_> {
fn drop(&mut self) {
self.0.store(false, Ordering::Release);
}
}
#[tauri::command]
pub fn semantic_status(state: State<'_, AppState>) -> Result<SemanticStatus, String> {
let semantic = &state.semantic;
let worker_ready = semantic.worker.is_file();
let index =
HybridSearch::embedding_status(&state.database).map_err(|error| error.to_string())?;
Ok(SemanticStatus {
model: "Google EmbeddingGemma 2",
ready: worker_ready && semantic.warmed.load(Ordering::Acquire),
model_directory: semantic.model_directory.to_string_lossy().into_owned(),
worker_ready,
downloading: semantic.downloading.load(Ordering::Acquire),
embedding: semantic.embedding.load(Ordering::Acquire),
device_policy: if std::env::var("SEARCHI_FORCE_CPU").as_deref() == Ok("1") {
"cpu"
} else {
"auto"
},
error: semantic.error.lock().ok().and_then(|value| value.clone()),
downloaded_bytes: semantic.downloaded_bytes.load(Ordering::Acquire),
total_bytes: model_download::TOTAL_BYTES,
generation: index.generation,
chunks: index.chunks,
stale: index.stale,
pending: index.pending,
warming: semantic.warming.load(Ordering::Acquire),
worker_info: semantic
.service
.try_lock()
.ok()
.and_then(|service| service.as_ref().and_then(HybridSearch::worker_info)),
})
}
#[tauri::command]
pub async fn setup_model(state: State<'_, AppState>) -> Result<(), String> {
let semantic = state.semantic.clone();
let database = state.database.clone();
let threads = state
.settings
.lock()
.map_err(|_| "Settings are unavailable.")?
.value
.cpu_threads;
if semantic
.downloading
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
return Err("Google model setup is already running.".into());
}
tauri::async_runtime::spawn_blocking(move || {
let _guard = WorkGuard(&semantic.downloading);
semantic.record_error(None);
let result =
model_download::provision(&semantic.model_directory, &semantic.downloaded_bytes);
semantic.verified.store(result.is_ok(), Ordering::Release);
let result = result.and_then(|_| semantic.prepare_query_session(database, threads));
semantic.record_error(result.as_ref().err().cloned());
result
})
.await
.map_err(|error| error.to_string())?
}
#[tauri::command]
pub async fn index_embeddings(state: State<'_, AppState>) -> Result<EmbeddingReport, String> {
let semantic = state.semantic.clone();
if !semantic.verified.load(Ordering::Acquire) || !semantic.worker.is_file() {
return Err("Set up the Google model and native worker first.".into());
}
let threads = state
.settings
.lock()
.map_err(|_| "Settings are unavailable.")?
.value
.cpu_threads;
if semantic
.embedding
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
return Err("Semantic indexing is already running.".into());
}
let database = state.database.clone();
tauri::async_runtime::spawn_blocking(move || {
let _guard = WorkGuard(&semantic.embedding);
semantic.record_error(None);
// Indexing uses its own isolated worker; interactive queries keep their own session.
let result = HybridSearch::open(
database,
&semantic.model_directory,
&semantic.worker,
threads,
)
.and_then(|mut service| service.index_embeddings())
.map_err(|error| error.to_string());
semantic.record_error(result.as_ref().err().cloned());
result
})
.await
.map_err(|error| error.to_string())?
}
#[tauri::command]
pub async fn hybrid_search(
query: String,
filters: Option<Filters>,
state: State<'_, AppState>,
) -> Result<Vec<SearchResult>, String> {
let semantic = state.semantic.clone();
if !semantic.warmed.load(Ordering::Acquire) {
return Err(
"Semantic search is preparing in the background; keyword results are available.".into(),
);
}
let database = state.database.clone();
let threads = state
.settings
.lock()
.map_err(|_| "Settings are unavailable.")?
.value
.cpu_threads;
if semantic
.querying
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
return Err("Semantic worker is busy; keyword results remain available.".into());
}
tauri::async_runtime::spawn_blocking(move || {
let _guard = WorkGuard(&semantic.querying);
let mut cached = semantic
.service
.lock()
.map_err(|_| "Semantic session is unavailable.")?;
if cached.is_none() {
*cached = Some(
HybridSearch::open(
database,
&semantic.model_directory,
&semantic.worker,
threads,
)
.map_err(|error| error.to_string())?,
);
}
cached
.as_mut()
.ok_or("Semantic session is unavailable.")?
.search(&query, 50, &filters.unwrap_or_default().into())
.map_err(|error| error.to_string())
})
.await
.map_err(|error| error.to_string())?
}
+201
View File
@@ -0,0 +1,201 @@
//! Versioned local settings. Writes replace the destination atomically; failed
//! saves leave both the previous file and in-memory settings intact.
use serde::{Deserialize, Serialize};
use std::{
fs,
io::Write,
path::{Path, PathBuf},
};
const SETTINGS_VERSION: u32 = 1;
const MAX_SETTINGS_BYTES: u64 = 128 * 1024;
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Settings {
pub version: u32,
pub roots: Vec<PathBuf>,
pub cpu_threads: usize,
/// Managed local Google EmbeddingGemma2 artifacts. Older settings default
/// to unconfigured; model acquisition is an explicit desktop action.
#[serde(default)]
pub model_directory: Option<PathBuf>,
}
impl Default for Settings {
fn default() -> Self {
Self {
version: SETTINGS_VERSION,
roots: Vec::new(),
cpu_threads: 1,
model_directory: None,
}
}
}
impl Settings {
pub fn validate(&self) -> Result<(), String> {
if self.version != SETTINGS_VERSION {
return Err(format!("Unsupported settings version {}", self.version));
}
if !(1..=8).contains(&self.cpu_threads) {
return Err("CPU threads must be between 1 and 8.".into());
}
if self.roots.len() > 64 {
return Err("At most 64 indexed folders are supported.".into());
}
if let Some(directory) = &self.model_directory {
if !directory.is_absolute() || directory.to_string_lossy().len() > 32768 {
return Err("Model directory must be an absolute bounded path.".into());
}
}
for (i, root) in self.roots.iter().enumerate() {
if !root.is_absolute() {
return Err("Indexed folder paths must be absolute.".into());
}
if root.to_string_lossy().len() > 32768 {
return Err("Indexed folder path is too long.".into());
}
if self.roots[..i].contains(root) {
return Err("Indexed folder paths must be unique.".into());
}
}
Ok(())
}
}
pub struct SettingsStore {
path: PathBuf,
pub value: Settings,
}
impl SettingsStore {
pub fn load(path: PathBuf) -> Result<Self, String> {
let value = match fs::metadata(&path) {
Ok(metadata) => {
if metadata.len() > MAX_SETTINGS_BYTES {
return Err("Settings file exceeds the size limit.".into());
}
serde_json::from_slice(&fs::read(&path).map_err(|e| e.to_string())?)
.map_err(|e| format!("Invalid settings file: {e}"))?
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Settings::default(),
Err(error) => return Err(error.to_string()),
};
let store = Self { path, value };
store.value.validate()?;
Ok(store)
}
pub fn save(&mut self, next: Settings) -> Result<(), String> {
next.validate()?;
let parent = self.path.parent().ok_or("Settings path has no parent.")?;
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
let mut temporary = tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?;
let bytes = serde_json::to_vec_pretty(&next).map_err(|e| e.to_string())?;
if bytes.len() as u64 > MAX_SETTINGS_BYTES {
return Err("Settings exceed the size limit.".into());
}
temporary.write_all(&bytes).map_err(|e| e.to_string())?;
temporary.as_file().sync_all().map_err(|e| e.to_string())?;
temporary.persist(&self.path).map_err(|e| e.to_string())?;
self.value = next;
Ok(())
}
pub fn add_root(&mut self, root: &Path) -> Result<(), String> {
let canonical = fs::canonicalize(root).map_err(|e| e.to_string())?;
if !canonical.is_dir() {
return Err("Indexed folder must be a directory.".into());
}
let mut next = self.value.clone();
if !next.roots.contains(&canonical) {
next.roots.push(canonical);
self.save(next)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn settings_round_trip_and_atomic_replacement() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("settings.json");
let mut store = SettingsStore::load(path.clone()).unwrap();
assert_eq!(store.value.cpu_threads, 1);
store.add_root(directory.path()).unwrap();
store.add_root(directory.path()).unwrap();
let mut next = store.value.clone();
next.cpu_threads = 2;
store.save(next.clone()).unwrap();
assert_eq!(SettingsStore::load(path).unwrap().value, next);
assert_eq!(store.value.roots.len(), 1);
}
#[test]
fn invalid_save_preserves_previous_file_and_memory() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("settings.json");
let mut store = SettingsStore::load(path.clone()).unwrap();
store.save(Settings::default()).unwrap();
let original = fs::read(&path).unwrap();
let mut invalid = store.value.clone();
invalid.cpu_threads = 0;
assert!(store.save(invalid).is_err());
assert_eq!(store.value, Settings::default());
assert_eq!(fs::read(&path).unwrap(), original);
let mut invalid = store.value.clone();
invalid.roots.push(PathBuf::from("relative"));
assert!(store.save(invalid).is_err());
}
#[test]
fn rejects_corruption_unknown_version_and_excessive_threads() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("settings.json");
fs::write(&path, "broken").unwrap();
assert!(SettingsStore::load(path.clone()).is_err());
fs::write(&path, r#"{"version":99,"roots":[],"cpu_threads":1}"#).unwrap();
assert!(SettingsStore::load(path).is_err());
let invalid = Settings {
cpu_threads: 9,
..Settings::default()
};
assert!(invalid.validate().is_err());
}
#[test]
fn loads_older_settings_with_unconfigured_google_model() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("settings.json");
fs::write(&path, r#"{"version":1,"roots":[],"cpu_threads":1}"#).unwrap();
let store = SettingsStore::load(path).unwrap();
assert!(store.value.model_directory.is_none());
let invalid = Settings {
model_directory: Some(PathBuf::from("relative")),
..Settings::default()
};
assert!(invalid.validate().is_err());
}
#[test]
fn oversized_save_leaves_existing_settings_readable() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("settings.json");
let mut store = SettingsStore::load(path.clone()).unwrap();
store.save(Settings::default()).unwrap();
let mut oversized = store.value.clone();
oversized.roots = (0..64)
.map(|i| directory.path().join(format!("{i}{}", "x".repeat(3000))))
.collect();
assert!(store.save(oversized).is_err());
assert_eq!(
SettingsStore::load(path).unwrap().value,
Settings::default()
);
}
}
+13 -3
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Searchi",
"productName": "searchi",
"version": "0.1.0",
"identifier": "dev.searchi.desktop",
"build": {
@@ -12,7 +12,7 @@
"app": {
"windows": [
{
"title": "Searchi",
"title": "searchi",
"width": 1080,
"height": 760,
"minWidth": 680,
@@ -23,5 +23,15 @@
"csp": "default-src 'self'; connect-src ipc: http://ipc.localhost; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
}
},
"bundle": { "active": false }
"bundle": {
"active": false,
"resources": { "resources/": "" },
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/[email protected]",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
+64
View File
@@ -0,0 +1,64 @@
// Optimized library assets only. No handcrafted SVG or runtime icon requests.
import search from "@fluentui/svg-icons/icons/search_24_regular.svg?raw";
import settings from "@fluentui/svg-icons/icons/settings_20_regular.svg?raw";
import filter from "@fluentui/svg-icons/icons/filter_20_regular.svg?raw";
import dismiss from "@fluentui/svg-icons/icons/dismiss_20_regular.svg?raw";
import folder from "@fluentui/svg-icons/icons/folder_24_regular.svg?raw";
import folderAdd from "@fluentui/svg-icons/icons/folder_add_20_regular.svg?raw";
import document from "@fluentui/svg-icons/icons/document_24_regular.svg?raw";
import pdf from "@fluentui/svg-icons/icons/document_pdf_24_regular.svg?raw";
import code from "@fluentui/svg-icons/icons/code_24_regular.svg?raw";
import lock from "@fluentui/svg-icons/icons/lock_closed_20_regular.svg?raw";
import arrowUp from "@fluentui/svg-icons/icons/arrow_up_16_regular.svg?raw";
import arrowDown from "@fluentui/svg-icons/icons/arrow_down_16_regular.svg?raw";
import open from "@fluentui/svg-icons/icons/open_20_regular.svg?raw";
import chevron from "@fluentui/svg-icons/icons/chevron_down_16_regular.svg?raw";
import sparkles from "@fluentui/svg-icons/icons/sparkle_20_regular.svg?raw";
const assets = {
search,
settings,
filter,
dismiss,
folder,
folderAdd,
document,
pdf,
code,
lock,
arrowUp,
arrowDown,
open,
chevron,
sparkles,
};
export function icon(name: keyof typeof assets) {
return `<span class="icon" aria-hidden="true">${assets[name]}</span>`;
}
export function fileIcon(filename: string) {
const extension = filename.split(".").pop()?.toLowerCase();
if (extension === "pdf") return { html: icon("pdf"), type: "pdf" };
if (
extension &&
[
"rs",
"js",
"ts",
"tsx",
"py",
"json",
"html",
"css",
"toml",
"yaml",
"yml",
].includes(extension)
)
return { html: icon("code"), type: "code" };
return {
html: icon("document"),
type: ["doc", "docx"].includes(extension || "")
? "document-word"
: "document",
};
}
+552 -57
View File
@@ -1,4 +1,13 @@
import { invoke, isTauri } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import {
AutomaticIndexGate,
emptySearchState,
LatestLane,
} from "./search-state";
import type { SemanticPhase } from "./search-state";
import { fileIcon, icon } from "./icons";
import "@fontsource-variable/inter/wght.css";
import "./style.css";
interface SearchResult {
@@ -20,16 +29,54 @@ interface IndexStats {
files: number;
content_files: number;
}
interface Settings {
version: number;
roots: string[];
cpu_threads: number;
}
interface DesktopStatus {
settings: Settings;
indexing: boolean;
shortcut_status: string;
}
interface SemanticStatus {
model: string;
ready: boolean;
model_directory: string;
worker_ready: boolean;
downloading: boolean;
embedding: boolean;
device_policy: "auto" | "cpu";
error: string | null;
downloaded_bytes: number;
total_bytes: number;
generation: number | null;
chunks: number;
stale: boolean;
pending: number;
warming: boolean;
worker_info?: {
model?: string;
device?: string;
runtime?: string;
threads?: number;
} | null;
}
interface SearchRequest {
id: number;
query: string;
filters: Record<string, string | number | null>;
started: number;
}
document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
<header><div class="wordmark">searchi<span class="dot">.</span></div><span class="privacy">● Local · CPU only</span></header>
<main><section class="intro"><p class="eyebrow">YOUR FILES, WITHIN REACH</p><h1>Find the thing you remember.</h1><p>Search filenames and document content in your selected folders.</p></section>
<form id="search-form" role="search"><label class="sr-only" for="query">Search your files</label><span class="search-icon" aria-hidden="true">⌕</span><input id="query" type="search" placeholder="A filename or words from a document…" autocomplete="off" spellcheck="false" maxlength="1000"><kbd>Ctrl K</kbd></form>
<div class="toolbar"><span id="index-status">Loading your index…</span><button id="add-folder" type="button">+ Index a folder</button></div>
<details id="filters"><summary>Filter results</summary><div class="filters-grid"><label>Within folder<input id="filter-folder" placeholder="Absolute folder path"></label><label>Extension<input id="filter-extension" placeholder="pdf, md, txt…"></label><label>Minimum size (bytes)<input id="filter-min" type="number" min="0" step="1"></label><label>Maximum size (bytes)<input id="filter-max" type="number" min="0" step="1"></label><label>Modified from<input id="filter-after" type="date"></label><label>Modified through<input id="filter-before" type="date"></label></div></details>
<header><div class="brand"><img class="brand-symbol" src="/searchi-symbol.png" alt=""><span class="wordmark">searchi</span></div><span id="index-status" class="index-status">Loading your index…</span><div class="header-actions"><button id="add-folder" class="quiet-button" type="button">${icon("folderAdd")}<span>Add folder</span></button><details id="filters" class="popover"><summary>${icon("filter")}<span>All files</span>${icon("chevron")}</summary><div class="popover-panel"><h2>Filter your search</h2><div class="filters-grid"><label>Within folder<input id="filter-folder" placeholder="Absolute folder path"></label><label>Extension<input id="filter-extension" placeholder="pdf, md, txt…"></label><label>Minimum size (bytes)<input id="filter-min" type="number" min="0" step="1"></label><label>Maximum size (bytes)<input id="filter-max" type="number" min="0" step="1"></label><label>Modified from<input id="filter-after" type="date"></label><label>Modified through<input id="filter-before" type="date"></label></div></div></details><details id="settings" class="popover"><summary class="icon-button" aria-label="Settings" title="Settings">${icon("settings")}</summary><div class="popover-panel"><h2>Folders & resources</h2><p id="shortcut-status" class="hint"></p><label class="resource-label" for="cpu-threads">CPU thread budget</label><select id="cpu-threads">${Array.from({ length: 8 }, (_, i) => `<option value="${i + 1}">${i + 1} ${i ? "threads" : "thread (default)"}</option>`).join("")}</select><p class="hint">Search automatically uses supported acceleration with CPU fallback.</p><ul id="roots" aria-label="Selected folders"></ul></div></details></div></header>
<main>
<form id="search-form" role="search"><label class="sr-only" for="query">Search your files</label>${icon("search")}<input id="query" type="search" placeholder="Search a filename, phrase, or what you remember…" autocomplete="off" spellcheck="false" maxlength="1000"><kbd id="focus-shortcut">Ctrl K</kbd><button id="clear-query" class="icon-button" type="button" aria-label="Clear search" title="Clear search">${icon("dismiss")}</button></form>
<details id="semantic-panel"><summary>${icon("sparkles")}<span id="semantic-status" role="status" aria-live="polite">Preparing local semantic search…</span><span id="semantic-device" class="kind">Local model</span>${icon("chevron")}</summary><div class="semantic-content"><strong>Google EmbeddingGemma 2</strong><progress id="semantic-progress" aria-label="Local model setup progress"></progress><p id="semantic-detail" class="hint">The verified local model downloads automatically on first launch (865 MB). Your files stay on this device.</p><div class="semantic-actions"><button id="retry-model" type="button" hidden>Retry model setup</button><button id="rebuild-semantic" type="button" hidden>Rebuild semantic index</button></div></div></details>
<form id="folder-form" hidden><label for="folder">Folder to index</label><div class="folder-row"><input id="folder" placeholder="Absolute folder path" required><button id="browse-folder" type="button">Browse…</button><button type="submit">Index folder</button><button id="cancel-folder" type="button">Cancel</button></div><p class="hint">Only choose folders you want searched. Large folders may take a while.</p></form>
<p id="notice" role="status" aria-live="polite"></p><div class="workspace"><section aria-label="Search results"><p id="result-summary" class="eyebrow">READY WHEN YOU ARE</p><ul id="results" aria-label="Matching files"></ul><div id="empty"><span aria-hidden="true">⌕</span><h2>Your next discovery starts here.</h2><p>Index a folder, then search a filename or a phrase from a document.</p></div></section><aside id="preview" aria-label="Result preview" hidden><p class="eyebrow">CONTENT PREVIEW</p><h2 id="preview-title"></h2><p id="preview-path" class="path"></p><p id="preview-kind"></p><div class="preview-actions"><button id="open-result" type="button">Open file</button><button id="reveal-result" type="button">Show in folder</button></div><pre id="preview-text"></pre></aside></div>
</main><footer>Offline by default<span>↑ ↓ Navigate · Enter Preview · Esc Clear</span></footer>`;
<p id="notice" role="status" aria-live="polite"></p><div class="workspace"><section class="results-pane" aria-label="Search results"><p id="result-summary" class="eyebrow">READY WHEN YOU ARE</p><ul id="results" aria-label="Matching files"></ul><div id="empty">${icon("search")}<h2>Find what matters. Locally.</h2><p>Index a folder, then search a filename, phrase, or what you remember.</p></div></section><aside id="preview" aria-label="Result preview" hidden><div class="preview-header"><span id="preview-icon" class="file-icon"></span><div class="preview-heading"><h2 id="preview-title"></h2><p id="preview-path" class="path"></p></div></div><div class="preview-toolbar"><span id="preview-kind"></span><div class="preview-actions"><button id="open-result" type="button">${icon("open")}<span>Open file</span></button><button id="reveal-result" type="button" title="Show in folder" aria-label="Show in folder">${icon("folder")}</button></div></div><div class="passage"><p class="eyebrow">EXTRACTED PASSAGE</p><pre id="preview-text"></pre></div></aside></div>
</main><footer><span class="footer-privacy">${icon("lock")}<span>Private search. Everything stays on your device.</span><span id="privacy" class="privacy">Local</span></span><span class="keyboard-hints"><kbd>${icon("arrowUp")}</kbd><kbd>${icon("arrowDown")}</kbd><span>Navigate</span><kbd>Enter</kbd><span>Open</span><kbd>Esc</kbd><span>Clear</span></span></footer>`;
const element = <T extends HTMLElement>(id: string) =>
document.getElementById(id) as T;
@@ -40,6 +87,246 @@ let results: SearchResult[] = [];
let selected = -1;
let requestId = 0;
let timer: ReturnType<typeof setTimeout>;
let indexingActive = false;
let semanticStatus: SemanticStatus | null = null;
let semanticJobActive = false;
let semanticRefreshActive = false;
let semanticTimer: ReturnType<typeof setTimeout> | undefined;
let hybridTimer: ReturnType<typeof setTimeout> | undefined;
let latestSearch: SearchRequest | null = null;
let shownLane: "lexical" | "hybrid" = "lexical";
let folderRevision = 0;
let localSemanticError: string | null = null;
let querySemanticFailed = false;
let previewPath: string | null = null;
let displayedRequestId: number | null = null;
let expandedError: string | null = null;
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)");
const resultRows = new Map<
string,
{
row: HTMLLIElement;
button: HTMLButtonElement;
title: HTMLElement;
path: HTMLElement;
snippet: HTMLElement;
kind: HTMLElement;
file: HTMLElement;
}
>();
const automaticIndex = new AutomaticIndexGate();
function refreshEmptySearchState() {
if (!latestSearch || latestSearch.id !== requestId || results.length) return;
let phase: SemanticPhase;
if (querySemanticFailed || localSemanticError || semanticStatus?.error)
phase = "unavailable";
else if (
semanticStatus?.embedding ||
semanticJobActive ||
indexingActive ||
(semanticStatus?.ready &&
semanticStatus.stale &&
semanticStatus.pending > 0)
)
phase = "indexing";
else if (!semanticStatus?.ready) phase = "preparing";
else if (semanticStatus.generation === null) phase = "no-content";
else phase = shownLane === "hybrid" ? "complete" : "pending";
const state = emptySearchState(shownLane, phase);
element("empty").querySelector("h2")!.textContent = state.title;
element("empty").querySelector("p")!.textContent = state.detail;
}
function canSearchSemantically() {
return Boolean(
semanticStatus?.ready &&
semanticStatus.generation !== null &&
!semanticStatus.embedding &&
!semanticJobActive,
);
}
function queueSemanticRefresh(delay = 1000) {
clearTimeout(semanticTimer);
semanticTimer = setTimeout(() => {
void refreshSemanticStatus();
}, delay);
}
async function buildSemanticIndex() {
if (semanticJobActive || indexingActive) return;
semanticJobActive = true;
localSemanticError = null;
element("semantic-status").textContent =
"Building your semantic index… Keyword search remains available.";
element<HTMLProgressElement>("semantic-progress").removeAttribute("value");
element("semantic-progress").hidden = false;
queueSemanticRefresh();
try {
const report = await invoke<{ passages: number; chunks: number }>(
"index_embeddings",
);
element("semantic-status").textContent =
`Semantic search indexed ${report.chunks.toLocaleString()} passages.`;
} catch (error) {
localSemanticError = String(error);
} finally {
semanticJobActive = false;
queueSemanticRefresh(0);
}
}
async function refreshSemanticStatus() {
if (semanticRefreshActive) {
queueSemanticRefresh();
return;
}
semanticRefreshActive = true;
try {
const previousReady = canSearchSemantically();
const previousGeneration = semanticStatus?.generation;
const status = await invoke<SemanticStatus>("semantic_status");
semanticStatus = status;
refreshEmptySearchState();
const error = localSemanticError || status.error;
if (error && error !== expandedError) {
element<HTMLDetailsElement>("semantic-panel").open = true;
expandedError = error;
}
if (!error) expandedError = null;
const progress = element<HTMLProgressElement>("semantic-progress");
const working =
status.downloading ||
status.warming ||
status.embedding ||
semanticJobActive;
progress.hidden = !working;
progress.removeAttribute("value");
if (
status.downloading &&
status.total_bytes > 0 &&
status.downloaded_bytes < status.total_bytes
) {
progress.max = status.total_bytes;
progress.value = status.downloaded_bytes;
}
const actualDevice =
status.worker_info?.device ||
(status.device_policy === "cpu" ? "CPU" : "Automatic acceleration");
element("privacy").textContent = `Local · ${actualDevice}`;
element("semantic-device").textContent = actualDevice;
let message: string;
if (error) message = error;
else if (status.warming)
message = "Preparing Google semantic search on your device…";
else if (status.downloading)
message =
status.downloaded_bytes >= status.total_bytes
? "Verifying the downloaded Google model…"
: `Downloading Google model · ${Math.round(status.downloaded_bytes / 1_000_000)} of ${Math.round(status.total_bytes / 1_000_000)} MB`;
else if (status.embedding || semanticJobActive)
message =
"Building your semantic index… Keyword search remains available.";
else if (status.ready && status.chunks > 0)
message = `Semantic search ready · ${status.chunks.toLocaleString()} passages${status.stale ? " · Updates pending" : ""}`;
else if (status.ready)
message = "Google model ready. Index a folder to search by meaning.";
else
message =
"Preparing local semantic search… Keyword search remains available.";
element("semantic-status").textContent = message;
element("semantic-status").classList.toggle("error", Boolean(error));
element("semantic-detail").textContent = status.worker_info
? `${status.model} · ${status.worker_info.runtime || "Local inference"} · Your files stay on this device.`
: "The verified Google model downloads automatically once (865 MB). After setup, inference runs locally.";
const retry = element<HTMLButtonElement>("retry-model");
retry.hidden = !error || status.ready;
retry.disabled = working;
const rebuild = element<HTMLButtonElement>("rebuild-semantic");
rebuild.hidden = !status.ready || (!status.chunks && !status.pending);
rebuild.disabled = working || indexingActive;
element<HTMLSelectElement>("cpu-threads").disabled =
working || indexingActive;
if (
status.ready &&
status.stale &&
status.pending > 0 &&
!working &&
!indexingActive
) {
const key = `${status.generation}:${status.pending}:${folderRevision}`;
if (automaticIndex.claim(key)) {
void buildSemanticIndex();
return;
}
}
if (
(!previousReady || previousGeneration !== status.generation) &&
canSearchSemantically() &&
latestSearch?.id === requestId
)
hybridLane.submit(latestSearch);
if (working || (!status.ready && !error)) queueSemanticRefresh();
} catch (error) {
element("semantic-status").textContent =
`Semantic search unavailable: ${String(error)}`;
element("semantic-status").classList.add("error");
element<HTMLButtonElement>("retry-model").hidden = false;
} finally {
semanticRefreshActive = false;
}
}
async function refreshDesktopStatus() {
const status = await invoke<DesktopStatus>("desktop_status");
indexingActive = status.indexing;
element("shortcut-status").textContent = status.shortcut_status;
element("focus-shortcut").textContent = status.shortcut_status.includes(
"Super",
)
? "Cmd K"
: "Ctrl K";
const threads = element<HTMLSelectElement>("cpu-threads");
threads.value = String(status.settings.cpu_threads);
threads.disabled =
status.indexing ||
semanticJobActive ||
Boolean(
semanticStatus?.downloading ||
semanticStatus?.warming ||
semanticStatus?.embedding,
);
element<HTMLButtonElement>("add-folder").disabled = status.indexing;
const roots = element("roots");
roots.replaceChildren();
for (const root of status.settings.roots) {
const item = document.createElement("li");
const path = document.createElement("span");
path.textContent = root;
path.className = "path";
const reindex = document.createElement("button");
reindex.type = "button";
reindex.textContent = "Reindex";
reindex.disabled = status.indexing;
reindex.addEventListener("click", () => {
element("folder-form").hidden = false;
element<HTMLInputElement>("folder").value = root;
element("folder").focus();
});
item.append(path, reindex);
roots.append(item);
}
if (!status.settings.roots.length) {
const item = document.createElement("li");
item.textContent = "No folders selected yet.";
roots.append(item);
}
if (status.indexing)
element("index-status").textContent =
"Indexing active · Search remains available";
return status;
}
function showError(error: unknown) {
notice.textContent = String(error);
@@ -51,10 +338,14 @@ function select(index: number, focus = false) {
const result = results[selected];
list.querySelectorAll<HTMLButtonElement>("button").forEach((button, i) => {
button.classList.toggle("selected", i === selected);
button.setAttribute("aria-pressed", String(i === selected));
button.setAttribute("aria-current", String(i === selected));
button.tabIndex = i === selected ? 0 : -1;
if (i === selected && focus) button.focus();
});
element("preview").hidden = false;
const selectedIcon = fileIcon(result.filename);
element("preview-icon").innerHTML = selectedIcon.html;
element("preview-icon").dataset.type = selectedIcon.type;
element("preview-title").textContent = result.filename;
element("preview-path").textContent = result.path;
element("preview-kind").textContent =
@@ -64,61 +355,92 @@ function select(index: number, focus = false) {
(result.extraction_error
? `Content could not be extracted: ${result.extraction_error}`
: "No extracted text is available for this file.");
element<HTMLButtonElement>("open-result").title = result.source
? `Opens this file in your default application. ${result.source.kind} ${result.source.number} is shown here; exact-location opening is not supported by the default application interface.`
: "Open in your default application";
if (previewPath !== result.path) {
const passage = document.querySelector<HTMLElement>(".passage")!;
passage.scrollTop = 0;
if (!reducedMotion.matches) {
passage.getAnimations().forEach((animation) => animation.cancel());
passage.animate(
[
{ opacity: 0.65, transform: "translateY(3px)" },
{ opacity: 1, transform: "translateY(0)" },
],
{ duration: 150, easing: "ease-out" },
);
}
previewPath = result.path;
}
}
function render() {
list.replaceChildren();
const scrollTop = list.scrollTop;
const retained = new Set(results.map((result) => result.path));
for (const [key, entry] of resultRows) {
if (!retained.has(key)) {
entry.row.remove();
resultRows.delete(key);
}
}
element("empty").hidden = results.length > 0;
if (!results.length) {
element("preview").hidden = true;
previewPath = null;
}
results.forEach((result, i) => {
let entry = resultRows.get(result.path);
if (!entry) {
const row = document.createElement("li");
if (i < 8) row.className = "new-row";
const button = document.createElement("button");
button.className = "result";
button.type = "button";
const file = document.createElement("span");
file.className = "file-icon";
const title = document.createElement("strong");
title.textContent = result.filename;
const path = document.createElement("span");
path.className = "path";
path.textContent = result.path;
const snippet = document.createElement("span");
snippet.className = "snippet";
snippet.textContent = result.snippet;
const kind = document.createElement("span");
kind.className = "kind";
kind.textContent = result.match_kind;
button.append(title, kind, path, snippet);
button.addEventListener("click", () => select(i));
button.append(file, title, kind, snippet, path);
button.addEventListener("click", () =>
select(Number(button.dataset.index)),
);
row.append(button);
list.append(row);
entry = { row, button, file, title, path, snippet, kind };
resultRows.set(result.path, entry);
}
entry.button.dataset.index = String(i);
entry.button.title = result.path;
entry.title.textContent = result.filename;
entry.path.textContent = result.path;
entry.snippet.textContent = result.snippet;
entry.kind.textContent = result.source
? `${result.source.kind} ${result.source.number}`
: result.match_kind;
const asset = fileIcon(result.filename);
if (entry.file.dataset.type !== asset.type) {
entry.file.innerHTML = asset.html;
entry.file.dataset.type = asset.type;
}
const positioned = list.children.item(i);
if (positioned !== entry.row) list.insertBefore(entry.row, positioned);
});
if (results.length) select(0);
list.scrollTop = scrollTop;
}
async function search() {
const id = ++requestId;
const value = query.value.trim();
if (!value) {
results = [];
render();
element("result-summary").textContent = "READY WHEN YOU ARE";
element("empty").querySelector("h2")!.textContent =
"Your next discovery starts here.";
element("empty").querySelector("p")!.textContent =
"Index a folder, then search a filename or a phrase from a document.";
return;
}
element("result-summary").textContent = "SEARCHING…";
const started = performance.now();
try {
function captureFilters() {
const input = (id: string) => element<HTMLInputElement>(id).value.trim();
const size = (id: string) => {
const text = input(id);
if (!text) return null;
const value = Number(text);
if (!Number.isSafeInteger(value) || value < 0)
throw new Error(
"File size must be a nonnegative whole number of bytes.",
);
throw new Error("File size must be a nonnegative whole number of bytes.");
return value;
};
// Date controls represent the user's local calendar day, including its end.
@@ -132,34 +454,119 @@ async function search() {
throw new Error("Enter a valid modification date.");
return stamp;
};
const found = await invoke<SearchResult[]>("search", {
query: value,
filters: {
return {
folder: input("filter-folder") || null,
extension: input("filter-extension") || null,
min_size: size("filter-min"),
max_size: size("filter-max"),
modified_after_unix_ms: date("filter-after", false),
modified_before_unix_ms: date("filter-before", true),
},
});
if (id !== requestId) return;
};
}
function applyResults(
found: SearchResult[],
request: SearchRequest,
lane: "lexical" | "hybrid",
) {
if (
request.id !== requestId ||
(lane === "lexical" && shownLane === "hybrid")
)
return;
const selectedPath = results[selected]?.path;
const resultsHadFocus = list.contains(document.activeElement);
shownLane = lane;
displayedRequestId = request.id;
results = found;
selected = -1;
render();
const preserved = results.findIndex((result) => result.path === selectedPath);
if (preserved >= 0) select(preserved, resultsHadFocus);
else if (results.length) select(0, resultsHadFocus);
element("result-summary").textContent =
`${found.length} RESULTS · ${Math.round(performance.now() - started)} MS`;
if (!found.length) {
element("empty").querySelector("h2")!.textContent =
"No matching files yet.";
element("empty").querySelector("p")!.textContent =
"Try a shorter phrase, an exact filename, or index another folder.";
}
} catch (error) {
if (id === requestId) {
`${found.length} RESULTS · ${lane === "hybrid" ? "KEYWORD + MEANING" : "KEYWORD"} · ${Math.round(performance.now() - request.started)} MS`;
if (!found.length) refreshEmptySearchState();
}
const isCurrent = (request: SearchRequest) => request.id === requestId;
const lexicalLane = new LatestLane<SearchRequest>(
async (request) => {
const found = await invoke<SearchResult[]>("search", {
query: request.query,
filters: request.filters,
});
applyResults(found, request, "lexical");
},
isCurrent,
(_request, error) => {
if (shownLane !== "hybrid") {
showError(error);
element("result-summary").textContent = "SEARCH FAILED";
element("result-summary").textContent = "KEYWORD SEARCH UNAVAILABLE";
}
},
);
const hybridLane = new LatestLane<SearchRequest>(
async (request) => {
if (!canSearchSemantically()) return;
const found = await invoke<SearchResult[]>("hybrid_search", {
query: request.query,
filters: request.filters,
});
applyResults(found, request, "hybrid");
},
isCurrent,
(_request, error) => {
querySemanticFailed = true;
refreshEmptySearchState();
element("semantic-status").textContent =
`Semantic results unavailable: ${String(error)}. Keyword results remain available.`;
queueSemanticRefresh();
},
);
function invalidateSearch() {
++requestId;
clearTimeout(timer);
clearTimeout(hybridTimer);
lexicalLane.clear();
hybridLane.clear();
latestSearch = null;
}
async function search() {
invalidateSearch();
querySemanticFailed = false;
shownLane = "lexical";
const value = query.value.trim();
if (!value) {
results = [];
displayedRequestId = null;
selected = -1;
render();
element("result-summary").textContent = "READY WHEN YOU ARE";
element("empty").querySelector("h2")!.textContent =
"Find what matters. Locally.";
element("empty").querySelector("p")!.textContent =
"Index a folder, then search a filename, phrase, or what you remember.";
return;
}
try {
const request: SearchRequest = {
id: requestId,
query: value,
filters: captureFilters(),
started: performance.now(),
};
latestSearch = request;
element("result-summary").textContent = "SEARCHING…";
lexicalLane.submit(request);
hybridTimer = setTimeout(() => {
if (isCurrent(request) && canSearchSemantically())
hybridLane.submit(request);
}, 350);
} catch (error) {
showError(error);
}
}
@@ -170,15 +577,36 @@ async function refreshStats() {
}
query.addEventListener("input", () => {
++requestId;
clearTimeout(timer);
timer = setTimeout(search, 100);
invalidateSearch();
timer = setTimeout(search, 60);
});
element("search-form").addEventListener("submit", (event) => {
event.preventDefault();
clearTimeout(timer);
void search();
});
element("clear-query").addEventListener("click", () => {
query.value = "";
void search();
query.focus();
});
document.querySelectorAll<HTMLDetailsElement>(".popover").forEach((popover) => {
popover.addEventListener("toggle", () => {
if (popover.open)
document
.querySelectorAll<HTMLDetailsElement>(".popover")
.forEach((other) => {
if (other !== popover) other.open = false;
});
});
});
document.addEventListener("pointerdown", (event) => {
document
.querySelectorAll<HTMLDetailsElement>(".popover[open]")
.forEach((popover) => {
if (!popover.contains(event.target as Node)) popover.open = false;
});
});
element("add-folder").addEventListener("click", () => {
element("folder-form").hidden = false;
element("folder").focus();
@@ -196,10 +624,26 @@ element("browse-folder").addEventListener("click", async () => {
}
});
element("filters").addEventListener("change", () => {
++requestId;
clearTimeout(timer);
void search();
});
element("retry-model").addEventListener("click", async () => {
const button = element<HTMLButtonElement>("retry-model");
button.disabled = true;
localSemanticError = null;
element("semantic-status").textContent = "Retrying Google model setup…";
queueSemanticRefresh();
try {
await invoke("setup_model");
} catch (error) {
localSemanticError = String(error);
} finally {
button.disabled = false;
queueSemanticRefresh(0);
}
});
element("rebuild-semantic").addEventListener("click", () => {
void buildSemanticIndex();
});
async function openSelected(reveal: boolean) {
if (selected < 0 || !results[selected]) return;
try {
@@ -214,6 +658,18 @@ element("open-result").addEventListener("click", () => {
element("reveal-result").addEventListener("click", () => {
void openSelected(true);
});
element("cpu-threads").addEventListener("change", async () => {
try {
await invoke<Settings>("set_cpu_threads", {
threads: Number(element<HTMLSelectElement>("cpu-threads").value),
});
await refreshDesktopStatus();
queueSemanticRefresh(0);
} catch (error) {
showError(error);
await refreshDesktopStatus().catch(showError);
}
});
element("folder-form").addEventListener("submit", async (event) => {
event.preventDefault();
const input = element<HTMLInputElement>("folder");
@@ -223,6 +679,10 @@ element("folder-form").addEventListener("submit", async (event) => {
button.disabled = true;
});
element<HTMLButtonElement>("add-folder").disabled = true;
element<HTMLSelectElement>("cpu-threads").disabled = true;
indexingActive = true;
element("index-status").textContent =
"Indexing active · Search remains available";
notice.className = "";
notice.textContent = "Indexing folder… Search remains available.";
try {
@@ -231,6 +691,8 @@ element("folder-form").addEventListener("submit", async (event) => {
});
notice.textContent = `Indexed ${report.indexed} files. ${report.removed} removed; ${report.skipped} skipped.`;
element("folder-form").hidden = true;
folderRevision++;
localSemanticError = null;
await refreshStats();
await search();
} catch (error) {
@@ -240,6 +702,9 @@ element("folder-form").addEventListener("submit", async (event) => {
button.disabled = false;
});
element<HTMLButtonElement>("add-folder").disabled = false;
await refreshDesktopStatus().catch(showError);
await refreshStats().catch(showError);
queueSemanticRefresh(0);
}
});
document.addEventListener("keydown", (event) => {
@@ -249,12 +714,25 @@ document.addEventListener("keydown", (event) => {
query.select();
}
if (event.key === "Escape") {
document
.querySelectorAll<HTMLDetailsElement>(".popover[open]")
.forEach((popover) => {
popover.open = false;
});
++requestId;
query.value = "";
void search();
query.focus();
}
if (event.target === query || list.contains(event.target as Node)) {
if (
event.key === "Enter" &&
results[selected] &&
displayedRequestId === requestId
) {
event.preventDefault();
void openSelected(false);
}
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
select(selected + (event.key === "ArrowDown" ? 1 : -1), true);
@@ -263,8 +741,25 @@ document.addEventListener("keydown", (event) => {
});
if (isTauri()) {
void refreshStats().catch(showError);
void refreshDesktopStatus().catch(showError);
void refreshSemanticStatus();
void listen("focus-search", () => {
query.focus();
query.select();
void refreshDesktopStatus().catch(showError);
queueSemanticRefresh(0);
}).catch(showError);
// Poll only during a user-triggered job; idle indexing status does no work.
setInterval(() => {
if (indexingActive) void refreshDesktopStatus().catch(showError);
}, 2000);
query.focus();
} else {
element("index-status").textContent = "Desktop preview";
element("semantic-status").textContent =
"Native semantic search runs in the desktop app.";
element("semantic-progress").hidden = true;
element("semantic-detail").textContent =
"This browser preview does not download models or return simulated search results.";
showError("Run the Tauri desktop app to index and search real files.");
}
+133
View File
@@ -0,0 +1,133 @@
/** One active request per lane; intermediate keystrokes collapse to the latest.
* Native work already running cannot be cancelled, so revisions guard every
* result and retry. Busy responses receive at most one delayed retry. */
export class LatestLane<T> {
private pending: T | undefined;
private running = false;
private run: (value: T) => Promise<void>;
private current: (value: T) => boolean;
private fail: (value: T, error: unknown) => void;
private delay: () => Promise<void>;
constructor(
run: (value: T) => Promise<void>,
current: (value: T) => boolean,
fail: (value: T, error: unknown) => void,
delay = () => new Promise<void>((resolve) => setTimeout(resolve, 400)),
) {
this.run = run;
this.current = current;
this.fail = fail;
this.delay = delay;
}
submit(value: T) {
this.pending = value;
if (!this.running) void this.drain();
}
clear() {
this.pending = undefined;
}
private async drain() {
this.running = true;
try {
while (this.pending !== undefined) {
const value = this.pending;
this.pending = undefined;
if (!this.current(value)) continue;
for (let attempt = 0; attempt < 2; attempt++) {
try {
await this.run(value);
break;
} catch (error) {
if (!this.current(value)) break;
if (
attempt === 0 &&
/busy|in progress|currently running|already running/i.test(
String(error),
)
) {
await this.delay();
if (!this.current(value) || this.pending !== undefined) break;
} else {
this.fail(value, error);
break;
}
}
}
}
} finally {
this.running = false;
}
}
}
/** An unsuccessful automatic build is not retried on every status poll. */
export class AutomaticIndexGate {
private attempted = new Set<string>();
claim(key: string) {
if (this.attempted.has(key)) return false;
this.attempted.add(key);
// Bound memory even across a very long desktop session.
if (this.attempted.size > 128)
this.attempted.delete(this.attempted.values().next().value!);
return true;
}
}
export type SemanticPhase =
| "preparing"
| "indexing"
| "pending"
| "unavailable"
| "no-content"
| "complete";
/** Zero keyword matches are provisional until the semantic lane completes. */
export function emptySearchState(
lane: "lexical" | "hybrid",
phase: SemanticPhase,
) {
const title =
lane === "lexical"
? "No keyword matches yet."
: "No matches in the current index yet.";
if (phase === "preparing")
return {
title:
lane === "lexical" ? title : "No matches in the current index yet.",
detail:
"Google semantic search is still preparing. This query will also search by meaning when it is ready.",
};
if (phase === "indexing")
return {
title:
lane === "lexical" ? title : "No matches in the current index yet.",
detail:
"Semantic indexing is underway. Results may change when your documents finish indexing.",
};
if (phase === "pending")
return {
title,
detail:
"Searching by meaning… Semantic results will appear here when this query finishes.",
};
if (phase === "unavailable")
return {
title,
detail:
"Semantic results are currently unavailable; see the model status above. Try a filename or words from your document, or retry the search.",
};
if (phase === "no-content")
return {
title,
detail:
"No document content is indexed for semantic search yet. Index a folder with readable documents, or search a filename.",
};
return {
title: lane === "hybrid" ? "No matches for this search." : title,
detail:
"Try another phrase, broaden your filters, or index another folder.",
};
}
+639 -214
View File
@@ -1,13 +1,38 @@
:root {
font-family:
Inter,
Segoe UI,
"Inter Variable",
system-ui,
-apple-system,
"Segoe UI",
sans-serif;
color: #20332e;
background: #f6f7f2;
font-synthesis: none;
color-scheme: light;
color-scheme: light dark;
--brand: #2563ff;
--canvas: #f8fafc;
--surface: #fff;
--subtle: #f1f5f9;
--selected: #e8efff;
--ink: #0b0f14;
--muted: #64748b;
--line: #e2e8f0;
--error: #b42318;
--shadow: 0 12px 36px #0b0f1418;
color: var(--ink);
background: var(--canvas);
font-size: 14px;
}
@media (prefers-color-scheme: dark) {
:root {
--canvas: #0b0f14;
--surface: #111720;
--subtle: #18212e;
--selected: #182c53;
--ink: #f8fafc;
--muted: #9cacc2;
--line: #263141;
--error: #ffaaa2;
--shadow: 0 12px 36px #00000050;
}
}
* {
box-sizing: border-box;
@@ -15,250 +40,626 @@
body {
margin: 0;
}
button,
input {
font: inherit;
#app {
height: 100dvh;
min-height: 500px;
display: flex;
flex-direction: column;
}
button {
button,
input,
select {
font: inherit;
color: inherit;
}
button,
summary {
cursor: pointer;
}
button:disabled {
cursor: wait;
opacity: 0.6;
}
header,
footer {
display: flex;
justify-content: space-between;
button {
display: inline-flex;
align-items: center;
padding: 24px 40px;
justify-content: center;
gap: 7px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 7px;
padding: 8px 12px;
transition:
background-color 120ms ease,
border-color 120ms ease;
}
button:hover,
summary:hover {
background: var(--subtle);
}
button:disabled {
opacity: 0.5;
cursor: default;
}
input,
select {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 6px;
padding: 9px 10px;
min-width: 0;
}
input::placeholder {
color: var(--muted);
}
input[type="search"]::-webkit-search-cancel-button {
display: none;
}
button:focus-visible,
input:focus-visible,
select:focus-visible,
summary:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 3px;
}
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
.icon svg {
width: 20px;
height: 20px;
fill: currentColor;
}
header {
display: flex;
align-items: center;
gap: 20px;
padding: 16px 22px 12px;
min-height: 64px;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
flex: 0 0 auto;
}
.brand-symbol {
width: 29px;
height: 29px;
object-fit: contain;
}
.wordmark {
font-size: 28px;
font-size: 20px;
font-weight: 750;
letter-spacing: -1.5px;
letter-spacing: -0.65px;
}
.dot {
color: #87a77f;
}
.privacy {
.index-status {
font-size: 12px;
color: #58705c;
color: var(--muted);
border-left: 1px solid var(--line);
padding-left: 18px;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
flex: 0 0 auto;
}
.quiet-button {
border-color: transparent;
background: transparent;
font-size: 12px;
}
.icon-button {
padding: 7px;
border-color: transparent;
background: transparent;
}
main {
max-width: 1120px;
margin: 0 auto;
padding: 24px 40px;
}
.intro {
margin: 12px 0 30px;
}
.eyebrow {
font-size: 11px;
font-weight: 700;
letter-spacing: 1.8px;
color: #78887c;
}
.intro h1 {
font-size: clamp(28px, 4vw, 42px);
letter-spacing: -1.5px;
margin: 12px 0;
}
.intro > p:last-child {
color: #738077;
margin: 0;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
padding: 0 18px;
}
#search-form {
display: flex;
align-items: center;
gap: 16px;
background: white;
border: 1px solid #d2dccf;
border-radius: 14px;
padding: 18px 22px;
box-shadow: 0 8px 30px #243b2910;
}
#query {
width: 100%;
border: 0;
background: transparent;
font-size: 19px;
color: inherit;
outline: none;
gap: 12px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 10px;
padding: 12px 15px;
min-height: 56px;
box-shadow: 0 2px 8px #0b0f1404;
}
#search-form:focus-within {
border-color: #588663;
box-shadow: 0 0 0 3px #65866922;
border-color: var(--brand);
box-shadow: 0 0 0 2px #2563ff15;
}
.search-icon {
font-size: 30px;
color: #6a8770;
#search-form > .icon {
color: var(--muted);
}
#search-form > .icon svg {
width: 24px;
height: 24px;
}
#query {
flex: 1;
min-width: 0;
padding: 0;
border: 0;
outline: none;
font-size: 18px;
background: transparent;
letter-spacing: -0.25px;
}
#query:focus-visible {
outline: none;
}
kbd {
white-space: nowrap;
color: #829086;
font-size: 11px;
border: 1px solid #dce3d9;
padding: 5px;
border-radius: 4px;
}
.toolbar {
display: flex;
justify-content: space-between;
display: inline-flex;
align-items: center;
margin: 18px 0;
font-size: 12px;
color: #78847b;
justify-content: center;
font-size: 11px;
line-height: 1.2;
padding: 4px 6px;
min-height: 23px;
border: 1px solid var(--line);
border-radius: 5px;
background: var(--surface);
color: var(--muted);
white-space: nowrap;
}
.toolbar button,
.folder-row button {
background: #e8ede2;
border: 1px solid #d9e0d2;
padding: 9px 14px;
kbd .icon svg {
width: 13px;
height: 13px;
}
.popover {
position: relative;
}
summary {
display: flex;
align-items: center;
gap: 8px;
list-style: none;
}
summary::-webkit-details-marker {
display: none;
}
.popover > summary {
border: 1px solid var(--line);
border-radius: 7px;
color: #36583c;
padding: 8px 10px;
background: var(--surface);
font-size: 12px;
}
#settings > summary {
border: 0;
background: transparent;
padding: 8px;
}
.popover-panel {
position: absolute;
right: 0;
top: calc(100% + 9px);
width: 360px;
max-width: calc(100vw - 36px);
z-index: 20;
padding: 20px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--surface);
box-shadow: var(--shadow);
max-height: calc(100vh - 120px);
overflow: auto;
animation: panel-in 160ms ease-out;
}
.popover-panel h2 {
font-size: 15px;
margin: 0 0 15px;
}
.filters-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 13px;
}
.filters-grid label {
font-size: 12px;
color: var(--muted);
}
.filters-grid input {
display: block;
width: 100%;
margin-top: 6px;
font-size: 12px;
}
.resource-label {
display: block;
font-size: 12px;
margin-bottom: 8px;
color: var(--muted);
}
#cpu-threads {
width: 100%;
}
#roots {
list-style: none;
padding: 0;
margin-bottom: 0;
}
#roots li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 9px 0;
border-top: 1px solid var(--line);
}
#roots button {
font-size: 11px;
padding: 6px 9px;
}
#semantic-panel {
margin: 8px 0 0;
font-size: 12px;
}
#semantic-panel > summary {
min-height: 34px;
padding: 5px 7px;
border-radius: 6px;
color: var(--muted);
}
#semantic-panel > summary > .icon:first-child {
color: var(--brand);
}
#semantic-status {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.semantic-content {
padding: 12px 15px;
margin: 6px 0 8px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface);
animation: panel-in 160ms ease-out;
}
.semantic-content strong {
font-size: 12px;
}
#semantic-progress {
display: block;
width: 100%;
height: 6px;
margin-top: 12px;
accent-color: var(--brand);
}
.semantic-actions {
display: flex;
gap: 8px;
}
.semantic-actions button {
font-size: 12px;
}
.hint {
font-size: 12px;
color: var(--muted);
line-height: 1.6;
}
#folder-form {
background: #eef2e9;
padding: 18px;
padding: 14px 16px;
margin: 8px 0;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 10px;
animation: panel-in 160ms ease-out;
}
#folder-form label {
font-size: 13px;
font-size: 12px;
font-weight: 600;
}
.folder-row {
display: flex;
gap: 8px;
margin-top: 10px;
margin-top: 8px;
}
.folder-row input {
min-width: 0;
flex: 1;
padding: 9px;
border: 1px solid #c9d3c3;
border-radius: 6px;
}
.hint {
color: #74806e;
.folder-row button {
font-size: 12px;
}
#notice {
font-size: 12px;
color: var(--muted);
line-height: 1.5;
margin: 4px 8px;
}
#notice:empty {
display: none;
}
.error,
#semantic-status.error {
color: var(--error);
}
.workspace {
display: grid;
grid-template-columns: minmax(0, 1.3fr) minmax(250px, 1fr);
gap: 28px;
border-top: 1px solid #e1e6db;
padding-top: 20px;
grid-template-columns: minmax(0, 0.96fr) minmax(0, 1.04fr);
flex: 1;
min-height: 0;
margin-top: 6px;
border: 1px solid var(--line);
border-radius: 10px 10px 0 0;
overflow: hidden;
background: var(--surface);
}
.workspace:has(aside[hidden]) {
grid-template-columns: 1fr;
}
.results-pane {
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
background: var(--canvas);
}
.eyebrow {
font-size: 10px;
font-weight: 600;
letter-spacing: 1px;
color: var(--muted);
}
#result-summary {
margin: 0;
padding: 12px 15px 9px;
flex: 0 0 auto;
}
#results {
list-style: none;
padding: 0;
margin: 0;
padding: 0 8px 8px;
overflow-y: auto;
flex: 1;
min-height: 0;
scrollbar-gutter: stable;
}
#results:empty {
display: none;
}
#results li {
padding: 3px 0;
}
.result {
display: grid;
grid-template-columns: 1fr auto;
grid-template-columns: 42px minmax(0, 1fr) auto;
text-align: left;
width: 100%;
padding: 16px;
min-height: 96px;
padding: 13px 12px;
background: transparent;
border: 1px solid transparent;
border-radius: 10px;
color: inherit;
gap: 8px;
border-radius: 8px;
gap: 5px 12px;
color: var(--ink);
}
.result:hover {
background: #edf1e7;
background: var(--subtle);
}
.result.selected {
background: white;
border-color: #d1dbc9;
box-shadow: 0 3px 12px #314b2810;
background: var(--selected);
border-color: #2563ff20;
}
.result .file-icon {
grid-row: 1/4;
align-self: start;
}
.file-icon {
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 7px;
background: var(--subtle);
color: var(--brand);
}
.file-icon svg {
width: 28px;
height: 28px;
}
.file-icon[data-type="pdf"] {
color: #e12e3d;
}
.file-icon[data-type="code"] {
color: var(--muted);
}
.result strong {
font-size: 14px;
overflow-wrap: anywhere;
grid-column: 2;
align-self: center;
font-size: 13px;
font-weight: 650;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.path {
font-size: 11px;
color: #79847a;
overflow-wrap: anywhere;
.kind {
font-size: 10px;
color: var(--muted);
white-space: nowrap;
}
.result .path,
.snippet {
grid-column: 1/-1;
.result .kind {
grid-column: 3;
align-self: center;
color: var(--brand);
}
.snippet {
grid-column: 2/4;
font-size: 12px;
color: #61705f;
line-height: 1.55;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.kind {
font-size: 10px;
padding: 3px 7px;
border-radius: 5px;
background: #e6eddf;
color: #638054;
.path {
font-size: 11px;
color: var(--muted);
overflow-wrap: anywhere;
}
.result .path {
grid-column: 2/4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.new-row {
animation: row-in 160ms ease-out;
}
#empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 45px 30px;
color: var(--muted);
}
#empty > .icon {
margin-bottom: 12px;
color: var(--brand);
}
#empty > .icon svg {
width: 36px;
height: 36px;
}
#empty h2 {
margin: 7px 0;
color: var(--ink);
font-size: 19px;
letter-spacing: -0.45px;
font-weight: 600;
}
#empty p {
max-width: 420px;
font-size: 13px;
line-height: 1.7;
}
aside {
padding: 18px 22px;
border-left: 1px solid #dce3d5;
display: flex;
flex-direction: column;
border-left: 1px solid var(--line);
min-width: 0;
min-height: 0;
background: var(--canvas);
}
.preview-header {
display: flex;
align-items: center;
gap: 13px;
padding: 18px 20px 10px;
}
.preview-heading {
min-width: 0;
}
aside h2 {
font-size: 20px;
margin: 0 0 5px;
font-size: 14px;
font-weight: 650;
overflow-wrap: anywhere;
}
aside pre {
font: 13px/1.8 inherit;
white-space: pre-wrap;
overflow-wrap: anywhere;
.preview-heading .path {
margin: 0;
line-height: 1.5;
}
.preview-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 0 20px 14px;
}
#preview-kind {
font-size: 12px;
color: #67875f;
font-size: 11px;
color: var(--muted);
}
#empty {
text-align: center;
padding: 38px 18px;
color: #91a088;
.preview-actions {
display: flex;
gap: 6px;
flex: 0 0 auto;
}
#empty > span {
font-size: 55px;
.preview-actions button {
font-size: 11px;
padding: 6px 9px;
}
#empty h2 {
font-size: 20px;
font-weight: 500;
color: #607358;
.preview-actions .icon svg {
width: 17px;
height: 17px;
}
#empty p {
font-size: 13px;
line-height: 1.6;
.passage {
flex: 1;
min-height: 0;
margin: 0 10px 10px;
padding: 22px 25px;
overflow: auto;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 7px;
scrollbar-gutter: stable;
}
#notice {
min-height: 18px;
font-size: 12px;
color: #627a5c;
.passage > .eyebrow {
margin: 0 0 18px;
}
#notice.error {
color: #a54639;
aside pre {
font-family: inherit;
font-size: 14px;
line-height: 1.9;
white-space: pre-wrap;
overflow-wrap: anywhere;
margin: 0;
}
footer {
color: #95a18e;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 13px 20px;
min-height: 50px;
border-top: 1px solid var(--line);
color: var(--muted);
font-size: 11px;
border-top: 1px solid #e1e6db;
margin-top: 32px;
}
button:focus-visible,
input:focus-visible {
outline: 2px solid #5b8357;
outline-offset: 3px;
.footer-privacy {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.footer-privacy > .icon {
color: var(--ink);
}
.privacy {
font-size: 10px;
border-left: 1px solid var(--line);
padding-left: 9px;
white-space: nowrap;
}
.keyboard-hints {
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.keyboard-hints > span:not(.icon) {
margin-right: 8px;
}
.sr-only {
position: absolute;
@@ -274,70 +675,94 @@ input:focus-visible {
[hidden] {
display: none !important;
}
@media (max-width: 760px) {
header,
footer {
padding: 20px;
@keyframes row-in {
from {
opacity: 0;
transform: translateY(3px);
}
main {
padding: 12px 20px;
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes panel-in {
from {
opacity: 0;
transform: translateY(-3px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 820px) {
.index-status {
display: none;
}
.header-actions {
gap: 5px;
}
.quiet-button > span:not(.icon) {
display: none;
}
.workspace {
grid-template-columns: 1fr;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
aside {
border-left: 0;
border-top: 1px solid #dce3d5;
.keyboard-hints > span {
display: none;
}
.folder-row {
flex-wrap: wrap;
.privacy {
display: none;
}
.folder-row input {
flex-basis: 100%;
.preview-actions button > span:not(.icon) {
display: none;
}
footer {
gap: 12px;
flex-wrap: wrap;
.preview-header {
padding: 14px 14px 8px;
}
.preview-toolbar {
padding: 0 14px 12px;
}
.passage {
padding: 18px;
}
}
.filters-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
padding: 16px 0;
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation: none !important;
transition: none !important;
scroll-behavior: auto !important;
}
}
.filters-grid label {
font-size: 11px;
color: #68785f;
}
.filters-grid input {
display: block;
width: 100%;
margin-top: 6px;
padding: 8px;
border: 1px solid #d5decd;
border-radius: 6px;
background: white;
}
details summary {
font-size: 12px;
color: #61795a;
cursor: pointer;
}
.preview-actions {
display: flex;
gap: 8px;
}
.preview-actions button {
font-size: 12px;
padding: 8px 12px;
border: 1px solid #d3decb;
border-radius: 6px;
background: #e9efdf;
color: #36583c;
}
@media (max-width: 760px) {
.filters-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
@media (forced-colors: active) {
:root {
--canvas: Canvas;
--surface: Canvas;
--subtle: ButtonFace;
--ink: CanvasText;
--muted: CanvasText;
--line: ButtonBorder;
--selected: Highlight;
--brand: LinkText;
--error: CanvasText;
}
.result.selected {
color: HighlightText;
border-color: Highlight;
}
.result.selected .path,
.result.selected .kind {
color: HighlightText;
}
.file-icon {
border: 1px solid ButtonBorder;
}
button:focus-visible,
input:focus-visible,
select:focus-visible,
summary:focus-visible {
outline-color: Highlight;
}
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+69
View File
@@ -0,0 +1,69 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { LatestLane, AutomaticIndexGate, emptySearchState } from '../src/search-state.ts';
const settle = () => new Promise(resolve => setImmediate(resolve));
test('coalesces queued keystrokes and discards stale responses', async () => {
let revision = 1; let release; const executed = []; const accepted = [];
const lane = new LatestLane(async value => {
executed.push(value);
if (value === 1) await new Promise(resolve => { release = resolve; });
if (value === revision) accepted.push(value);
}, value => value === revision, () => assert.fail('Unexpected failure'));
lane.submit(1); revision = 2; lane.submit(2); revision = 3; lane.submit(3);
release(); await settle();
assert.deepEqual(executed, [1, 3]); assert.deepEqual(accepted, [3]);
});
test('Escape invalidates both active and queued work', async () => {
let revision = 1; let release; const accepted = [];
const lane = new LatestLane(async value => {
await new Promise(resolve => { release = resolve; });
if (value === revision) accepted.push(value);
}, value => value === revision, () => assert.fail('Unexpected failure'));
lane.submit(1); lane.submit(1); revision++; lane.clear(); release(); await settle();
assert.deepEqual(accepted, []);
});
test('busy retries are bounded and stop when superseded', async () => {
let attempts = 0; let errors = 0;
const lane = new LatestLane(async () => { attempts++; throw new Error('worker busy'); }, () => true, () => errors++, async () => {});
lane.submit(1); await settle(); assert.equal(attempts, 2); assert.equal(errors, 1);
let revision = 1; let release; const executed = [];
const superseded = new LatestLane(async value => { executed.push(value); if (value === 1) throw new Error('busy'); }, value => value === revision, () => assert.fail('Unexpected failure'), () => new Promise(resolve => { release = resolve; }));
superseded.submit(1); await settle(); revision = 2; superseded.submit(2); release(); await settle();
assert.deepEqual(executed, [1, 2]);
});
test('automatic rebuild failures wait for a changed revision', () => {
const gate = new AutomaticIndexGate();
assert.equal(gate.claim('generation1:pending4:folder1'), true);
assert.equal(gate.claim('generation1:pending4:folder1'), false);
assert.equal(gate.claim('generation1:pending4:folder2'), true);
});
test('empty keyword results stay provisional while semantic setup or search is pending', () => {
for (const phase of ['preparing', 'indexing', 'pending']) {
const state = emptySearchState('lexical', phase);
assert.equal(state.title, 'No keyword matches yet.');
assert.match(state.detail, /preparing|indexing|Searching by meaning/);
assert.doesNotMatch(state.title, /No matching files|No matches for this search/);
}
});
test('only a completed hybrid search gives a final no-match message', () => {
const complete = emptySearchState('hybrid', 'complete');
assert.equal(complete.title, 'No matches for this search.');
assert.match(complete.detail, /filters/);
const rebuilding = emptySearchState('hybrid', 'indexing');
assert.equal(rebuilding.title, 'No matches in the current index yet.');
assert.match(rebuilding.detail, /may change/);
for (const phase of ['preparing', 'indexing', 'pending', 'unavailable', 'no-content']) {
assert.notEqual(emptySearchState('hybrid', phase).title, complete.title);
}
});
test('no document content and unavailable semantics have actionable distinct guidance', () => {
assert.match(emptySearchState('lexical', 'no-content').detail, /readable documents/);
assert.match(emptySearchState('lexical', 'unavailable').detail, /retry the search/);
});
+5 -1
View File
@@ -1,5 +1,9 @@
import { defineConfig } from "vite";
export default defineConfig({
server: { port: 1420, strictPort: true },
server: {
port: 1420,
strictPort: true,
watch: { ignored: ["**/src-tauri/**", "**/node_modules/**"] },
},
clearScreen: false,
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

+15
View File
@@ -0,0 +1,15 @@
[package]
name = "searchi-assets"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Explicit verified local model provisioning shared by desktop and CLI"
[dependencies]
anyhow.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2 = "0.10"
directories = "6"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
tempfile = "3"
+52
View File
@@ -0,0 +1,52 @@
# Searchi assets
GUI-free model provisioning shared by desktop setup and headless CLI setup.
Search never invokes this downloader automatically. Inference and GPU providers
are outside this crate; provisioning tests run on the Windows VM using CPU only.
The compiled [model manifest](../../tools/embeddinggemma2/model-manifest.json)
pins Google EmbeddingGemma 2's text GGUF and multimedia projection to one
Hugging Face export commit, exact byte lengths, and SHA-256 digests. The two
artifacts total 864,676,480 bytes. Source URLs cannot be overridden through the
public API. Executable embedding workers remain bundled release resources and
are never downloaded by this crate.
```rust,no_run
use std::sync::atomic::AtomicU64;
let directory = searchi_assets::default_model_directory()?;
let progress = AtomicU64::new(0);
if !searchi_assets::verify_cached(&directory)? {
// Only after the user explicitly chooses setup/download.
searchi_assets::provision(&directory, &progress)?;
}
# Ok::<(), anyhow::Error>(())
```
- `default_model_directory()` computes the platform-local application data path
via `directories::ProjectDirs`, then appends `models/embeddinggemma2`.
- `verify_cached(directory)` performs offline size and full-digest validation.
It does not trust a same-size file or create directories.
- `provision(directory, progress)` verifies/reuses cached artifacts and downloads
only missing or corrupt files. Call it on a blocking setup worker.
- `TOTAL_BYTES` supplies the progress total to GUI and CLI consumers.
New files are streamed through a 64 KiB buffer and rejected immediately if the
body exceeds the pinned size. Content-Length, when supplied, must also match.
The final byte count and SHA-256 must match before the temporary file is synced
and atomically replaces the destination in the same directory. A failed attempt
leaves any existing destination intact and deletes its temporary file through
RAII. Unix directory metadata is also synced after replacement.
There are three attempts per artifact, with 250/500 ms retry delays. Connections
time out after 20 seconds, and an entire request including streamed body reads
after 900 seconds. Progress includes completed verified artifacts and the current
attempt's received bytes; it resets to completed verified bytes after failure.
Hard process termination can leave orphan `.part` files, which are never treated
as valid models. Downloads resume by reusing completed verified artifacts rather
than trusting partial files.
Tests use a private local HTTP fixture and small authentic byte payloads; they
do not download the production model or rely on a public service. They verify
same-size corruption, cache reuse without URL requests, interrupted-response
retry, streaming size bounds, incorrect digests, atomic replacement, and partial
file cleanup. Linux/macOS runtime checks remain pending.
+520
View File
@@ -0,0 +1,520 @@
//! Explicit model setup shared by the GUI and headless CLI. These functions do
//! not configure an inference provider or download executable workers. Search
//! callers must never call provisioning implicitly.
use anyhow::{bail, Context, Result};
use directories::ProjectDirs;
use reqwest::blocking::Client;
use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::{
fs::{self, File},
io::{BufReader, Read, Write},
path::{Component, Path, PathBuf},
sync::atomic::{AtomicU64, Ordering},
time::Duration,
};
/// Exact bytes of the pinned text model plus its multimedia projection.
pub const TOTAL_BYTES: u64 = 864_676_480;
const ATTEMPTS: usize = 3;
#[derive(Deserialize)]
struct Manifest {
model_id: String,
export_repository: String,
revision: String,
files: Vec<Artifact>,
}
#[derive(Deserialize)]
struct Artifact {
path: String,
bytes: u64,
sha256: String,
}
fn manifest() -> Result<Manifest> {
let manifest: Manifest = serde_json::from_str(include_str!(
"../../../tools/embeddinggemma2/model-manifest.json"
))?;
if manifest.model_id != "google/embeddinggemma-2"
|| manifest.export_repository != "ggml-org/embeddinggemma-2-GGUF"
|| manifest.revision.len() != 40
|| !manifest
.revision
.bytes()
.all(|byte| byte.is_ascii_hexdigit())
|| manifest.files.len() != 2
|| manifest
.files
.iter()
.map(|artifact| artifact.bytes)
.sum::<u64>()
!= TOTAL_BYTES
{
bail!("invalid bundled model manifest");
}
for artifact in &manifest.files {
validate_artifact(artifact)?;
if !matches!(
artifact.path.as_str(),
"embeddinggemma-2-Q8_0.gguf" | "mmproj-embeddinggemma-2-Q8_0.gguf"
) {
bail!("bundled model manifest contains an unexpected artifact");
}
}
if manifest.files[0].path == manifest.files[1].path {
bail!("duplicate model manifest artifact");
}
Ok(manifest)
}
fn validate_artifact(artifact: &Artifact) -> Result<()> {
let mut components = Path::new(&artifact.path).components();
if !matches!(components.next(), Some(Component::Normal(_)))
|| components.next().is_some()
|| artifact.path.contains(['/', '\\'])
|| artifact.bytes == 0
|| artifact.sha256.len() != 64
|| !artifact.sha256.bytes().all(|byte| byte.is_ascii_hexdigit())
{
bail!("invalid artifact path, size, or digest");
}
Ok(())
}
/// Platform-local application data, consistently used by CLI and desktop.
/// This computes the path without creating files or accessing the network.
pub fn default_model_directory() -> Result<PathBuf> {
let project = ProjectDirs::from("dev", "Searchi", "Searchi")
.context("platform application data directory is unavailable")?;
Ok(project
.data_local_dir()
.join("models")
.join("embeddinggemma2"))
}
/// Offline validation includes file length and full SHA-256, not mere existence.
pub fn verify_cached(directory: &Path) -> Result<bool> {
for artifact in manifest()?.files {
if !verified(&directory.join(&artifact.path), &artifact)? {
return Ok(false);
}
}
Ok(true)
}
fn verified(path: &Path, artifact: &Artifact) -> Result<bool> {
let file = match File::open(path) {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => return Err(error).context("open cached model artifact"),
};
if file.metadata()?.len() != artifact.bytes {
return Ok(false);
}
let mut reader = BufReader::new(file).take(artifact.bytes + 1);
let mut hash = Sha256::new();
let mut size = 0u64;
let mut buffer = [0u8; 65536];
loop {
let read = reader.read(&mut buffer)?;
if read == 0 {
break;
}
size += read as u64;
if size > artifact.bytes {
return Ok(false);
}
hash.update(&buffer[..read]);
}
Ok(size == artifact.bytes
&& format!("{:x}", hash.finalize()).eq_ignore_ascii_case(&artifact.sha256))
}
/// Explicit blocking setup: pinned HTTPS artifacts, three attempts per missing
/// or corrupt artifact, streaming size/SHA checks, atomic replacement. Progress
/// is completed verified bytes plus bytes of the current attempt. On failure it
/// resets to verified completed bytes; cached files never trigger network access.
pub fn provision(directory: &Path, progress: &AtomicU64) -> Result<()> {
let manifest = manifest()?;
let client = Client::builder()
.connect_timeout(Duration::from_secs(20))
.timeout(Duration::from_secs(900))
.user_agent(concat!("Searchi/", env!("CARGO_PKG_VERSION")))
.build()?;
provision_with(directory, progress, &manifest, &client, |artifact| {
format!(
"https://huggingface.co/{}/resolve/{}/{}",
manifest.export_repository, manifest.revision, artifact.path
)
})
}
// URL injection is private so tests can use a local HTTP fixture. Production
// exposes no arbitrary download source or unverified executable provisioner.
fn provision_with(
directory: &Path,
progress: &AtomicU64,
manifest: &Manifest,
client: &Client,
url: impl Fn(&Artifact) -> String,
) -> Result<()> {
fs::create_dir_all(directory).context("create model cache directory")?;
progress.store(0, Ordering::Release);
let mut completed = 0;
for artifact in &manifest.files {
validate_artifact(artifact)?;
let destination = directory.join(&artifact.path);
if verified(&destination, artifact)? {
completed += artifact.bytes;
progress.store(completed, Ordering::Release);
continue;
}
let mut last_error = None;
for attempt in 0..ATTEMPTS {
match download_one(
&destination,
artifact,
progress,
completed,
client,
&url(artifact),
) {
Ok(()) => {
last_error = None;
break;
}
Err(error) => {
last_error = Some(error);
progress.store(completed, Ordering::Release);
if attempt + 1 < ATTEMPTS {
std::thread::sleep(Duration::from_millis(250 << attempt));
}
}
}
}
if let Some(error) = last_error {
return Err(error).with_context(|| {
format!(
"model artifact {} failed after {ATTEMPTS} attempts",
artifact.path
)
});
}
completed += artifact.bytes;
progress.store(completed, Ordering::Release);
}
Ok(())
}
fn download_one(
destination: &Path,
artifact: &Artifact,
progress: &AtomicU64,
completed: u64,
client: &Client,
url: &str,
) -> Result<()> {
let mut response = client
.get(url)
.send()
.and_then(reqwest::blocking::Response::error_for_status)
.map_err(reqwest::Error::without_url)
.context("request model artifact")?;
if response
.content_length()
.is_some_and(|size| size != artifact.bytes)
{
bail!("model artifact response length differs from pinned size");
}
let directory = destination
.parent()
.context("model artifact has no parent directory")?;
let mut output = tempfile::Builder::new()
.prefix(".searchi-model-")
.suffix(".part")
.tempfile_in(directory)?;
let mut hash = Sha256::new();
let mut downloaded = 0;
let mut buffer = [0u8; 65536];
loop {
let read = response
.read(&mut buffer)
.context("read model artifact stream")?;
if read == 0 {
break;
}
downloaded += read as u64;
if downloaded > artifact.bytes {
bail!("model artifact exceeds pinned size");
}
output.write_all(&buffer[..read])?;
hash.update(&buffer[..read]);
progress.store(completed + downloaded, Ordering::Release);
}
if downloaded != artifact.bytes
|| !format!("{:x}", hash.finalize()).eq_ignore_ascii_case(&artifact.sha256)
{
bail!("model artifact size or SHA-256 mismatch");
}
output.as_file().sync_all()?;
output
.persist(destination)
.map_err(|error| error.error)
.context("atomically replace model artifact")?;
#[cfg(unix)]
File::open(directory)?.sync_all()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::{net::TcpListener, sync::atomic::AtomicUsize, thread, time::Instant};
fn artifact(path: &str, bytes: &[u8]) -> Artifact {
Artifact {
path: path.into(),
bytes: bytes.len() as u64,
sha256: format!("{:x}", Sha256::digest(bytes)),
}
}
fn test_manifest(files: Vec<Artifact>) -> Manifest {
Manifest {
model_id: "test".into(),
export_repository: "test".into(),
revision: "test".into(),
files,
}
}
fn client() -> Client {
Client::builder()
.no_proxy()
.connect_timeout(Duration::from_secs(1))
.timeout(Duration::from_secs(2))
.build()
.unwrap()
}
// Local HTTP only. The production API does not expose this override.
fn server(responses: Vec<Vec<u8>>) -> (String, thread::JoinHandle<usize>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}/fixture", listener.local_addr().unwrap());
listener.set_nonblocking(true).unwrap();
let handle = thread::spawn(move || {
let mut count = 0;
for response in responses {
let deadline = Instant::now() + Duration::from_secs(5);
let mut stream = loop {
match listener.accept() {
Ok((stream, _)) => break stream,
Err(error)
if error.kind() == std::io::ErrorKind::WouldBlock
&& Instant::now() < deadline =>
{
thread::sleep(Duration::from_millis(2))
}
Err(error) => panic!("local fixture accept failed: {error}"),
}
};
stream.set_nonblocking(false).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(1)))
.unwrap();
let mut request = Vec::new();
let mut buffer = [0u8; 1024];
while !request.windows(4).any(|window| window == b"\r\n\r\n") {
let read = stream.read(&mut buffer).unwrap();
assert!(read > 0);
request.extend_from_slice(&buffer[..read]);
assert!(request.len() <= 8192);
}
stream.write_all(&response).unwrap();
count += 1;
}
count
});
(url, handle)
}
fn response(body: &[u8], declared: Option<usize>) -> Vec<u8> {
let mut response = b"HTTP/1.1 200 OK\r\nConnection: close\r\n".to_vec();
if let Some(length) = declared {
response.extend_from_slice(format!("Content-Length: {length}\r\n").as_bytes());
}
response.extend_from_slice(b"\r\n");
response.extend_from_slice(body);
response
}
fn assert_no_part_files(directory: &Path) {
assert!(!fs::read_dir(directory).unwrap().any(|entry| entry
.unwrap()
.file_name()
.to_string_lossy()
.ends_with(".part")));
}
#[test]
fn manifest_is_exact_pinned_google_text_and_media_contract() {
let manifest = manifest().unwrap();
assert_eq!(
manifest.revision,
"bfcd298762cc34d0357ece5ebdd31791a3a374d8"
);
assert_eq!(
manifest
.files
.iter()
.map(|artifact| artifact.bytes)
.sum::<u64>(),
TOTAL_BYTES
);
assert_eq!(
manifest.files[0].sha256,
"2188ac1deca4b77dffefd603c2776a9d76d9d74ec01841392982ebb840b09135"
);
assert_eq!(
manifest.files[1].sha256,
"c4a8a52691ecef40618438928bdf9e68379b854e24166f292592353db0aab64f"
);
}
#[test]
fn same_size_corruption_is_detected_and_missing_cache_is_offline() {
let directory = tempfile::tempdir().unwrap();
assert!(!verify_cached(directory.path()).unwrap());
let artifact = artifact("model.gguf", b"abc");
let path = directory.path().join(&artifact.path);
fs::write(&path, b"abc").unwrap();
assert!(verified(&path, &artifact).unwrap());
fs::write(&path, b"xyz").unwrap();
assert!(!verified(&path, &artifact).unwrap());
}
#[test]
fn cached_valid_artifacts_do_not_request_a_url() {
let directory = tempfile::tempdir().unwrap();
fs::write(directory.path().join("one.gguf"), b"abc").unwrap();
let manifest = test_manifest(vec![artifact("one.gguf", b"abc")]);
let calls = AtomicUsize::new(0);
let progress = AtomicU64::new(999);
provision_with(directory.path(), &progress, &manifest, &client(), |_| {
calls.fetch_add(1, Ordering::Relaxed);
"http://127.0.0.1:1/unreachable".into()
})
.unwrap();
assert_eq!(calls.load(Ordering::Relaxed), 0);
assert_eq!(progress.load(Ordering::Acquire), 3);
}
#[test]
fn interrupted_attempt_retries_and_atomically_replaces_corrupt_cache() {
let directory = tempfile::tempdir().unwrap();
fs::write(directory.path().join("one.gguf"), b"badbad").unwrap();
fs::write(directory.path().join("two.gguf"), b"abcdef").unwrap();
let manifest = test_manifest(vec![
artifact("one.gguf", b"abcdef"),
artifact("two.gguf", b"abcdef"),
]);
let (url, server) = server(vec![
response(b"abc", Some(6)),
response(b"abcdef", Some(6)),
]);
let progress = AtomicU64::new(0);
provision_with(directory.path(), &progress, &manifest, &client(), |_| {
url.clone()
})
.unwrap();
assert_eq!(server.join().unwrap(), 2);
assert_eq!(progress.load(Ordering::Acquire), 12);
assert_eq!(
fs::read(directory.path().join("one.gguf")).unwrap(),
b"abcdef"
);
assert_no_part_files(directory.path());
}
#[test]
fn oversized_stream_is_bounded_and_failure_cleans_partial_files() {
let directory = tempfile::tempdir().unwrap();
fs::write(directory.path().join("one.gguf"), b"old").unwrap();
let manifest = test_manifest(vec![artifact("one.gguf", b"abc")]);
let (url, server) = server(vec![response(b"abcde", None); ATTEMPTS]);
let progress = AtomicU64::new(0);
let error = provision_with(directory.path(), &progress, &manifest, &client(), |_| {
url.clone()
})
.unwrap_err();
assert!(format!("{error:#}").contains("exceeds pinned size"));
assert_eq!(server.join().unwrap(), ATTEMPTS);
assert_eq!(progress.load(Ordering::Acquire), 0);
assert_eq!(fs::read(directory.path().join("one.gguf")).unwrap(), b"old");
assert_no_part_files(directory.path());
}
#[test]
fn wrong_digest_cannot_be_installed_even_with_correct_size() {
let directory = tempfile::tempdir().unwrap();
let manifest = test_manifest(vec![artifact("one.gguf", b"abc")]);
let (url, server) = server(vec![response(b"xyz", Some(3)); ATTEMPTS]);
let progress = AtomicU64::new(0);
let error = provision_with(directory.path(), &progress, &manifest, &client(), |_| {
url.clone()
})
.unwrap_err();
assert!(format!("{error:#}").contains("SHA-256 mismatch"));
assert_eq!(server.join().unwrap(), ATTEMPTS);
assert!(!directory.path().join("one.gguf").exists());
assert_no_part_files(directory.path());
}
#[test]
fn completed_artifact_survives_later_failure_and_is_reused_on_retry() {
let directory = tempfile::tempdir().unwrap();
let manifest = test_manifest(vec![
artifact("one.gguf", b"abc"),
artifact("two.gguf", b"def"),
]);
let mut responses = vec![response(b"abc", Some(3))];
responses.extend(vec![response(b"bad", Some(3)); ATTEMPTS]);
let (url, first_server) = server(responses);
let progress = AtomicU64::new(0);
assert!(
provision_with(directory.path(), &progress, &manifest, &client(), |_| url
.clone())
.is_err()
);
assert_eq!(first_server.join().unwrap(), ATTEMPTS + 1);
assert_eq!(progress.load(Ordering::Acquire), 3);
assert_eq!(fs::read(directory.path().join("one.gguf")).unwrap(), b"abc");
assert!(!directory.path().join("two.gguf").exists());
let (url, second_server) = server(vec![response(b"def", Some(3))]);
provision_with(
directory.path(),
&progress,
&manifest,
&client(),
|artifact| {
assert_eq!(artifact.path, "two.gguf");
url.clone()
},
)
.unwrap();
assert_eq!(second_server.join().unwrap(), 1);
assert_eq!(progress.load(Ordering::Acquire), 6);
assert_no_part_files(directory.path());
}
#[test]
fn unsafe_artifact_names_are_rejected_and_default_path_is_platform_local() {
assert!(validate_artifact(&artifact("../outside", b"abc")).is_err());
assert!(validate_artifact(&artifact("..\\outside", b"abc")).is_err());
assert!(default_model_directory()
.unwrap()
.ends_with("models/embeddinggemma2"));
}
}
+2
View File
@@ -13,4 +13,6 @@ path = "src/main.rs"
anyhow.workspace = true
clap = { version = "4", features = ["derive"] }
searchi-core = { path = "../searchi-core" }
searchi-service = { path = "../searchi-service" }
searchi-assets = { path = "../searchi-assets" }
serde_json.workspace = true
+90 -10
View File
@@ -5,6 +5,7 @@ use std::path::PathBuf;
use anyhow::{bail, Context, Result};
use clap::{Parser, Subcommand};
use searchi_core::{IndexOptions, SearchIndex, SearchOptions};
use searchi_service::HybridSearch;
#[derive(Debug, Parser)]
#[command(name = "searchi", version, about = "Search your local files offline")]
@@ -15,6 +16,12 @@ struct Cli {
#[derive(Debug, Subcommand)]
enum Command {
/// Set up the required Google model in the default local cache.
Setup {
/// Override the platform-local model cache directory.
#[arg(long)]
model_dir: Option<PathBuf>,
},
/// Discover files and refresh an index for one folder.
Index {
/// SQLite index file; parent directory must exist.
@@ -54,6 +61,25 @@ enum Command {
modified_after_ms: Option<i64>,
#[arg(long)]
modified_before_ms: Option<i64>,
/// Enable hybrid search with explicitly provisioned local model assets.
#[arg(long, requires = "embedding_worker")]
model_dir: Option<PathBuf>,
/// Local native Google embedding worker, required with --model-dir.
#[arg(long, requires = "model_dir")]
embedding_worker: Option<PathBuf>,
#[arg(long, default_value_t = 1, value_parser = clap::value_parser!(u8).range(1..=8))]
cpu_threads: u8,
},
/// Build a persistent semantic generation from already indexed passages.
Embed {
#[arg(long)]
db: PathBuf,
#[arg(long)]
model_dir: PathBuf,
#[arg(long)]
embedding_worker: PathBuf,
#[arg(long, default_value_t = 1, value_parser = clap::value_parser!(u8).range(1..=8))]
cpu_threads: u8,
},
/// Show the indexed file and content counts.
Stats {
@@ -66,6 +92,15 @@ enum Command {
fn main() -> Result<()> {
match Cli::parse().command {
Command::Setup { model_dir } => {
let directory = model_dir
.map(Ok)
.unwrap_or_else(searchi_assets::default_model_directory)?;
let progress = std::sync::atomic::AtomicU64::new(0);
eprintln!("Setting up Google EmbeddingGemma 2; cached assets are reused (865 MB maximum download).");
searchi_assets::provision(&directory, &progress)?;
println!("Google model ready: {}", directory.display());
}
Command::Index { db, root, exclude } => {
let mut options = IndexOptions::default();
options.excluded_directory_names.extend(exclude);
@@ -74,6 +109,9 @@ fn main() -> Result<()> {
let report = index
.index_folder_with_options(&root, &options)
.with_context(|| format!("Cannot index folder {}", root.display()))?;
index
.prewarm()
.context("Cannot prepare search statistics")?;
println!(
"Discovered: {} Indexed: {} Unchanged: {} Removed: {} Skipped: {} Extraction failures: {}",
report.discovered, report.indexed, report.unchanged, report.removed, report.skipped, report.failures
@@ -90,27 +128,32 @@ fn main() -> Result<()> {
max_size,
modified_after_ms,
modified_before_ms,
model_dir,
embedding_worker,
cpu_threads,
} => {
if query.trim().is_empty() {
bail!("Search text cannot be empty");
}
require_existing_index(&db)?;
let index = SearchIndex::open(&db)
.with_context(|| format!("Cannot open index {}", db.display()))?;
let results = index
.search_with_options(
&query,
usize::from(limit),
&SearchOptions {
let options = SearchOptions {
folder,
extension,
min_size,
max_size,
modified_after_unix_ms: modified_after_ms,
modified_before_unix_ms: modified_before_ms,
},
)
.context("Search failed")?;
};
let results = if let (Some(model), Some(runtime)) = (model_dir, embedding_worker) {
HybridSearch::open(&db, model, runtime, usize::from(cpu_threads))?
.search(&query, usize::from(limit), &options)
.context("Hybrid search failed")?
} else {
SearchIndex::open(&db)
.with_context(|| format!("Cannot open index {}", db.display()))?
.search_with_options(&query, usize::from(limit), &options)
.context("Search failed")?
};
if json {
println!("{}", serde_json::to_string_pretty(&results)?);
} else if results.is_empty() {
@@ -130,6 +173,19 @@ fn main() -> Result<()> {
}
}
}
Command::Embed {
db,
model_dir,
embedding_worker,
cpu_threads,
} => {
require_existing_index(&db)?;
let report =
HybridSearch::open(&db, model_dir, embedding_worker, usize::from(cpu_threads))?
.index_embeddings()
.context("Semantic indexing failed; prior generation remains active")?;
println!("{}", serde_json::to_string_pretty(&report)?);
}
Command::Stats { db, json } => {
require_existing_index(&db)?;
let index = SearchIndex::open(&db)
@@ -173,6 +229,30 @@ mod tests {
}
}
#[test]
fn requires_complete_semantic_configuration_and_bounded_cpu_budget() {
assert!(Cli::try_parse_from([
"searchi",
"search",
"--db",
"index.db",
"query",
"--model-dir",
"models"
])
.is_err());
assert!(Cli::try_parse_from([
"searchi",
"search",
"--db",
"index.db",
"query",
"--cpu-threads",
"0"
])
.is_err());
}
#[test]
fn supports_json_search_and_quoted_query() {
let cli = Cli::try_parse_from([
+24 -10
View File
@@ -4,7 +4,7 @@ The core has no desktop or inference dependencies. Its current retrieval engine
combines literal filename matching with SQLite FTS5 BM25 keyword search. It does
not implement semantic search.
`SearchIndex::open` creates or migrates schema version 7. Current-schema opens do
`SearchIndex::open` creates or migrates schema version 9. Current-schema opens do
not acquire a write lock. WAL allows independent readers to use the committed
index while reconciliation runs. All indexing changes are committed atomically;
a traversal or plain-text file read error rolls back the entire reconciliation.
@@ -82,20 +82,33 @@ Exact filenames always precede other lanes. Ties use persistent file row IDs,
and keyword ties use relevance then row ID. Selected rows are deduplicated.
Filename/path substrings of at least three Unicode characters use an
external-content trigram FTS index. The rarest known query trigram supplies a
candidate superset; the whole literal substring is verified on the candidate.
external-content trigram FTS index. A positional anchor grows around the rarest
known query trigram, admitting only neighboring grams within four times its
document frequency (with a small floor) and at most 32 Unicode characters.
This rejects repeated-identifier false candidates inside FTS without extending
through dense common suffix postings. The anchor supplies a candidate superset;
the whole literal substring is still verified on each candidate.
At most 256 unique query trigrams are inspected. This lookup budget does not cap
candidate rows or hide matching results. All size/type/folder/date filters apply
before each lane's SQL LIMIT. Shorter substrings retain complete metadata scans
and can be slow. Broad queries under restrictive filters can also examine many
matching candidates; no universal filtered-query latency claim is made.
Trigram frequencies use the FTS vocabulary and a bounded 4,096-term cache on each
`SearchIndex`. Initial frequency lookups can be expensive; the cache is cleared
after reconciliation. Keep an independent reader engine alive across searches to
benefit from warm behavior. These frequency estimates affect candidate selection
cost, not correctness. A source query batches all finalists' indexed passage IDs
and selects actual matching passages before choosing one source per file.
`prewarm()` refreshes persisted trigram frequencies from the FTS vocabulary only
when names/path membership is dirty, then loads a bounded 4,096-term reader cache.
Foreground frequency misses use an indexed persisted table, not vocabulary
postings scans. Name/path insert/delete/change marks statistics dirty; content
changes alone do not. Statistics affect candidate cost, not match correctness.
Background preparation exercises actual first/last filenames, alphabetic name
tokens, and three words of the first document. UI callers should complete this
preparation before enabling interaction. Representative inputs are disclosed in
the benchmark report; preparation is not counted as foreground query time.
Unfiltered content retrieval materializes ranked row IDs before reading finalist
metadata and snippets. Filtered requests retain complete filter-before-LIMIT
ranking. Tests compare ordering, scores, snippets, and sources between both paths.
A source query batches all finalists' indexed passage IDs and chooses one actual
matching source per file. Keep an independent reader alive across searches.
Migration to the new indexes preserves file content and passage IDs and rebuilds
both filename/path and passage indexes atomically. Large migrations can take time
@@ -105,7 +118,8 @@ opens. The measured Windows synthetic fixture grew from about 295 MiB to 605 MiB
## Known limits
- Short substring queries and broad restrictive filters can still scan many
metadata candidates. Cold frequency-cache fills exceed the warm filename target.
metadata candidates. Statistics preparation may take seconds after large scans;
arbitrary unseen or OS-cold queries have no universal latency guarantee.
- Files changed without changing size or modification time require a future
forced refresh/content-fingerprint option. Timestamp precision depends on the
filesystem. Indexing does not yet run a filesystem watcher.
+124
View File
@@ -102,3 +102,127 @@ is still not established. Query distribution and filters are not represented by
this four-case fixture; restrictive broad filters and sub-three-character searches
remain potentially slow, while correctness tests ensure late matching files are
not lost through arbitrary pre-filter caps.
## Actual first foreground inputs, schema 8
The schema-8 run persists trigram frequency statistics in background preparation
before allowing the measured inputs. Preparation took **17.21 seconds** and
loaded 1,268 frequency entries. That time is reported separately and is not
subtracted from a foreground query. The same 1M/100K fixture was reused.
The harness measures 20 different exact filenames, spread across the corpus,
with no preceding query warmups. It then measures 20 different filenames, each
on a newly opened reader without calling `prewarm` again, using persisted stats.
This second set includes reader-open time. Both sets validate exact-match priority
and record every raw query and duration. They are disjoint from each other and
from the four-case exact query for this full fixture.
| Distinct first-input set | p50 | p95 | Actual very first input | Samples ≥50 ms |
| --- | ---: | ---: | ---: | ---: |
| Prepared reader, 20 names | 13.96 ms | 18.97 ms | **98.49 ms** | 1/20 |
| Fresh readers, 20 other names | 16.78 ms | 22.73 ms | **103.57 ms** | 1/20 |
The p95 filename targets pass, but the very first zero-heavy filename in each
set fails the 50 ms target. The nearest-rank p95 excludes one maximum sample
when there are 20 samples, so p95 alone must not be used to claim that the actual
first input is always fast. Explicit first-sample flags and maximum durations
are included in the report, derived directly from the recorded raw samples.
| Existing case, first execution before its warmups | First input | First target | Repeated warm p95 |
| --- | ---: | --- | ---: |
| Exact filename at end of corpus | 16.31 ms | Passed <50 ms | 13.80 ms |
| Broad filename substring | 13.01 ms | Passed <50 ms | 0.47 ms |
| Unique content token | 14.07 ms | Passed <150 ms | 0.49 ms |
| Broad keyword | **264.53 ms** | **Failed <150 ms** | 145.19 ms |
These measurements expose remaining first-input work even after frequency
preparation. Zero-heavy names produce larger candidate sets, and broad content
retrieval has a first-use penalty. This report does not establish the requested
fast-first-input behavior as complete. It also does not represent a cold OS
page-cache test: background preparation and previous operations can warm disk
pages. Host workload isolation remains unverified. The retained report is
`windows-cpu-full-prepared-v8.json`; engine code was not changed during this run.
## Background representative search and restart cost, schema 9
Schema 9 refreshes frequency statistics only when filename/path membership is
dirty. It also runs representative searches in background. The report explicitly
records the actual five background inputs from this corpus: `invoice_0000000.txt`,
`invoice_0999999.txt`, `Supplier`, `termination`, and `cancellation`. Consequently
the later first foreground executions of overlapping inputs are prepared inputs,
not never-before-executed queries. This behavior is intentional application
preparation, rather than an undisclosed benchmark warmup.
Initial preparation took **17.723 seconds**. A second `prewarm()` on a newly
opened reader took **0.396 seconds**, reusing clean persisted statistics. That
second call occurred only after all foreground measurements, so it could not
improve their recorded first timings. Startup time is amortized when names have
not changed; refresh after mutations still has an explicit background cost.
| Twenty distinct foreground names | p50 | p95 | Very first name | ≥50 ms |
| --- | ---: | ---: | ---: | ---: |
| Prepared reader | 12.75 ms | 14.40 ms | **85.38 ms** | 1/20 |
| Newly reopened readers | 16.31 ms | 31.42 ms | **100.07 ms** | 1/20 |
The remaining zero-heavy filename outliers fail the first-input target even
after representative preparation. Repeating that representative query in
background does not eliminate its candidate-processing cost. The 20 distinct
query sets remain unchanged from schema 8, and every raw sample and explicit
first/max failure flag is retained.
| Existing case | First foreground | Repeated p95 | Target |
| --- | ---: | ---: | --- |
| Exact filename at corpus end | 13.48 ms | 13.22 ms | Passed <50 ms |
| Filename substring | 3.20 ms | 0.43 ms | Passed <50 ms |
| Unique content token | 2.57 ms | 0.53 ms | Passed <150 ms |
| Broad keyword | 99.10 ms | 106.20 ms | Passed <150 ms |
Background initialization fixes the observed broad-keyword first-use penalty,
but the full fast-first-input requirement remains incomplete because of the
filename outliers. Further algorithmic filename work is required; the report
does not infer that preparation makes every unseen query fast. This Windows
CPU-only run is stored in `windows-cpu-full-prepared-v9.json`.
## Positional candidate correction and finalist projection
Extending the rarest trigram through the entire suffix regressed retrieval:
the unchanged 20-name sets had p95 **136.60/129.17 ms**, and the first reopened
zero-heavy name took **554.32 ms**. That rejected experiment remains recorded in
`windows-cpu-full-suffix-anchor.json`. A long phrase can initialize dense suffix
postings even when its first term is rare.
The replacement grows a positional anchor only through neighboring grams within
a bounded frequency factor, with a maximum of 32 Unicode characters. Full literal
verification and filter-before-limit completeness remain unchanged. Temporary
lane profiling separated cheap indexed equality (about 0.06 ms in the diagnostic
run) from first-use broad-prefix filename retrieval (about 67 ms). Generic
alphabetic tokens derived from representative names now initialize those common
postings in background. Unfiltered content searches rank compact IDs before
materializing finalist paths/snippets; filtered ranking is kept complete.
The final unchanged 1M/100K corpus run uses these disclosed background inputs:
first/last filenames, `invoice`, `txt`, and the first document words `Supplier`,
`termination`, and `cancellation`. Clean-statistics preparation took **0.709 s**;
a subsequent new-reader preparation took **0.264 s**. Earlier initial dirty
statistics refresh still took roughly 17 seconds; this run does not erase that
startup/refresh cost.
| Twenty distinct first inputs | p95 | Very first | Maximum | Samples ≥50 ms |
| --- | ---: | ---: | ---: | ---: |
| Prepared reader | 18.15 ms | 6.14 ms | 22.94 ms | 0/20 |
| Newly reopened readers, including open | 30.86 ms | 30.86 ms | 34.28 ms | 0/20 |
| Existing case | First foreground | Repeated p95 | Target |
| --- | ---: | ---: | --- |
| Exact filename at corpus end | 4.37 ms | 3.77 ms | Passed <50 ms |
| Filename substring | 14.18 ms | 1.23 ms | Passed <50 ms |
| Unique content token | 10.06 ms | 0.72 ms | Passed <150 ms |
| Broad keyword | 114.44 ms | 117.88 ms | Passed <150 ms |
All measured first-input samples and repeated-query p95 values pass in this
Windows CPU run. The retained final report is
`windows-cpu-full-candidate-first-final.json`, with every raw filename/sample.
This establishes the measured fixture result, not a guarantee for all queries,
filters, corpus distributions, cold OS caches, or other operating systems. The
intermediate density-only reports retain remaining failures and are not promoted
as final acceptance evidence.
@@ -0,0 +1,179 @@
{
"background_prepare_seconds": 0.7093476,
"reopened_background_prepare_seconds": 0.2639451,
"background_representative_queries": [
"invoice_0000000.txt",
"invoice_0999999.txt",
"invoice",
"txt",
"Supplier",
"termination",
"cancellation"
],
"prepared_frequency_count": 1268,
"first_foreground_distinct_names": {
"description": "distinct foreground filenames; no benchmark query warmups; production background representative queries may overlap",
"includes_reader_open": false,
"queries": [
"invoice_0000000.txt",
"invoice_0049999.txt",
"invoice_0099999.txt",
"invoice_0149999.txt",
"invoice_0199999.txt",
"invoice_0249999.txt",
"invoice_0299999.txt",
"invoice_0349999.txt",
"invoice_0399999.txt",
"invoice_0449999.txt",
"invoice_0499999.txt",
"invoice_0549998.txt",
"invoice_0599998.txt",
"invoice_0649998.txt",
"invoice_0699998.txt",
"invoice_0749998.txt",
"invoice_0799998.txt",
"invoice_0849998.txt",
"invoice_0899998.txt",
"invoice_0949998.txt"
],
"samples_ms": [
6.1442,
18.1445,
3.5369,
14.9677,
12.8753,
13.4905,
9.2402,
10.4807,
8.5722,
11.1046,
4.2845,
22.9355,
12.088099999999999,
13.8535,
9.436399999999999,
13.2516,
11.2014,
10.591899999999999,
9.587399999999999,
9.6533
],
"p50_ms": 10.591899999999999,
"p95_ms": 18.1445,
"first_sample_ms": 6.1442,
"first_sample_target_met": true,
"maximum_ms": 22.9355,
"samples_exceeding_target": 0,
"target_p95_ms": 50.0,
"target_met": true
},
"reopened_reader_first_names": {
"description": "each never-repeated filename on a newly opened reader, persisted statistics, no prewarm; includes open+search",
"includes_reader_open": true,
"queries": [
"invoice_0000001.txt",
"invoice_0050000.txt",
"invoice_0100000.txt",
"invoice_0150000.txt",
"invoice_0200000.txt",
"invoice_0250000.txt",
"invoice_0300000.txt",
"invoice_0350000.txt",
"invoice_0400000.txt",
"invoice_0450000.txt",
"invoice_0500000.txt",
"invoice_0549999.txt",
"invoice_0599999.txt",
"invoice_0649999.txt",
"invoice_0699999.txt",
"invoice_0749999.txt",
"invoice_0799999.txt",
"invoice_0849999.txt",
"invoice_0899999.txt",
"invoice_0949999.txt"
],
"samples_ms": [
30.8552,
28.1971,
34.2776,
14.4363,
29.89,
13.669,
28.3825,
9.8496,
28.3787,
13.243400000000001,
21.4598,
7.0038,
6.1826,
6.6606,
6.697,
6.902,
7.950900000000001,
6.4144,
6.8307,
7.2419
],
"p50_ms": 9.8496,
"p95_ms": 30.8552,
"first_sample_ms": 30.8552,
"first_sample_target_met": true,
"maximum_ms": 34.2776,
"samples_exceeding_target": 0,
"target_p95_ms": 50.0,
"target_met": true
},
"fixture": "synthetic SQLite metadata and text; no filesystem extraction or semantic inference",
"files": 1000000,
"documents": 100000,
"seed_seconds": 0.0,
"migration_seconds": 0.0110352,
"fixture_reused": true,
"database_bytes": 634449920,
"database_path": "target/benchmarks/full.db",
"architecture": "x86_64",
"os": "windows",
"cpu_only": true,
"queries": [
{
"query": "invoice_0999999.txt",
"samples": 20,
"p50_ms": 2.3605,
"first_query_ms": 4.368399999999999,
"first_query_target_met": true,
"p95_ms": 3.7694,
"target_p95_ms": 50.0,
"target_met": true
},
{
"query": "invoice_",
"samples": 20,
"p50_ms": 0.7753,
"first_query_ms": 14.1849,
"first_query_target_met": true,
"p95_ms": 1.228,
"target_p95_ms": 50.0,
"target_met": true
},
{
"query": "marker0099999",
"samples": 20,
"p50_ms": 0.47550000000000003,
"first_query_ms": 10.0635,
"first_query_target_met": true,
"p95_ms": 0.7228,
"target_p95_ms": 150.0,
"target_met": true
},
{
"query": "cancellation",
"samples": 20,
"p50_ms": 101.68339999999999,
"first_query_ms": 114.44120000000001,
"first_query_target_met": true,
"p95_ms": 117.8776,
"target_p95_ms": 150.0,
"target_met": true
}
]
}
@@ -0,0 +1,177 @@
{
"background_prepare_seconds": 0.7323155,
"reopened_background_prepare_seconds": 0.3083516,
"background_representative_queries": [
"invoice_0000000.txt",
"invoice_0999999.txt",
"Supplier",
"termination",
"cancellation"
],
"prepared_frequency_count": 1268,
"first_foreground_distinct_names": {
"description": "distinct foreground filenames; no benchmark query warmups; production background representative queries may overlap",
"includes_reader_open": false,
"queries": [
"invoice_0000000.txt",
"invoice_0049999.txt",
"invoice_0099999.txt",
"invoice_0149999.txt",
"invoice_0199999.txt",
"invoice_0249999.txt",
"invoice_0299999.txt",
"invoice_0349999.txt",
"invoice_0399999.txt",
"invoice_0449999.txt",
"invoice_0499999.txt",
"invoice_0549998.txt",
"invoice_0599998.txt",
"invoice_0649998.txt",
"invoice_0699998.txt",
"invoice_0749998.txt",
"invoice_0799998.txt",
"invoice_0849998.txt",
"invoice_0899998.txt",
"invoice_0949998.txt"
],
"samples_ms": [
5.4780999999999995,
14.290199999999999,
2.2094,
9.4278,
9.712800000000001,
11.698,
7.796,
9.2627,
7.9072,
71.67869999999999,
2.4205,
20.3763,
9.2537,
11.2855,
7.5798000000000005,
9.6667,
7.8527000000000005,
8.970600000000001,
5.9854,
8.1966
],
"p50_ms": 8.970600000000001,
"p95_ms": 20.3763,
"first_sample_ms": 5.4780999999999995,
"first_sample_target_met": true,
"maximum_ms": 71.67869999999999,
"samples_exceeding_target": 1,
"target_p95_ms": 50.0,
"target_met": true
},
"reopened_reader_first_names": {
"description": "each never-repeated filename on a newly opened reader, persisted statistics, no prewarm; includes open+search",
"includes_reader_open": true,
"queries": [
"invoice_0000001.txt",
"invoice_0050000.txt",
"invoice_0100000.txt",
"invoice_0150000.txt",
"invoice_0200000.txt",
"invoice_0250000.txt",
"invoice_0300000.txt",
"invoice_0350000.txt",
"invoice_0400000.txt",
"invoice_0450000.txt",
"invoice_0500000.txt",
"invoice_0549999.txt",
"invoice_0599999.txt",
"invoice_0649999.txt",
"invoice_0699999.txt",
"invoice_0749999.txt",
"invoice_0799999.txt",
"invoice_0849999.txt",
"invoice_0899999.txt",
"invoice_0949999.txt"
],
"samples_ms": [
12.800699999999999,
26.5927,
25.4506,
11.3757,
24.2376,
10.9734,
22.7002,
8.4673,
22.9176,
11.1351,
16.4276,
5.4818999999999996,
4.0053,
4.1241,
3.9019,
4.209,
3.9389000000000003,
4.0844000000000005,
3.8915,
5.3457
],
"p50_ms": 8.4673,
"p95_ms": 25.4506,
"first_sample_ms": 12.800699999999999,
"first_sample_target_met": true,
"maximum_ms": 26.5927,
"samples_exceeding_target": 0,
"target_p95_ms": 50.0,
"target_met": true
},
"fixture": "synthetic SQLite metadata and text; no filesystem extraction or semantic inference",
"files": 1000000,
"documents": 100000,
"seed_seconds": 0.0,
"migration_seconds": 0.0040623,
"fixture_reused": true,
"database_bytes": 634449920,
"database_path": "target/benchmarks/full.db",
"architecture": "x86_64",
"os": "windows",
"cpu_only": true,
"queries": [
{
"query": "invoice_0999999.txt",
"samples": 20,
"p50_ms": 1.3525,
"first_query_ms": 2.9366000000000003,
"first_query_target_met": true,
"p95_ms": 2.5606,
"target_p95_ms": 50.0,
"target_met": true
},
{
"query": "invoice_",
"samples": 20,
"p50_ms": 0.5368999999999999,
"first_query_ms": 66.6776,
"first_query_target_met": false,
"p95_ms": 0.7038,
"target_p95_ms": 50.0,
"target_met": true
},
{
"query": "marker0099999",
"samples": 20,
"p50_ms": 0.2814,
"first_query_ms": 10.037799999999999,
"first_query_target_met": true,
"p95_ms": 0.4111,
"target_p95_ms": 150.0,
"target_met": true
},
{
"query": "cancellation",
"samples": 20,
"p50_ms": 98.19590000000001,
"first_query_ms": 107.1098,
"first_query_target_met": true,
"p95_ms": 102.2884,
"target_p95_ms": 150.0,
"target_met": true
}
]
}
@@ -0,0 +1,179 @@
{
"background_prepare_seconds": 0.9157306,
"reopened_background_prepare_seconds": 0.390985,
"background_representative_queries": [
"invoice_0000000.txt",
"invoice_0999999.txt",
"invoice",
"txt",
"Supplier",
"termination",
"cancellation"
],
"prepared_frequency_count": 1268,
"first_foreground_distinct_names": {
"description": "distinct foreground filenames; no benchmark query warmups; production background representative queries may overlap",
"includes_reader_open": false,
"queries": [
"invoice_0000000.txt",
"invoice_0049999.txt",
"invoice_0099999.txt",
"invoice_0149999.txt",
"invoice_0199999.txt",
"invoice_0249999.txt",
"invoice_0299999.txt",
"invoice_0349999.txt",
"invoice_0399999.txt",
"invoice_0449999.txt",
"invoice_0499999.txt",
"invoice_0549998.txt",
"invoice_0599998.txt",
"invoice_0649998.txt",
"invoice_0699998.txt",
"invoice_0749998.txt",
"invoice_0799998.txt",
"invoice_0849998.txt",
"invoice_0899998.txt",
"invoice_0949998.txt"
],
"samples_ms": [
6.9921999999999995,
16.238,
2.3243,
13.4229,
9.434,
11.145,
8.304300000000001,
11.0419,
9.932699999999999,
10.8164,
2.3062,
21.6846,
9.497,
19.0655,
8.6778,
14.024099999999999,
10.106300000000001,
11.553799999999999,
8.5308,
7.7924999999999995
],
"p50_ms": 9.932699999999999,
"p95_ms": 19.0655,
"first_sample_ms": 6.9921999999999995,
"first_sample_target_met": true,
"maximum_ms": 21.6846,
"samples_exceeding_target": 0,
"target_p95_ms": 50.0,
"target_met": true
},
"reopened_reader_first_names": {
"description": "each never-repeated filename on a newly opened reader, persisted statistics, no prewarm; includes open+search",
"includes_reader_open": true,
"queries": [
"invoice_0000001.txt",
"invoice_0050000.txt",
"invoice_0100000.txt",
"invoice_0150000.txt",
"invoice_0200000.txt",
"invoice_0250000.txt",
"invoice_0300000.txt",
"invoice_0350000.txt",
"invoice_0400000.txt",
"invoice_0450000.txt",
"invoice_0500000.txt",
"invoice_0549999.txt",
"invoice_0599999.txt",
"invoice_0649999.txt",
"invoice_0699999.txt",
"invoice_0749999.txt",
"invoice_0799999.txt",
"invoice_0849999.txt",
"invoice_0899999.txt",
"invoice_0949999.txt"
],
"samples_ms": [
27.8174,
25.6445,
29.525699999999997,
11.5664,
27.617,
10.4947,
30.0687,
10.766,
28.287300000000002,
13.547,
23.8423,
6.6371,
4.4586,
4.5778,
5.1159,
4.6030999999999995,
4.845000000000001,
5.5757,
4.6811,
6.2597
],
"p50_ms": 10.4947,
"p95_ms": 29.525699999999997,
"first_sample_ms": 27.8174,
"first_sample_target_met": true,
"maximum_ms": 30.0687,
"samples_exceeding_target": 0,
"target_p95_ms": 50.0,
"target_met": true
},
"fixture": "synthetic SQLite metadata and text; no filesystem extraction or semantic inference",
"files": 1000000,
"documents": 100000,
"seed_seconds": 0.0,
"migration_seconds": 0.0098789,
"fixture_reused": true,
"database_bytes": 634449920,
"database_path": "target/benchmarks/full.db",
"architecture": "x86_64",
"os": "windows",
"cpu_only": true,
"queries": [
{
"query": "invoice_0999999.txt",
"samples": 20,
"p50_ms": 1.8600999999999999,
"first_query_ms": 2.1075,
"first_query_target_met": true,
"p95_ms": 2.5455,
"target_p95_ms": 50.0,
"target_met": true
},
{
"query": "invoice_",
"samples": 20,
"p50_ms": 0.5207999999999999,
"first_query_ms": 12.9272,
"first_query_target_met": true,
"p95_ms": 0.6883,
"target_p95_ms": 50.0,
"target_met": true
},
{
"query": "marker0099999",
"samples": 20,
"p50_ms": 0.33340000000000003,
"first_query_ms": 10.2134,
"first_query_target_met": true,
"p95_ms": 0.4555,
"target_p95_ms": 150.0,
"target_met": true
},
{
"query": "cancellation",
"samples": 20,
"p50_ms": 141.4495,
"first_query_ms": 125.0308,
"first_query_target_met": true,
"p95_ms": 172.7458,
"target_p95_ms": 150.0,
"target_met": false
}
]
}
@@ -0,0 +1,169 @@
{
"background_prepare_seconds": 17.2134568,
"prepared_frequency_count": 1268,
"first_foreground_distinct_names": {
"description": "never-repeated filenames on prepared reader; no query warmups",
"includes_reader_open": false,
"queries": [
"invoice_0000000.txt",
"invoice_0049999.txt",
"invoice_0099999.txt",
"invoice_0149999.txt",
"invoice_0199999.txt",
"invoice_0249999.txt",
"invoice_0299999.txt",
"invoice_0349999.txt",
"invoice_0399999.txt",
"invoice_0449999.txt",
"invoice_0499999.txt",
"invoice_0549998.txt",
"invoice_0599998.txt",
"invoice_0649998.txt",
"invoice_0699998.txt",
"invoice_0749998.txt",
"invoice_0799998.txt",
"invoice_0849998.txt",
"invoice_0899998.txt",
"invoice_0949998.txt"
],
"samples_ms": [
98.4897,
17.2344,
15.2996,
14.8814,
13.0075,
12.6754,
12.543099999999999,
13.686200000000001,
13.9632,
13.702300000000001,
14.8554,
15.3544,
15.874699999999999,
13.2766,
17.0515,
18.9665,
12.6579,
14.1467,
12.6395,
13.706100000000001
],
"p50_ms": 13.9632,
"p95_ms": 18.9665,
"first_sample_ms": 98.4897,
"first_sample_target_met": false,
"maximum_ms": 98.4897,
"samples_exceeding_target": 1,
"target_p95_ms": 50.0,
"target_met": true
},
"reopened_reader_first_names": {
"description": "each never-repeated filename on a newly opened reader, persisted statistics, no prewarm; includes open+search",
"includes_reader_open": true,
"queries": [
"invoice_0000001.txt",
"invoice_0050000.txt",
"invoice_0100000.txt",
"invoice_0150000.txt",
"invoice_0200000.txt",
"invoice_0250000.txt",
"invoice_0300000.txt",
"invoice_0350000.txt",
"invoice_0400000.txt",
"invoice_0450000.txt",
"invoice_0500000.txt",
"invoice_0549999.txt",
"invoice_0599999.txt",
"invoice_0649999.txt",
"invoice_0699999.txt",
"invoice_0749999.txt",
"invoice_0799999.txt",
"invoice_0849999.txt",
"invoice_0899999.txt",
"invoice_0949999.txt"
],
"samples_ms": [
103.57130000000001,
19.669,
17.804299999999998,
16.7811,
15.733600000000001,
16.381300000000003,
16.7549,
16.395500000000002,
21.7466,
17.044,
21.080399999999997,
22.7327,
20.9134,
18.464399999999998,
17.691300000000002,
14.9367,
15.176,
16.0537,
14.7705,
15.5895
],
"p50_ms": 16.7811,
"p95_ms": 22.7327,
"first_sample_ms": 103.57130000000001,
"first_sample_target_met": false,
"maximum_ms": 103.57130000000001,
"samples_exceeding_target": 1,
"target_p95_ms": 50.0,
"target_met": true
},
"fixture": "synthetic SQLite metadata and text; no filesystem extraction or semantic inference",
"files": 1000000,
"documents": 100000,
"seed_seconds": 0.0,
"migration_seconds": 0.0128952,
"fixture_reused": true,
"database_bytes": 634449920,
"database_path": "target/benchmarks/full.db",
"architecture": "x86_64",
"os": "windows",
"cpu_only": true,
"queries": [
{
"query": "invoice_0999999.txt",
"samples": 20,
"p50_ms": 12.3028,
"first_query_ms": 16.3077,
"first_query_target_met": true,
"p95_ms": 13.7969,
"target_p95_ms": 50.0,
"target_met": true
},
{
"query": "invoice_",
"samples": 20,
"p50_ms": 0.33149999999999996,
"first_query_ms": 13.0136,
"first_query_target_met": true,
"p95_ms": 0.4704,
"target_p95_ms": 50.0,
"target_met": true
},
{
"query": "marker0099999",
"samples": 20,
"p50_ms": 0.2775,
"first_query_ms": 14.0693,
"first_query_target_met": true,
"p95_ms": 0.49319999999999997,
"target_p95_ms": 150.0,
"target_met": true
},
{
"query": "cancellation",
"samples": 20,
"p50_ms": 101.3439,
"first_query_ms": 264.5315,
"first_query_target_met": false,
"p95_ms": 145.1926,
"target_p95_ms": 150.0,
"target_met": true
}
]
}
@@ -0,0 +1,177 @@
{
"background_prepare_seconds": 17.7229177,
"reopened_background_prepare_seconds": 0.3963525,
"background_representative_queries": [
"invoice_0000000.txt",
"invoice_0999999.txt",
"Supplier",
"termination",
"cancellation"
],
"prepared_frequency_count": 1268,
"first_foreground_distinct_names": {
"description": "distinct foreground filenames; no benchmark query warmups; production background representative queries may overlap",
"includes_reader_open": false,
"queries": [
"invoice_0000000.txt",
"invoice_0049999.txt",
"invoice_0099999.txt",
"invoice_0149999.txt",
"invoice_0199999.txt",
"invoice_0249999.txt",
"invoice_0299999.txt",
"invoice_0349999.txt",
"invoice_0399999.txt",
"invoice_0449999.txt",
"invoice_0499999.txt",
"invoice_0549998.txt",
"invoice_0599998.txt",
"invoice_0649998.txt",
"invoice_0699998.txt",
"invoice_0749998.txt",
"invoice_0799998.txt",
"invoice_0849998.txt",
"invoice_0899998.txt",
"invoice_0949998.txt"
],
"samples_ms": [
85.3797,
14.3974,
13.200800000000001,
13.4454,
12.4215,
11.9434,
12.0694,
11.862400000000001,
12.014999999999999,
13.409600000000001,
12.745899999999999,
12.9113,
13.3632,
12.5779,
12.828199999999999,
13.0159,
12.9193,
12.663499999999999,
12.2875,
12.1379
],
"p50_ms": 12.745899999999999,
"p95_ms": 14.3974,
"first_sample_ms": 85.3797,
"first_sample_target_met": false,
"maximum_ms": 85.3797,
"samples_exceeding_target": 1,
"target_p95_ms": 50.0,
"target_met": true
},
"reopened_reader_first_names": {
"description": "each never-repeated filename on a newly opened reader, persisted statistics, no prewarm; includes open+search",
"includes_reader_open": true,
"queries": [
"invoice_0000001.txt",
"invoice_0050000.txt",
"invoice_0100000.txt",
"invoice_0150000.txt",
"invoice_0200000.txt",
"invoice_0250000.txt",
"invoice_0300000.txt",
"invoice_0350000.txt",
"invoice_0400000.txt",
"invoice_0450000.txt",
"invoice_0500000.txt",
"invoice_0549999.txt",
"invoice_0599999.txt",
"invoice_0649999.txt",
"invoice_0699999.txt",
"invoice_0749999.txt",
"invoice_0799999.txt",
"invoice_0849999.txt",
"invoice_0899999.txt",
"invoice_0949999.txt"
],
"samples_ms": [
100.0679,
19.9179,
17.9353,
16.6326,
16.3062,
16.3483,
16.519099999999998,
16.380499999999998,
19.8438,
16.5421,
15.4087,
31.4163,
14.6464,
14.1976,
14.2664,
15.4924,
16.0576,
14.2649,
13.9564,
16.1585
],
"p50_ms": 16.3062,
"p95_ms": 31.4163,
"first_sample_ms": 100.0679,
"first_sample_target_met": false,
"maximum_ms": 100.0679,
"samples_exceeding_target": 1,
"target_p95_ms": 50.0,
"target_met": true
},
"fixture": "synthetic SQLite metadata and text; no filesystem extraction or semantic inference",
"files": 1000000,
"documents": 100000,
"seed_seconds": 0.0,
"migration_seconds": 0.0118237,
"fixture_reused": true,
"database_bytes": 634449920,
"database_path": "target/benchmarks/full.db",
"architecture": "x86_64",
"os": "windows",
"cpu_only": true,
"queries": [
{
"query": "invoice_0999999.txt",
"samples": 20,
"p50_ms": 11.9678,
"first_query_ms": 13.4818,
"first_query_target_met": true,
"p95_ms": 13.218,
"target_p95_ms": 50.0,
"target_met": true
},
{
"query": "invoice_",
"samples": 20,
"p50_ms": 0.3231,
"first_query_ms": 3.2028999999999996,
"first_query_target_met": true,
"p95_ms": 0.4335,
"target_p95_ms": 50.0,
"target_met": true
},
{
"query": "marker0099999",
"samples": 20,
"p50_ms": 0.2697,
"first_query_ms": 2.5677,
"first_query_target_met": true,
"p95_ms": 0.5285,
"target_p95_ms": 150.0,
"target_met": true
},
{
"query": "cancellation",
"samples": 20,
"p50_ms": 95.59660000000001,
"first_query_ms": 99.10470000000001,
"first_query_target_met": true,
"p95_ms": 106.197,
"target_p95_ms": 150.0,
"target_met": true
}
]
}
@@ -0,0 +1,177 @@
{
"background_prepare_seconds": 4.8178274,
"reopened_background_prepare_seconds": 0.4527295,
"background_representative_queries": [
"invoice_0000000.txt",
"invoice_0999999.txt",
"Supplier",
"termination",
"cancellation"
],
"prepared_frequency_count": 1268,
"first_foreground_distinct_names": {
"description": "distinct foreground filenames; no benchmark query warmups; production background representative queries may overlap",
"includes_reader_open": false,
"queries": [
"invoice_0000000.txt",
"invoice_0049999.txt",
"invoice_0099999.txt",
"invoice_0149999.txt",
"invoice_0199999.txt",
"invoice_0249999.txt",
"invoice_0299999.txt",
"invoice_0349999.txt",
"invoice_0399999.txt",
"invoice_0449999.txt",
"invoice_0499999.txt",
"invoice_0549998.txt",
"invoice_0599998.txt",
"invoice_0649998.txt",
"invoice_0699998.txt",
"invoice_0749998.txt",
"invoice_0799998.txt",
"invoice_0849998.txt",
"invoice_0899998.txt",
"invoice_0949998.txt"
],
"samples_ms": [
136.6046,
43.111999999999995,
29.0124,
77.8574,
35.7493,
67.303,
92.4451,
91.5913,
29.5216,
66.8154,
27.6054,
366.9255,
28.3015,
70.88719999999999,
27.576900000000002,
69.3327,
27.537200000000002,
69.94200000000001,
26.957600000000003,
72.7744
],
"p50_ms": 66.8154,
"p95_ms": 136.6046,
"first_sample_ms": 136.6046,
"first_sample_target_met": false,
"maximum_ms": 366.9255,
"samples_exceeding_target": 11,
"target_p95_ms": 50.0,
"target_met": false
},
"reopened_reader_first_names": {
"description": "each never-repeated filename on a newly opened reader, persisted statistics, no prewarm; includes open+search",
"includes_reader_open": true,
"queries": [
"invoice_0000001.txt",
"invoice_0050000.txt",
"invoice_0100000.txt",
"invoice_0150000.txt",
"invoice_0200000.txt",
"invoice_0250000.txt",
"invoice_0300000.txt",
"invoice_0350000.txt",
"invoice_0400000.txt",
"invoice_0450000.txt",
"invoice_0500000.txt",
"invoice_0549999.txt",
"invoice_0599999.txt",
"invoice_0649999.txt",
"invoice_0699999.txt",
"invoice_0749999.txt",
"invoice_0799999.txt",
"invoice_0849999.txt",
"invoice_0899999.txt",
"invoice_0949999.txt"
],
"samples_ms": [
554.3185000000001,
129.17229999999998,
102.8,
102.7292,
101.44300000000001,
98.4861,
112.4415,
95.5881,
94.8502,
97.8484,
28.9007,
31.1041,
29.8992,
31.3256,
29.8718,
28.2319,
26.3245,
29.8964,
29.4154,
65.40020000000001
],
"p50_ms": 65.40020000000001,
"p95_ms": 129.17229999999998,
"first_sample_ms": 554.3185000000001,
"first_sample_target_met": false,
"maximum_ms": 554.3185000000001,
"samples_exceeding_target": 11,
"target_p95_ms": 50.0,
"target_met": false
},
"fixture": "synthetic SQLite metadata and text; no filesystem extraction or semantic inference",
"files": 1000000,
"documents": 100000,
"seed_seconds": 0.0,
"migration_seconds": 0.0049354,
"fixture_reused": true,
"database_bytes": 634449920,
"database_path": "target/benchmarks/full.db",
"architecture": "x86_64",
"os": "windows",
"cpu_only": true,
"queries": [
{
"query": "invoice_0999999.txt",
"samples": 20,
"p50_ms": 26.0376,
"first_query_ms": 28.9998,
"first_query_target_met": true,
"p95_ms": 30.639599999999998,
"target_p95_ms": 50.0,
"target_met": true
},
{
"query": "invoice_",
"samples": 20,
"p50_ms": 0.3764,
"first_query_ms": 73.6061,
"first_query_target_met": false,
"p95_ms": 0.8251000000000001,
"target_p95_ms": 50.0,
"target_met": true
},
{
"query": "marker0099999",
"samples": 20,
"p50_ms": 0.3811,
"first_query_ms": 9.8315,
"first_query_target_met": true,
"p95_ms": 0.5450999999999999,
"target_p95_ms": 150.0,
"target_met": true
},
{
"query": "cancellation",
"samples": 20,
"p50_ms": 97.4611,
"first_query_ms": 98.24900000000001,
"first_query_target_met": true,
"p95_ms": 112.3313,
"target_p95_ms": 150.0,
"target_met": true
}
]
}
+135 -4
View File
@@ -12,6 +12,7 @@ struct Measurements {
samples: usize,
p50_ms: f64,
first_query_ms: f64,
first_query_target_met: bool,
p95_ms: f64,
target_p95_ms: f64,
target_met: bool,
@@ -19,6 +20,12 @@ struct Measurements {
#[derive(Serialize)]
struct Report {
background_prepare_seconds: f64,
reopened_background_prepare_seconds: f64,
background_representative_queries: Vec<String>,
prepared_frequency_count: usize,
first_foreground_distinct_names: DistinctMeasurements,
reopened_reader_first_names: DistinctMeasurements,
fixture: &'static str,
files: usize,
documents: usize,
@@ -33,6 +40,84 @@ struct Report {
queries: Vec<Measurements>,
}
#[derive(Serialize)]
struct DistinctMeasurements {
description: &'static str,
includes_reader_open: bool,
queries: Vec<String>,
samples_ms: Vec<f64>,
p50_ms: f64,
p95_ms: f64,
first_sample_ms: f64,
first_sample_target_met: bool,
maximum_ms: f64,
samples_exceeding_target: usize,
target_p95_ms: f64,
target_met: bool,
}
fn distinct_names(
index: &SearchIndex,
database: &std::path::Path,
files: usize,
reopen: bool,
) -> Result<DistinctMeasurements> {
let count = 20.min(files / 2);
let mut queries = Vec::with_capacity(count);
let mut samples_ms = Vec::with_capacity(count);
for sample in 0..count {
// Two disjoint deterministic sets spread across the entire corpus.
// Neither set repeats the four-case exact filename at files-1.
let number = sample * (files - 2) / count + usize::from(reopen);
let query = format!("invoice_{number:07}.txt");
let start = Instant::now();
let results = if reopen {
SearchIndex::open(database)?.search(&query, 20)?
} else {
index.search(&query, 20)?
};
let elapsed = start.elapsed().as_secs_f64() * 1000.0;
let first = results
.first()
.context("distinct exact query returned no results")?;
if first.filename != query || first.match_kind != "exact_filename" {
bail!("distinct query lost exact filename priority");
}
queries.push(query);
samples_ms.push(elapsed);
}
let mut sorted = samples_ms.clone();
sorted.sort_by(f64::total_cmp);
let percentile = |fraction: f64| {
sorted[((sorted.len() as f64 * fraction).ceil() as usize).saturating_sub(1)]
};
let p95_ms = percentile(0.95);
eprintln!(
"{} distinct first filename queries (reopened={reopen}): p95 {p95_ms:.3} ms",
count
);
let first_sample_ms = samples_ms[0];
let samples_exceeding_target = samples_ms.iter().filter(|sample| **sample >= 50.0).count();
Ok(DistinctMeasurements {
description: if reopen {
"each never-repeated filename on a newly opened reader, persisted statistics, no prewarm; includes open+search"
} else {
"distinct foreground filenames; no benchmark query warmups; production background representative queries may overlap"
},
includes_reader_open: reopen,
queries,
samples_ms,
p50_ms: percentile(0.5),
p95_ms,
first_sample_ms,
first_sample_target_met: first_sample_ms < 50.0,
maximum_ms: sorted[sorted.len() - 1],
samples_exceeding_target,
target_p95_ms: 50.0,
target_met: p95_ms < 50.0,
})
}
fn main() -> Result<()> {
let arguments = env::args().skip(1).collect::<Vec<_>>();
let mut files = 10_000usize;
@@ -53,13 +138,12 @@ fn main() -> Result<()> {
option => bail!("unknown argument {option}"),
}
}
if files == 0
|| files > 2_000_000
if !(2..=2_000_000).contains(&files)
|| documents == 0
|| documents > files
|| !(5..=1000).contains(&samples)
{
bail!("files must be1..2000000, documents1..files, samples5..1000");
bail!("files must be2..2000000, documents1..files, samples5..1000");
}
if database.exists() && !reuse {
bail!(
@@ -135,7 +219,41 @@ fn main() -> Result<()> {
start.elapsed().as_secs_f64()
};
let database_bytes = fs::metadata(&database)?.len();
let index = SearchIndex::open(&database)?;
let mut index = SearchIndex::open(&database)?;
let connection = Connection::open(&database)?;
let mut statement=connection.prepare("SELECT filename FROM files WHERE id=(SELECT min(id) FROM files) OR id=(SELECT max(id) FROM files)")?;
let mut background_representative_queries = statement
.query_map([], |row| row.get::<_, String>(0))?
.collect::<rusqlite::Result<Vec<_>>>()?;
let words = background_representative_queries
.iter()
.flat_map(|name| {
name.split(|character: char| !character.is_alphabetic())
.filter(|word| word.chars().count() >= 3)
.take(3)
})
.map(str::to_owned)
.collect::<std::collections::BTreeSet<_>>();
background_representative_queries.extend(words);
let text: String = connection.query_row(
"SELECT substr(content,1,256) FROM files WHERE content!='' ORDER BY id LIMIT 1",
[],
|row| row.get(0),
)?;
background_representative_queries.extend(
text.split_whitespace()
.filter(|term| term.chars().all(char::is_alphanumeric))
.take(3)
.map(str::to_owned),
);
drop(statement);
drop(connection);
let preparation = Instant::now();
let prepared_frequency_count = index.prewarm()?;
let background_prepare_seconds = preparation.elapsed().as_secs_f64();
// Measure user-like first inputs before the old repeated-query cases.
let first_foreground_distinct_names = distinct_names(&index, &database, files, false)?;
let reopened_reader_first_names = distinct_names(&index, &database, files, true)?;
let exact = format!("invoice_{:07}.txt", files - 1);
let unique_content = format!("marker{:07}", documents - 1);
let cases = [
@@ -175,12 +293,25 @@ fn main() -> Result<()> {
samples,
p50_ms: percentile(0.5),
first_query_ms,
first_query_target_met: first_query_ms < target,
p95_ms,
target_p95_ms: target,
target_met: p95_ms < target,
});
}
// Measure restart preparation after foreground samples, so a second
// prewarm cannot secretly improve the first-input measurements above.
let mut restarted = SearchIndex::open(&database)?;
let restart_start = Instant::now();
restarted.prewarm()?;
let reopened_background_prepare_seconds = restart_start.elapsed().as_secs_f64();
let report = Report {
background_prepare_seconds,
reopened_background_prepare_seconds,
background_representative_queries,
prepared_frequency_count,
first_foreground_distinct_names,
reopened_reader_first_names,
fixture:
"synthetic SQLite metadata and text; no filesystem extraction or semantic inference",
files,
+167 -15
View File
@@ -16,7 +16,7 @@ use std::{
};
use walkdir::WalkDir;
const SCHEMA_VERSION: i64 = 7;
const SCHEMA_VERSION: i64 = 9;
const MAX_RESULTS: usize = 1000;
#[derive(Debug, Default, Serialize)]
@@ -214,7 +214,18 @@ impl SearchIndex {
}
initialization.execute_batch(
"CREATE VIRTUAL TABLE IF NOT EXISTS names_vocab USING fts5vocab(names,'row');
PRAGMA user_version=7;",
CREATE TABLE IF NOT EXISTS name_frequencies(term TEXT PRIMARY KEY, doc INTEGER NOT NULL);
CREATE INDEX IF NOT EXISTS name_frequencies_popularity ON name_frequencies(doc DESC);
CREATE TABLE IF NOT EXISTS name_statistics_state(singleton INTEGER PRIMARY KEY CHECK(singleton=1), dirty INTEGER NOT NULL);
INSERT OR IGNORE INTO name_statistics_state VALUES(1,1);
CREATE TRIGGER IF NOT EXISTS files_statistics_insert AFTER INSERT ON files BEGIN
UPDATE name_statistics_state SET dirty=1 WHERE singleton=1; END;
CREATE TRIGGER IF NOT EXISTS files_statistics_delete AFTER DELETE ON files BEGIN
UPDATE name_statistics_state SET dirty=1 WHERE singleton=1; END;
CREATE TRIGGER IF NOT EXISTS files_statistics_update AFTER UPDATE OF filename,path ON files
WHEN new.filename!=old.filename OR new.path!=old.path BEGIN
UPDATE name_statistics_state SET dirty=1 WHERE singleton=1; END;
PRAGMA user_version=9;",
)?;
initialization.commit()?;
}
@@ -225,6 +236,89 @@ impl SearchIndex {
})
}
/// Refresh candidate-frequency statistics off the foreground search path.
/// Stale/missing estimates can affect speed, never literal-match correctness.
/// The app invokes this during background startup and after reconciliation.
pub fn prewarm(&mut self) -> Result<usize> {
let dirty: bool = self.connection.query_row(
"SELECT dirty FROM name_statistics_state WHERE singleton=1",
[],
|row| row.get(0),
)?;
if dirty {
let transaction = self
.connection
.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
transaction.execute("DELETE FROM name_frequencies", [])?;
transaction.execute(
"INSERT INTO name_frequencies(term,doc) SELECT term,doc FROM names_vocab",
[],
)?;
transaction.execute(
"UPDATE name_statistics_state SET dirty=0 WHERE singleton=1",
[],
)?;
transaction.commit()?;
}
let mut statement = self
.connection
.prepare("SELECT term,doc FROM name_frequencies ORDER BY doc DESC LIMIT 4096")?;
let rows = statement.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
let mut frequencies = self.gram_frequencies.borrow_mut();
frequencies.clear();
for row in rows {
let (term, frequency) = row?;
frequencies.insert(term, frequency);
}
let cached = frequencies.len();
drop(frequencies);
// Exercise real query paths using representative corpus data, rather
// than paying postings, snippet and SQLite page initialization on input.
let mut representative = self.connection.prepare(
"SELECT filename FROM files WHERE id=(SELECT min(id) FROM files)
OR id=(SELECT max(id) FROM files)",
)?;
let names = representative
.query_map([], |row| row.get::<_, String>(0))?
.collect::<rusqlite::Result<Vec<_>>>()?;
for name in &names {
self.search(name, 20)?;
}
// Full names often anchor at sparse identifiers. Also exercise actual
// alphabetic name tokens to initialize common prefix/type postings.
let words = names
.iter()
.flat_map(|name| {
name.split(|character: char| !character.is_alphabetic())
.filter(|word| word.chars().count() >= 3)
.take(3)
})
.collect::<BTreeSet<_>>();
for word in words {
self.search(word, 20)?;
}
let text: Option<String> = self
.connection
.query_row(
"SELECT substr(content,1,256) FROM files WHERE content!='' ORDER BY id LIMIT 1",
[],
|row| row.get(0),
)
.optional()?;
if let Some(text) = text {
for term in text
.split_whitespace()
.filter(|term| term.chars().all(char::is_alphanumeric))
.take(3)
{
self.search(term, 20)?;
}
}
Ok(cached)
}
/// Reconcile one explicitly selected root. Symlinks are never followed.
/// Every file receives a filename record, including unsupported formats.
pub fn index_folder(&mut self, root: impl AsRef<Path>) -> Result<IndexReport> {
@@ -441,16 +535,17 @@ impl SearchIndex {
.modified_before_unix_ms
.map_or(Value::Null, Value::Integer),
];
let has_filters = filter_values.iter().any(|value| *value != Value::Null);
let mut results = Vec::new();
let mut selected = Vec::<i64>::new();
// Each lane filters its entire candidate set before LIMIT. There is no
// hidden pre-filter candidate cap that can hide later matching files.
let trigram = query.chars().count() >= 3;
// A long FTS phrase may walk common leading trigram postings even when
// later trigrams are rare. Use the rarest known trigram as a candidate
// superset, then verify the whole literal substring. The 256-term lookup
// budget can only enlarge candidates; it never caps results. Frequency
// lookups are cached because FTS vocabulary counts have a first-use cost.
// later trigrams are rare. Build a frequency-bounded positional anchor,
// then verify the whole literal substring. The 256-term lookup budget
// can only enlarge candidates; it never caps results. Frequencies come
// from persisted background statistics and a bounded reader cache.
let characters = query.chars().collect::<Vec<_>>();
let grams = characters
.windows(3)
@@ -461,7 +556,7 @@ impl SearchIndex {
.collect::<Vec<_>>();
let mut vocabulary = self
.connection
.prepare("SELECT doc FROM names_vocab WHERE term=?1")?;
.prepare("SELECT doc FROM name_frequencies WHERE term=?1")?;
let mut cache = self.gram_frequencies.borrow_mut();
if cache.len() + grams.len() > 4096 {
cache.clear();
@@ -485,12 +580,8 @@ impl SearchIndex {
.collect::<Result<Vec<_>>>()?;
rare_first.sort();
drop(cache);
let grams = rare_first
.into_iter()
.take(1)
.map(|(_, gram)| format!("\"{}\"", gram.replace('"', "\"\"")))
.collect::<Vec<_>>()
.join(" AND ");
let anchor = substring_anchor(&characters, &rare_first);
let grams = format!("\"{}\"", anchor.replace('"', "\"\""));
let mut lanes = vec![
(
"exact_filename",
@@ -585,10 +676,25 @@ impl SearchIndex {
} else {
"''"
};
let sql = format!(
let sql = if kind == "content" && !has_filters {
// Rank compact row IDs before materializing paths and snippets.
// Filtered requests retain the complete filter-before-LIMIT lane
// below; this fast path is only for genuinely unfiltered search.
let excluded = exclusions.replace("f.id", "rowid");
format!("WITH hits AS MATERIALIZED (
SELECT rowid AS id,rank AS relevance FROM content_search
WHERE content_search MATCH ?1 {excluded}
ORDER BY rank,rowid LIMIT ?9)
SELECT f.id,f.path,f.filename,snippet(content_search,0,'','',' … ',24),hits.relevance,f.extraction_error
FROM hits CROSS JOIN files f ON f.id=hits.id
CROSS JOIN content_search ON content_search.rowid=hits.id
WHERE content_search MATCH ?1 ORDER BY hits.relevance,f.id")
} else {
format!(
"SELECT f.id,f.path,f.filename,{snippet},{rank},f.extraction_error FROM {from}
WHERE {condition} {filters} {exclusions} ORDER BY {ordering} LIMIT ?9"
);
)
};
let mut values = vec![Value::Text(expression), Value::Text(query.into())];
values.extend(filter_values.iter().cloned());
values.push(Value::Integer((limit - results.len()) as i64));
@@ -702,6 +808,52 @@ impl SearchIndex {
}
}
/// Build a bounded positional anchor around the rarest trigram. Extending a
/// phrase through common suffixes can force FTS to decode dense postings. Only
/// adjacent grams within a small frequency factor are admitted here. Every
/// literal match contains this anchor; SQL still verifies the entire substring.
fn substring_anchor(characters: &[char], frequencies: &[(i64, String)]) -> String {
let Some((minimum, gram)) = frequencies.first() else {
return String::new();
};
let center = characters
.windows(3)
.position(|window| window.iter().collect::<String>() == *gram)
.unwrap_or(0);
let mut start = center;
let mut end = center + 3;
if *minimum > 0 {
let maximum = minimum.saturating_mul(4).max(32);
let lookup = frequencies
.iter()
.map(|(frequency, gram)| (gram.as_str(), *frequency))
.collect::<HashMap<_, _>>();
let eligible = |index: usize| {
lookup
.get(
characters[index..index + 3]
.iter()
.collect::<String>()
.as_str(),
)
.is_some_and(|frequency| *frequency <= maximum)
};
loop {
let previous = (start, end);
if start > 0 && end - start < 32 && eligible(start - 1) {
start -= 1;
}
if end < characters.len() && end - start < 32 && eligible(end - 2) {
end += 1;
}
if previous == (start, end) {
break;
}
}
}
characters[start..end].iter().collect()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -123,3 +123,106 @@ fn v4_migration_backfills_names_and_preserves_passage_ids() -> Result<()> {
assert_eq!(index.passage_batch(0, 1)?[0].id, 88);
Ok(())
}
#[test]
fn background_statistics_preserve_results_across_reopen_and_changes() -> Result<()> {
let root = tempfile::tempdir()?;
let db = root.path().join("index.db");
fs::write(root.path().join("invoice_1042.txt"), "billing evidence")?;
let mut index = SearchIndex::open(&db)?;
index.index_folder(root.path())?;
assert!(index.prewarm()? > 0);
drop(index);
let mut index = SearchIndex::open(&db)?;
assert_eq!(index.search("1042", 1)?[0].filename, "invoice_1042.txt");
fs::rename(
root.path().join("invoice_1042.txt"),
root.path().join("new_pattern.txt"),
)?;
index.index_folder(root.path())?;
// Stale statistics affect only candidate cost, never substring correctness.
assert_eq!(index.search("pattern", 1)?[0].filename, "new_pattern.txt");
assert!(index.search("1042", 1)?.is_empty());
index.prewarm()?;
assert_eq!(index.search("pattern", 1)?[0].filename, "new_pattern.txt");
Ok(())
}
#[test]
fn positional_candidates_preserve_repeated_identifiers_unicode_and_path_matches() -> Result<()> {
let root = tempfile::tempdir()?;
let nested = root.path().join("folder_0000001_rare");
fs::create_dir(&nested)?;
for (name, text) in [
("ζαζα_0000001_rare.txt", "first source"),
("ζαζα_0000101_rare.txt", "second source"),
("backup_ζαζα_0000001_rare.txt", "backup source"),
] {
fs::write(root.path().join(name), text)?;
}
fs::write(nested.join("other.md"), "path source")?;
let mut index = SearchIndex::open(":memory:")?;
index.index_folder(root.path())?;
index.prewarm()?;
let matches = index.search("0000001_rare", 20)?;
assert_eq!(matches.len(), 3);
assert_eq!(matches[0].match_kind, "filename");
assert_eq!(matches[1].match_kind, "filename");
assert_eq!(matches[2].match_kind, "path");
assert_eq!(
index.search("ζαζα_0000101_rare", 1)?[0].filename,
"ζαζα_0000101_rare.txt"
);
let filtered = SearchOptions {
folder: Some(nested),
extension: Some("md".into()),
..Default::default()
};
assert_eq!(
index.search_with_options("0000001_rare", 1, &filtered)?[0].filename,
"other.md"
);
assert!(index.search("0000010_rare", 20)?.is_empty());
Ok(())
}
#[test]
fn candidate_first_content_matches_complete_filtered_ranking_and_sources() -> Result<()> {
let root = tempfile::tempdir()?;
fs::write(root.path().join("needle.txt"), "needle once")?;
for number in 0..50 {
let text = format!(
"{} background evidence {}",
"needle ".repeat(number % 7 + 1),
"filler ".repeat(number % 5)
);
fs::write(root.path().join(format!("notes_{number:03}.md")), text)?;
}
let mut index = SearchIndex::open(":memory:")?;
index.index_folder(root.path())?;
index.prewarm()?;
let fast = index.search("needle", 20)?;
// This nonrestrictive filter selects the normal complete filtered lane.
let filtered = index.search_with_options(
"needle",
20,
&SearchOptions {
min_size: Some(0),
..Default::default()
},
)?;
assert_eq!(fast.len(), 20);
assert_eq!(filtered.len(), 20);
for (fast, filtered) in fast.iter().zip(&filtered) {
assert_eq!(fast.path, filtered.path);
assert_eq!(fast.match_kind, filtered.match_kind);
assert!((fast.score - filtered.score).abs() < 1e-12);
assert_eq!(fast.snippet, filtered.snippet);
assert_eq!(
fast.source.as_ref().unwrap().number,
filtered.source.as_ref().unwrap().number
);
}
assert_eq!(fast[0].match_kind, "filename");
Ok(())
}
+9 -3
View File
@@ -3,13 +3,19 @@ name = "searchi-semantic"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Bounded offline CPU ONNX embedding adapter for Searchi"
description = "Bounded local Google EmbeddingGemma 2 worker adapter for Searchi"
[features]
default = []
minilm-proof = ["dep:ort", "dep:tokenizers"]
[dependencies]
anyhow.workspace = true
ort = { version = "=2.0.0-rc.13", default-features = false, features = ["std", "load-dynamic", "api-27"] }
ort = { version = "=2.0.0-rc.13", optional = true, default-features = false, features = ["std", "load-dynamic", "api-27"] }
sha2 = "0.10"
tokenizers = { version = "=0.22.2", default-features = false, features = ["fancy-regex"] }
tokenizers = { version = "=0.22.2", optional = true, default-features = false, features = ["fancy-regex"] }
serde.workspace = true
serde_json.workspace = true
[dev-dependencies]
serde_json.workspace = true
@@ -0,0 +1,51 @@
cmake_minimum_required(VERSION 3.20)
project(searchi_gemma_worker LANGUAGES C CXX)
if(NOT DEFINED LLAMA_CPP_DIR OR NOT EXISTS "${LLAMA_CPP_DIR}/include/llama.h")
message(FATAL_ERROR "Supply -DLLAMA_CPP_DIR=<pinned patched llama.cpp b11461 source>")
endif()
set(CMAKE_CXX_STANDARD 17)
if(MSVC)
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>" CACHE STRING "Standalone worker static MSVC runtime" FORCE)
endif()
set(BUILD_SHARED_LIBS OFF CACHE BOOL "Static local worker runtime" FORCE)
set(GGML_OPENMP OFF CACHE BOOL "Use bounded native llama thread pools without external OpenMP runtime" FORCE)
set(GGML_NATIVE OFF CACHE BOOL "Portable CPU kernels")
# GPU backends are explicit production build options. The Windows VM builds
# with all of these OFF; runtime device selection always retains CPU fallback.
set(GGML_CUDA OFF CACHE BOOL "Production CUDA backend")
set(GGML_VULKAN OFF CACHE BOOL "Production Vulkan backend")
set(GGML_METAL OFF CACHE BOOL "Production Metal backend")
set(SEARCHI_ACCELERATION "cpu" CACHE STRING "cpu, auto, metal, vulkan, or cuda")
if(SEARCHI_ACCELERATION STREQUAL "cpu")
set(GGML_CUDA OFF CACHE BOOL "CPU test build" FORCE)
set(GGML_VULKAN OFF CACHE BOOL "CPU test build" FORCE)
set(GGML_METAL OFF CACHE BOOL "CPU test build" FORCE)
elseif(SEARCHI_ACCELERATION STREQUAL "metal" OR (SEARCHI_ACCELERATION STREQUAL "auto" AND APPLE))
set(GGML_METAL ON CACHE BOOL "Production Metal backend" FORCE)
elseif(SEARCHI_ACCELERATION STREQUAL "cuda")
set(GGML_CUDA ON CACHE BOOL "Production CUDA backend" FORCE)
elseif(SEARCHI_ACCELERATION STREQUAL "vulkan")
set(GGML_VULKAN ON CACHE BOOL "Production Vulkan backend" FORCE)
elseif(SEARCHI_ACCELERATION STREQUAL "auto")
find_package(Vulkan QUIET)
if(Vulkan_FOUND)
set(GGML_VULKAN ON CACHE BOOL "Available production Vulkan backend" FORCE)
else()
message(STATUS "No production acceleration SDK detected; retaining CPU backend")
endif()
else()
message(FATAL_ERROR "Unknown SEARCHI_ACCELERATION=${SEARCHI_ACCELERATION}")
endif()
set(LLAMA_BUILD_COMMON OFF CACHE BOOL "No extra common runtime" FORCE)
set(LLAMA_BUILD_TOOLS OFF CACHE BOOL "No runtime tools" FORCE)
set(LLAMA_BUILD_SERVER OFF CACHE BOOL "No HTTP server" FORCE)
set(LLAMA_BUILD_EXAMPLES OFF CACHE BOOL "No examples" FORCE)
set(LLAMA_BUILD_TESTS OFF CACHE BOOL "No upstream tests in worker build" FORCE)
set(LLAMA_BUILD_MTMD OFF CACHE BOOL "Text worker; media encoder follows separately" FORCE)
set(LLAMA_OPENSSL OFF CACHE BOOL "No network runtime dependencies" FORCE)
add_subdirectory("${LLAMA_CPP_DIR}" llama-build)
add_executable(searchi-gemma-worker worker.cpp)
target_include_directories(searchi-gemma-worker PRIVATE "${LLAMA_CPP_DIR}/vendor")
target_link_libraries(searchi-gemma-worker PRIVATE llama)
target_compile_definitions(searchi-gemma-worker PRIVATE SEARCHI_LLAMA_TAG="b11461-no-logits-v1")
install(TARGETS searchi-gemma-worker RUNTIME DESTINATION bin)
+221
View File
@@ -0,0 +1,221 @@
// Local NDJSON helper around the pinned llama.cpp encoder. stdout is exclusively
// protocol output; llama diagnostics go to stderr. No model download or HTTP.
#include "llama.h"
#include "nlohmann/json.hpp"
#include <algorithm>
#include <cmath>
#include <cstdlib>
#include <cstdio>
#include <iostream>
#include <memory>
#include <stdexcept>
#include <string>
#include <vector>
using json = nlohmann::json;
constexpr size_t max_request_bytes = 2 * 1024 * 1024;
constexpr size_t max_passage_bytes = 1024 * 1024;
constexpr size_t max_text_bytes = 65536;
constexpr size_t max_windows = 256;
constexpr int context_tokens = 2048;
constexpr int dimensions = 768;
static void require(bool condition, const char * message) {
if (!condition) throw std::runtime_error(message);
}
struct InferenceError : std::runtime_error { using std::runtime_error::runtime_error; };
static void require_inference(bool condition, const char * message) {
if (!condition) throw InferenceError(message);
}
class Encoder {
llama_model * model = nullptr;
llama_context * context = nullptr;
llama_batch_ext * batch = nullptr;
const llama_vocab * vocab = nullptr;
std::string model_path;
void release() {
if (batch) llama_batch_ext_free(batch);
if (context) llama_free(context);
if (model) llama_model_free(model);
batch = nullptr; context = nullptr; model = nullptr; vocab = nullptr;
}
bool open(bool gpu) {
auto mp = llama_model_default_params();
mp.n_gpu_layers = gpu ? 999 : 0;
mp.progress_callback = [](float,void *) {return true;};
model = llama_model_load_from_file(model_path.c_str(),mp);
if (!model) return false;
auto cp = llama_context_default_params();
cp.n_ctx = context_tokens; cp.n_batch = context_tokens; cp.n_ubatch = context_tokens;
cp.n_seq_max = 1; cp.kv_unified = true; cp.embeddings = true;
cp.pooling_type = LLAMA_POOLING_TYPE_MEAN;
cp.n_threads = threads; cp.n_threads_batch = threads;
context = llama_init_from_model(model,cp);
if (!context) {release(); return false;}
require(llama_model_n_embd_out(model) == dimensions,"EmbeddingGemma 2 output must have 768 dimensions");
vocab = llama_model_get_vocab(model);
batch = llama_batch_ext_init(context);
if (!batch) {release(); return false;}
device = gpu ? "gpu" : "cpu";
return true;
}
public:
std::string device;
int threads;
Encoder(const std::string & path, int cpu_threads, bool want_gpu) : model_path(path),threads(cpu_threads) {
llama_log_set([](ggml_log_level level, const char * text, void *) {
if (level >= GGML_LOG_LEVEL_WARN) std::fputs(text,stderr);
},nullptr);
llama_backend_init();
bool use_gpu = want_gpu && llama_supports_gpu_offload();
require(open(use_gpu) || (use_gpu && open(false)),"Cannot initialize local EmbeddingGemma 2 model/context");
}
~Encoder() {
release();
llama_backend_free();
}
bool healthy() const {return context != nullptr && batch != nullptr;}
void retry_cpu() {
require(device == "gpu","Only a GPU inference failure can request a CPU retry");
release();
require(open(false),"CPU fallback initialization failed after GPU inference failure");
}
std::vector<llama_token> tokenize(const std::string & text, bool specials) const {
std::vector<llama_token> tokens(text.size() + 8);
int count = llama_tokenize(vocab, text.data(), int(text.size()), tokens.data(), int(tokens.size()), specials, false);
if (count < 0) {
tokens.resize(size_t(-count));
count = llama_tokenize(vocab, text.data(), int(text.size()), tokens.data(), int(tokens.size()), specials, false);
}
require(count >= 0, "Tokenizer failed");
tokens.resize(size_t(count));
return tokens;
}
std::string decode(const std::vector<llama_token> & tokens, size_t count) const {
std::vector<char> text(max_passage_bytes + 64);
int length = llama_detokenize(vocab, tokens.data(), int(count), text.data(), int(text.size()), false, false);
require(length >= 0, "Detokenized passage exceeds byte budget");
return std::string(text.data(),size_t(length));
}
static std::string document_prefix(const std::string & title) {
require(title.size() <= 512, "Document title exceeds 512 UTF-8 bytes");
return "title: " + (title.empty() ? "none" : title) + " | text: ";
}
std::vector<float> embed(const std::string & text, int cap) {
require(!text.empty() && text.size() <= max_text_bytes, "Embedding text must be nonempty and at most 64 KiB");
auto tokens = tokenize(text,true);
require(!tokens.empty() && tokens.size() <= size_t(cap), "Prompt exceeds model token budget; split document windows first");
llama_batch_ext_clear(batch);
for (size_t i = 0; i < tokens.size(); ++i) {
int index = llama_batch_ext_add_token(batch,0,tokens[i]);
require(index >= 0, "Embedding batch is full");
llama_pos position[4] = {llama_pos(i),0,0,0};
require(llama_batch_ext_set_pos(batch,index,position), "Cannot set encoder position");
require(llama_batch_ext_set_output_embd(batch,index,true), "Cannot select embedding output");
}
require_inference(llama_process(context,LLAMA_PROCESS_TYPE_ENCODE,batch) == 0, "llama_process encoder failed");
const float * pooled = llama_get_embeddings_seq(context,0);
require_inference(pooled != nullptr, "Encoder returned no pooled vector");
double norm = 0;
for (int i = 0; i < dimensions; ++i) { require_inference(std::isfinite(pooled[i]), "Nonfinite encoder output"); norm += double(pooled[i])*pooled[i]; }
require_inference(norm > 0 && std::isfinite(norm), "Zero or nonfinite embedding norm");
std::vector<float> vector(dimensions);
for (int i = 0; i < dimensions; ++i) vector[i] = float(pooled[i]/std::sqrt(norm));
return vector;
}
json windows(const std::string & text, const std::string & title) const {
require(!text.empty() && text.size() <= max_passage_bytes, "Passage exceeds 1 MiB or is empty");
auto tokens = tokenize(text,false);
require(!tokens.empty(), "Passage has no tokens");
// Preserve actual source offsets, rather than inventing locations from
// a lossy decoded representation. Unexpected normalization is an error.
require(decode(tokens,tokens.size()) == text, "Tokenizer roundtrip changed source text; cannot verify source offsets");
size_t prompt = tokenize(document_prefix(title),true).size();
require(prompt + 132 < size_t(context_tokens), "Document prefix exceeds token budget");
size_t budget = size_t(context_tokens) - prompt - 4;
size_t stride = budget - 128;
json result = json::array();
for (size_t start = 0; start < tokens.size(); start += stride) {
require(result.size() < max_windows, "Passage exceeds 256 windows");
size_t end = std::min(start + budget,tokens.size());
size_t begin_byte = start ? decode(tokens,start).size() : 0;
size_t end_byte = decode(tokens,end).size();
// Token pieces can split a UTF-8 character. Enlarge to valid source
// boundaries; the four-token reserve covers boundary re-tokenization.
while (begin_byte > 0 && begin_byte < text.size() && (uint8_t(text[begin_byte]) & 0xc0) == 0x80) --begin_byte;
while (end_byte < text.size() && (uint8_t(text[end_byte]) & 0xc0) == 0x80) ++end_byte;
require(begin_byte < end_byte && end_byte <= text.size(), "Invalid source window byte range");
auto fragment = text.substr(begin_byte,end_byte-begin_byte);
require(fragment.size() + document_prefix(title).size() <= max_text_bytes, "Window exceeds text byte budget");
require(tokenize(document_prefix(title)+fragment,true).size() <= size_t(context_tokens), "Window re-tokenization exceeds context budget");
result.push_back({{"text",fragment},{"start_byte",begin_byte},{"end_byte",end_byte}});
if (end == tokens.size()) break;
}
return result;
}
json handle(const json & request) {
auto op = request.at("op").get<std::string>();
if (op == "windows") return {{"windows",windows(request.at("text").get<std::string>(),request.value("title",std::string()))}};
if (op == "query") return {{"vectors",json::array({embed("task: search result | query: "+request.at("text").get<std::string>(),512)})}};
if (op == "documents") {
const auto & texts = request.at("texts");
require(texts.is_array() && !texts.empty() && texts.size() <= 8,"Batch size must be between 1 and 8");
auto prefix = document_prefix(request.value("title",std::string()));
json vectors = json::array();
for (const auto & text : texts) vectors.push_back(embed(prefix+text.get<std::string>(),context_tokens));
return {{"vectors",vectors}};
}
throw std::runtime_error("Unknown embedding operation");
}
};
int main(int argc, char ** argv) {
try {
std::string path, device = "auto"; int threads = 1;
for (int i = 1; i < argc; ++i) {
require(i+1 < argc,"Worker options require values");
std::string option = argv[i++], value = argv[i];
if (option == "--model") path = value;
else if (option == "--threads") threads = std::stoi(value);
else if (option == "--device") device = value;
else throw std::runtime_error("Unknown worker option");
}
require(!path.empty() && threads >= 1 && threads <= 8,"Model path required; threads must be 1–8");
require(device == "cpu" || device == "auto","Device must be cpu or auto");
bool force_cpu = std::getenv("SEARCHI_FORCE_CPU") && std::string(std::getenv("SEARCHI_FORCE_CPU")) == "1";
Encoder encoder(path,threads,device == "auto" && !force_cpu);
std::cout << json({{"ready",true},{"model","google/embeddinggemma-2"},{"dimensions",dimensions},
{"device",encoder.device},{"threads",threads},{"runtime",SEARCHI_LLAMA_TAG}}).dump() << '\n' << std::flush;
for (;;) {
std::string line; char c;
while (std::cin.get(c) && c != '\n') {
require(line.size() < max_request_bytes,"Request exceeds 2 MiB"); line.push_back(c);
}
if (!std::cin && line.empty()) break;
json response;
try {
auto request = json::parse(line);
try {response = encoder.handle(request);}
catch (const InferenceError &) {
if (encoder.device != "gpu") throw;
encoder.retry_cpu();
response = encoder.handle(request); // Exactly one CPU retry.
response["fallback"] = "gpu_inference_failure";
}
response["ok"] = true;
}
catch (const std::exception & error) { response = {{"ok",false},{"error",error.what()},{"fatal",!encoder.healthy()}}; }
response["device"] = encoder.device;
auto encoded = response.dump();
require(encoded.size() <= 4*1024*1024,"Response exceeds 4 MiB");
std::cout << encoded << '\n' << std::flush;
if (!encoder.healthy()) return 1;
}
return 0;
} catch (const std::exception & error) {
std::cerr << "Searchi Gemma worker: " << error.what() << '\n';
return 1;
}
}
+398
View File
@@ -0,0 +1,398 @@
use crate::TextWindow;
use anyhow::{ensure, Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::{
fs::File,
io::{BufRead, BufReader, Read, Write},
path::{Path, PathBuf},
process::{Child, ChildStdin, Command, Stdio},
sync::mpsc::{self, Receiver},
thread,
time::Duration,
};
pub const GEMMA_DIMENSIONS: usize = 768;
pub const GEMMA_FINGERPRINT: &str = "google/embeddinggemma-2:bfcd298762cc34d0357ece5ebdd31791a3a374d8:q8_0:llama-b11461-no-logits-v1:mean:l2:query512:doc2048:windows-overlap128:prompts-v1";
const MODEL_FILE: &str = "embeddinggemma-2-Q8_0.gguf";
const MODEL_BYTES: u64 = 309855456;
const MODEL_SHA256: &str = "2188ac1deca4b77dffefd603c2776a9d76d9d74ec01841392982ebb840b09135";
const MAX_REQUEST: usize = 2 * 1024 * 1024;
const MAX_RESPONSE: usize = 4 * 1024 * 1024;
#[derive(Debug, Clone, Copy)]
pub enum Device {
Auto,
Cpu,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct WorkerInfo {
pub ready: bool,
pub model: String,
pub dimensions: usize,
pub device: String,
pub threads: usize,
pub runtime: String,
}
/// Persistent isolated native worker. No HTTP or remote model fallback.
pub struct GemmaEmbedder {
child: Child,
stdin: ChildStdin,
responses: Receiver<std::result::Result<Value, String>>,
pub info: WorkerInfo,
healthy: bool,
force_cpu: bool,
model_dir: PathBuf,
worker_path: PathBuf,
}
impl Drop for GemmaEmbedder {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
impl GemmaEmbedder {
/// Load the verified Google model and explicitly supplied local worker.
/// Production selects available acceleration with CPU fallback. Tests set
/// `SEARCHI_FORCE_CPU=1` or call `load_with_device(..., Device::Cpu)`.
pub fn load(
model_dir: impl AsRef<Path>,
worker: impl AsRef<Path>,
threads: usize,
) -> Result<Self> {
Self::load_with_device(model_dir, worker, threads, Device::Auto)
}
pub fn load_with_device(
model_dir: impl AsRef<Path>,
worker: impl AsRef<Path>,
threads: usize,
device: Device,
) -> Result<Self> {
ensure!(
(1..=8).contains(&threads),
"CPU threads must be between 1 and 8"
);
let model_dir = model_dir.as_ref().to_path_buf();
let model = model_dir.join(MODEL_FILE);
verify_model(&model)?;
let worker = worker
.as_ref()
.canonicalize()
.context("Cannot resolve local Gemma worker executable")?;
ensure!(
worker.is_file(),
"Gemma worker must be a local executable file"
);
let force_cpu = std::env::var("SEARCHI_FORCE_CPU").is_ok_and(|value| value == "1");
let cpu = force_cpu || matches!(device, Device::Cpu);
let mut command = Command::new(&worker);
command
.args(["--model"])
.arg(model)
.args([
"--threads",
&threads.to_string(),
"--device",
if cpu { "cpu" } else { "auto" },
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit());
// Background helpers must not create a visible console on Windows.
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
command.creation_flags(0x08000000); // CREATE_NO_WINDOW
}
let mut child = command.spawn().context("Cannot start local Gemma worker")?;
let stdin = child.stdin.take().context("Worker stdin missing")?;
let stdout = child.stdout.take().context("Worker stdout missing")?;
let (sender, responses) = mpsc::sync_channel(1);
thread::spawn(move || {
let mut reader = BufReader::new(stdout);
loop {
let response = read_response(&mut reader);
let failed = response.is_err();
if sender.send(response).is_err() || failed {
break;
}
}
});
let ready = match responses.recv_timeout(Duration::from_secs(120)) {
Ok(Ok(value)) => value,
other => {
let _ = child.kill();
let _ = child.wait();
anyhow::bail!("Gemma worker did not initialize: {other:?}");
}
};
let info: WorkerInfo = match serde_json::from_value(ready) {
Ok(info) => info,
Err(error) => {
let _ = child.kill();
let _ = child.wait();
return Err(error).context("Invalid Gemma worker handshake");
}
};
if !info.ready
|| info.model != "google/embeddinggemma-2"
|| info.dimensions != GEMMA_DIMENSIONS
|| info.runtime != "b11461-no-logits-v1"
|| info.threads != threads
|| !["cpu", "gpu"].contains(&info.device.as_str())
|| (cpu && info.device != "cpu")
{
let _ = child.kill();
let _ = child.wait();
anyhow::bail!("Gemma worker handshake violates configured model/device contract");
}
Ok(Self {
child,
stdin,
responses,
info,
healthy: true,
force_cpu: cpu,
model_dir,
worker_path: worker,
})
}
fn request(&mut self, request: Value) -> Result<Value> {
ensure!(
self.healthy,
"Gemma worker session failed; reopen it explicitly"
);
let encoded = serde_json::to_vec(&request)?;
ensure!(
encoded.len() < MAX_REQUEST,
"Gemma request exceeds 2 MiB protocol budget"
);
let response = match self.exchange(&encoded) {
Ok(response) => response,
Err(error) if can_retry_cpu(&self.info.device, self.force_cpu) => {
// GPU runtimes can abort rather than return an encoder error.
// Restart once as CPU and retry this same bounded request.
let _ = self.child.wait();
let replacement = Self::load_with_device(
&self.model_dir,
&self.worker_path,
self.info.threads,
Device::Cpu,
)
.with_context(|| {
format!("CPU worker restart failed after GPU transport error: {error}")
})?;
*self = replacement;
self.exchange(&encoded)?
}
Err(error) => return Err(error),
};
if let Some(device) = response["device"].as_str() {
ensure!(
["cpu", "gpu"].contains(&device) && (!self.force_cpu || device == "cpu"),
"Worker response violates configured device policy"
);
self.info.device = device.into();
}
if response["fatal"] == true {
self.healthy = false;
self.info.ready = false;
let _ = self.child.kill();
}
ensure!(
response["ok"] == true,
"Gemma worker error: {}",
response["error"].as_str().unwrap_or("unknown error")
);
Ok(response)
}
fn exchange(&mut self, encoded: &[u8]) -> Result<Value> {
let sent = (|| {
self.stdin.write_all(encoded)?;
self.stdin.write_all(b"\n")?;
self.stdin.flush()
})()
.context("Cannot write local Gemma worker request");
if let Err(error) = sent {
self.healthy = false;
self.info.ready = false;
let _ = self.child.kill();
return Err(error);
}
let response = match self.responses.recv_timeout(Duration::from_secs(180)) {
Ok(Ok(value)) => value,
other => {
self.healthy = false;
self.info.ready = false;
let _ = self.child.kill();
anyhow::bail!("Gemma worker request failed: {other:?}");
}
};
Ok(response)
}
pub fn split_windows(&mut self, text: &str, title: &str) -> Result<Vec<TextWindow>> {
ensure!(
!text.trim().is_empty() && text.len() <= 1024 * 1024,
"Passage must be nonempty and at most 1 MiB"
);
let response = self.request(json!({"op":"windows","text":text,"title":title}))?;
let windows: Vec<TextWindow> = serde_json::from_value(response["windows"].clone())?;
ensure!(
!windows.is_empty() && windows.len() <= 256,
"Invalid Gemma window count"
);
for window in &windows {
ensure!(
text.get(window.start_byte..window.end_byte) == Some(window.text.as_str()),
"Gemma window does not match original source range"
);
}
ensure!(
windows
.last()
.is_some_and(|window| window.end_byte == text.len()),
"Gemma windows did not cover the passage tail"
);
Ok(windows)
}
pub fn embed_documents(&mut self, texts: &[&str], title: &str) -> Result<Vec<Vec<f32>>> {
ensure!(
!texts.is_empty() && texts.len() <= 8,
"Document batch size must be between 1 and 8"
);
ensure!(
texts
.iter()
.all(|text| !text.trim().is_empty() && text.len() <= 65536),
"Document window must be nonempty and at most 64 KiB"
);
let response = self.request(json!({"op":"documents","texts":texts,"title":title}))?;
extract_vectors(response, texts.len())
}
pub fn embed_query(&mut self, query: &str) -> Result<Vec<f32>> {
ensure!(
!query.trim().is_empty() && query.len() <= 4096,
"Query must be nonempty and at most 4096 UTF-8 bytes"
);
let response = self.request(json!({"op":"query","text":query}))?;
Ok(extract_vectors(response, 1)?.remove(0))
}
}
fn can_retry_cpu(actual_device: &str, force_cpu: bool) -> bool {
actual_device == "gpu" && !force_cpu
}
fn read_response(reader: &mut impl BufRead) -> std::result::Result<Value, String> {
let mut line = Vec::new();
match reader
.take((MAX_RESPONSE + 1) as u64)
.read_until(b'\n', &mut line)
{
Ok(0) => Err("Gemma worker closed its output".into()),
Ok(_) if line.len() > MAX_RESPONSE || line.last() != Some(&b'\n') => {
Err("Gemma worker response exceeds protocol byte budget".into())
}
Ok(_) => serde_json::from_slice(&line)
.map_err(|error| format!("Invalid Gemma worker JSON: {error}")),
Err(error) => Err(format!("Gemma worker read failed: {error}")),
}
}
fn extract_vectors(response: Value, count: usize) -> Result<Vec<Vec<f32>>> {
let vectors: Vec<Vec<f32>> = serde_json::from_value(response["vectors"].clone())?;
ensure!(
vectors.len() == count,
"Gemma returned incorrect embedding batch length"
);
for vector in &vectors {
ensure!(
vector.len() == GEMMA_DIMENSIONS && vector.iter().all(|value| value.is_finite()),
"Invalid Gemma embedding shape or values"
);
let norm = vector.iter().map(|v| v * v).sum::<f32>().sqrt();
ensure!(
(norm - 1.0).abs() < 1e-4,
"Gemma embedding is not L2 normalized"
);
}
Ok(vectors)
}
fn verify_model(path: &Path) -> Result<()> {
let file = File::open(path).with_context(|| {
format!(
"Missing Google EmbeddingGemma 2 model {}; run explicit model setup first",
path.display()
)
})?;
ensure!(
file.metadata()?.len() == MODEL_BYTES,
"EmbeddingGemma 2 model size mismatch"
);
let mut reader = BufReader::new(file);
let mut hash = Sha256::new();
let mut buffer = [0; 65536];
loop {
let count = reader.read(&mut buffer)?;
if count == 0 {
break;
}
hash.update(&buffer[..count]);
}
ensure!(
format!("{:x}", hash.finalize()) == MODEL_SHA256,
"EmbeddingGemma 2 model SHA256 mismatch"
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_google_model_fails_without_starting_worker() {
assert!(GemmaEmbedder::load("missing-google-model", "missing-worker", 1).is_err());
}
#[test]
fn retry_policy_never_enables_gpu_or_retries_cpu_as_gpu() {
assert!(can_retry_cpu("gpu", false));
assert!(!can_retry_cpu("gpu", true));
assert!(!can_retry_cpu("cpu", false));
assert!(!can_retry_cpu("cpu", true));
}
#[test]
fn protocol_rejects_oversized_truncated_and_malformed_frames() {
assert!(read_response(&mut std::io::Cursor::new(vec![b'x'; MAX_RESPONSE + 1])).is_err());
assert!(read_response(&mut std::io::Cursor::new(b"{\"ok\":true}")).is_err());
assert!(read_response(&mut std::io::Cursor::new(b"not-json\n")).is_err());
assert_eq!(
read_response(&mut std::io::Cursor::new(b"{\"ok\":true}\n")).unwrap()["ok"],
true
);
}
#[test]
fn protocol_rejects_wrong_embedding_shape_count_and_normalization() {
assert!(extract_vectors(json!({"vectors":[]}), 1).is_err());
assert!(extract_vectors(json!({"vectors":[[1.0,0.0]]}), 1).is_err());
assert!(extract_vectors(json!({"vectors":[vec![0.0;GEMMA_DIMENSIONS]]}), 1).is_err());
let mut invalid = vec![0.0; GEMMA_DIMENSIONS];
invalid[0] = 1.0;
invalid[1] = 1.0;
assert!(extract_vectors(json!({"vectors":[invalid]}), 1).is_err());
}
}
+17 -288
View File
@@ -1,290 +1,19 @@
//! Offline text embeddings using a pinned quantized multilingual model.
//!
//! Model provisioning is explicit and separate. This crate never downloads a
//! model or runtime and exposes no GPU execution-provider configuration.
//! Local Google EmbeddingGemma 2 inference through an isolated native worker.
//! The rejected MiniLM experiment is available only under `minilm-proof`.
mod gemma;
pub use gemma::{Device, GemmaEmbedder, WorkerInfo, GEMMA_DIMENSIONS, GEMMA_FINGERPRINT};
use std::{
fs::File,
io::{BufReader, Read},
path::{Path, PathBuf},
sync::Mutex,
/// A verified source byte range covered by a model token window.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct TextWindow {
pub text: String,
pub start_byte: usize,
pub end_byte: usize,
}
#[cfg(feature = "minilm-proof")]
mod minilm;
#[cfg(feature = "minilm-proof")]
pub use minilm::{
CpuEmbedder, DIMENSIONS, MAX_BATCH, MAX_TEXT_BYTES, MAX_TOKENS, MODEL_FINGERPRINT,
};
use anyhow::{bail, ensure, Context, Result};
use ort::{ep, session::Session, value::Tensor};
use sha2::{Digest, Sha256};
use tokenizers::{PaddingDirection, PaddingParams, Tokenizer, TruncationParams};
/// Embedding dimensionality; stored vectors must carry the model fingerprint.
pub const DIMENSIONS: usize = 384;
pub const MAX_TOKENS: usize = 128;
pub const MAX_BATCH: usize = 8;
pub const MAX_TEXT_BYTES: usize = 65536;
/// Includes model revision, quantization, pooling, normalization and token cap.
pub const MODEL_FINGERPRINT: &str =
"minilm-multilingual:2c4055b12046f11709e9df2c122e59ffbdc2f900:int8:mean:l2:128";
const ARTIFACTS: [(&str, u64, &str); 2] = [
(
"onnx/model_quantized.onnx",
118308126,
"66fc00f5f29afcaff34092e1bdd20008ca3918265a82fb9695a551e510cc4ebc",
),
(
"tokenizer.json",
17082913,
"b60b6b43406a48bf3638526314f3d232d97058bc93472ff2de930d43686fa441",
),
];
// ONNX Runtime's library and environment are process-global. Prevent concurrent
// initializers from selecting different libraries or silently overriding state.
static RUNTIME_PATH: Mutex<Option<PathBuf>> = Mutex::new(None);
/// Local, mutable inference session. Call from a bounded background worker.
pub struct CpuEmbedder {
session: Session,
tokenizer: Tokenizer,
}
impl CpuEmbedder {
/// Verify local artifacts, then load the explicitly supplied runtime.
///
/// The runtime must be initialized through this adapter before other `ort`
/// code in this process. Threads are limited to 1–8; default caller policy
/// should use one thread in a VM. Missing or damaged assets are errors.
pub fn load(
model_dir: impl AsRef<Path>,
runtime_library: impl AsRef<Path>,
threads: usize,
) -> Result<Self> {
ensure!(
(1..=8).contains(&threads),
"CPU threads must be between 1 and 8"
);
let model_dir = model_dir.as_ref();
verify_artifacts(model_dir)?;
let runtime_path = runtime_library
.as_ref()
.canonicalize()
.context("Cannot resolve explicitly supplied ONNX Runtime library")?;
ensure!(
runtime_path.is_file(),
"ONNX Runtime library must be a file"
);
{
let mut initialized = RUNTIME_PATH
.lock()
.map_err(|_| anyhow::anyhow!("Runtime initialization lock poisoned"))?;
if let Some(existing) = initialized.as_ref() {
ensure!(
*existing == runtime_path,
"ONNX Runtime already initialized with a different library"
);
} else {
let committed = ort::init_from(&runtime_path)?
.with_telemetry(false)
.with_execution_providers([ep::CPU::default().build()])
.commit();
ensure!(
committed,
"ONNX Runtime environment was already initialized outside the CPU adapter"
);
*initialized = Some(runtime_path);
}
}
let session = Session::builder()?
.with_execution_providers([ep::CPU::default().build()])
.map_err(|e| anyhow::anyhow!("CPU session configuration: {e}"))?
.with_intra_threads(threads)
.map_err(|e| anyhow::anyhow!("CPU thread configuration: {e}"))?
.with_inter_threads(1)
.map_err(|e| anyhow::anyhow!("Inter-thread configuration: {e}"))?
.with_parallel_execution(false)
.map_err(|e| anyhow::anyhow!("Sequential session configuration: {e}"))?
.commit_from_file(model_dir.join("onnx/model_quantized.onnx"))?;
let mut tokenizer = Tokenizer::from_file(model_dir.join("tokenizer.json"))
.map_err(|error| anyhow::anyhow!("Cannot load tokenizer: {error}"))?;
tokenizer
.with_truncation(Some(TruncationParams {
max_length: MAX_TOKENS,
..Default::default()
}))
.map_err(|error| anyhow::anyhow!("Cannot configure tokenizer: {error}"))?;
tokenizer.with_padding(Some(PaddingParams {
pad_id: 1,
pad_token: "<pad>".into(),
..Default::default()
}));
Ok(Self { session, tokenizer })
}
/// Produce finite, L2-normalized vectors from genuine local model inference.
///
/// Inputs above 128 tokens are truncated. Production indexing should chunk
/// documents first and keep chunk locations; never silently embed entire PDFs.
pub fn encode(&mut self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
validate_texts(texts)?;
// Encode sequentially so tokenizers cannot create an unbounded global
// Rayon worker pool independently of the caller's inference budget.
let mut encoded = texts
.iter()
.map(|text| self.tokenizer.encode(*text, true))
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(|error| anyhow::anyhow!("Tokenization failed: {error}"))?;
let sequence_len = encoded.iter().map(|item| item.len()).max().unwrap_or(0);
for item in &mut encoded {
item.pad(sequence_len, 1, 0, "<pad>", PaddingDirection::Right);
}
ensure!(
sequence_len > 0 && sequence_len <= MAX_TOKENS,
"Invalid token sequence length"
);
let ids: Vec<i64> = encoded
.iter()
.flat_map(|item| item.get_ids().iter().map(|&v| i64::from(v)))
.collect();
let masks: Vec<i64> = encoded
.iter()
.flat_map(|item| item.get_attention_mask().iter().map(|&v| i64::from(v)))
.collect();
let types: Vec<i64> = encoded
.iter()
.flat_map(|item| item.get_type_ids().iter().map(|&v| i64::from(v)))
.collect();
let shape = [texts.len(), sequence_len];
let outputs = self.session.run(ort::inputs![
"input_ids" => Tensor::from_array((shape, ids))?,
"attention_mask" => Tensor::from_array((shape, masks.clone()))?,
"token_type_ids" => Tensor::from_array((shape, types))?
])?;
let (output_shape, hidden) = outputs[0].try_extract_tensor::<f32>()?;
ensure!(
**output_shape == [texts.len() as i64, sequence_len as i64, DIMENSIONS as i64],
"Unexpected embedding output shape"
);
pool_and_normalize(hidden, &masks, texts.len(), sequence_len)
}
}
fn validate_texts(texts: &[&str]) -> Result<()> {
ensure!(
!texts.is_empty() && texts.len() <= MAX_BATCH,
"Batch size must be between 1 and {MAX_BATCH}"
);
ensure!(
texts
.iter()
.all(|text| !text.trim().is_empty() && text.len() <= MAX_TEXT_BYTES),
"Text must be nonempty and at most {MAX_TEXT_BYTES} UTF-8 bytes"
);
Ok(())
}
fn verify_artifacts(model_dir: &Path) -> Result<()> {
for (relative, bytes, expected) in ARTIFACTS {
let path = model_dir.join(relative);
let file = File::open(&path).with_context(|| {
format!(
"Missing local model artifact {}; run the explicit model downloader first",
path.display()
)
})?;
ensure!(
file.metadata()?.len() == bytes,
"Model artifact size mismatch: {}",
path.display()
);
let mut reader = BufReader::new(file);
let mut hash = Sha256::new();
let mut buffer = [0_u8; 65536];
loop {
let read = reader.read(&mut buffer)?;
if read == 0 {
break;
}
hash.update(&buffer[..read]);
}
let actual = format!("{:x}", hash.finalize());
ensure!(
actual == expected,
"Model artifact SHA256 mismatch: {}",
path.display()
);
}
Ok(())
}
fn pool_and_normalize(
hidden: &[f32],
masks: &[i64],
batch: usize,
tokens: usize,
) -> Result<Vec<Vec<f32>>> {
ensure!(
hidden.len() == batch * tokens * DIMENSIONS && masks.len() == batch * tokens,
"Invalid pooling input dimensions"
);
let mut vectors = Vec::with_capacity(batch);
for item in 0..batch {
let mut vector = vec![0_f32; DIMENSIONS];
let mut count = 0;
for token in 0..tokens {
if masks[item * tokens + token] == 0 {
continue;
}
count += 1;
let offset = (item * tokens + token) * DIMENSIONS;
for dimension in 0..DIMENSIONS {
vector[dimension] += hidden[offset + dimension];
}
}
ensure!(count > 0, "Cannot pool a fully masked sequence");
for value in &mut vector {
*value /= count as f32;
}
let norm = vector.iter().map(|value| value * value).sum::<f32>().sqrt();
if !norm.is_finite() || norm <= f32::EPSILON {
bail!("Model produced nonfinite or zero embeddings");
}
for value in &mut vector {
*value /= norm;
}
ensure!(
vector.iter().all(|value| value.is_finite()),
"Model produced nonfinite embeddings"
);
vectors.push(vector);
}
Ok(vectors)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn padding_is_excluded_and_output_is_normalized() {
let mut hidden = vec![0.0; 2 * DIMENSIONS];
hidden[0] = 3.0;
hidden[1] = 4.0;
hidden[DIMENSIONS] = 999.0;
let pooled = pool_and_normalize(&hidden, &[1, 0], 1, 2).unwrap();
assert!((pooled[0][0] - 0.6).abs() < 1e-6);
assert!((pooled[0][1] - 0.8).abs() < 1e-6);
}
#[test]
fn rejects_unbounded_inputs_and_invalid_outputs() {
assert!(validate_texts(&[]).is_err());
assert!(validate_texts(&["x"; MAX_BATCH + 1]).is_err());
assert!(validate_texts(&[" "]).is_err());
assert!(validate_texts(&[&"x".repeat(MAX_TEXT_BYTES + 1)]).is_err());
assert!(pool_and_normalize(&vec![0.0; DIMENSIONS], &[0], 1, 1).is_err());
assert!(pool_and_normalize(&vec![f32::NAN; DIMENSIONS], &[1], 1, 1).is_err());
}
#[test]
fn missing_model_does_not_load_or_download_runtime() {
assert!(CpuEmbedder::load("this-model-does-not-exist", "missing-runtime", 1).is_err());
}
}
+347
View File
@@ -0,0 +1,347 @@
//! Offline text embeddings using a pinned quantized multilingual model.
//!
//! Model provisioning is explicit and separate. This crate never downloads a
//! model or runtime and exposes no GPU execution-provider configuration.
use std::{
fs::File,
io::{BufReader, Read},
path::{Path, PathBuf},
sync::Mutex,
};
use anyhow::{bail, ensure, Context, Result};
use ort::{ep, session::Session, value::Tensor};
use sha2::{Digest, Sha256};
use tokenizers::{PaddingDirection, PaddingParams, Tokenizer, TruncationParams};
/// Embedding dimensionality; stored vectors must carry the model fingerprint.
pub const DIMENSIONS: usize = 384;
pub const MAX_TOKENS: usize = 128;
pub const MAX_BATCH: usize = 8;
pub const MAX_TEXT_BYTES: usize = 65536;
pub const MAX_PASSAGE_BYTES: usize = 1024 * 1024;
pub const MAX_WINDOWS: usize = 256;
pub const WINDOW_OVERLAP_TOKENS: usize = 24;
/// A source byte range covered by one overlapping model token window.
use crate::TextWindow;
/// Includes model revision, quantization, pooling, normalization and token cap.
pub const MODEL_FINGERPRINT: &str =
"minilm-multilingual:2c4055b12046f11709e9df2c122e59ffbdc2f900:int8:mean:l2:128";
const ARTIFACTS: [(&str, u64, &str); 2] = [
(
"onnx/model_quantized.onnx",
118308126,
"66fc00f5f29afcaff34092e1bdd20008ca3918265a82fb9695a551e510cc4ebc",
),
(
"tokenizer.json",
17082913,
"b60b6b43406a48bf3638526314f3d232d97058bc93472ff2de930d43686fa441",
),
];
// ONNX Runtime's library and environment are process-global. Prevent concurrent
// initializers from selecting different libraries or silently overriding state.
static RUNTIME_PATH: Mutex<Option<PathBuf>> = Mutex::new(None);
/// Local, mutable inference session. Call from a bounded background worker.
pub struct CpuEmbedder {
session: Session,
tokenizer: Tokenizer,
}
impl CpuEmbedder {
/// Split a bounded passage into overlapping token windows, retaining its tail.
/// Special tokens have no source range; source offsets remain UTF-8 byte
/// offsets into the original passage for accurate preview highlighting.
pub fn split_windows(&self, text: &str) -> Result<Vec<TextWindow>> {
ensure!(
!text.trim().is_empty() && text.len() <= MAX_PASSAGE_BYTES,
"Passage must be nonempty and at most {MAX_PASSAGE_BYTES} UTF-8 bytes"
);
let mut tokenizer = self.tokenizer.clone();
tokenizer
.with_truncation(Some(TruncationParams {
max_length: MAX_TOKENS,
stride: WINDOW_OVERLAP_TOKENS,
..Default::default()
}))
.map_err(|error| anyhow::anyhow!("Window tokenizer configuration: {error}"))?;
tokenizer.with_padding(None);
let encoded = tokenizer
.encode(text, true)
.map_err(|error| anyhow::anyhow!("Window tokenization failed: {error}"))?;
ensure!(
encoded.get_overflowing().len() < MAX_WINDOWS,
"Passage exceeds {MAX_WINDOWS} model windows; split it before embedding"
);
let mut windows = Vec::new();
for item in std::iter::once(&encoded).chain(encoded.get_overflowing()) {
let offsets: Vec<_> = item
.get_offsets()
.iter()
.zip(item.get_special_tokens_mask())
.filter(|((start, end), special)| **special == 0 && end > start)
.map(|(offset, _)| *offset)
.collect();
let start_byte = offsets.iter().map(|(start, _)| *start).min().unwrap_or(0);
let end_byte = offsets.iter().map(|(_, end)| *end).max().unwrap_or(0);
ensure!(start_byte < end_byte, "Token window has no source text");
let chunk = text
.get(start_byte..end_byte)
.context("Invalid UTF-8 tokenizer offsets")?;
ensure!(
chunk.len() <= MAX_TEXT_BYTES,
"Token window exceeds embedding text byte budget"
);
windows.push(TextWindow {
text: chunk.to_owned(),
start_byte,
end_byte,
});
}
Ok(windows)
}
/// Verify local artifacts, then load the explicitly supplied runtime.
///
/// The runtime must be initialized through this adapter before other `ort`
/// code in this process. Threads are limited to 1–8; default caller policy
/// should use one thread in a VM. Missing or damaged assets are errors.
pub fn load(
model_dir: impl AsRef<Path>,
runtime_library: impl AsRef<Path>,
threads: usize,
) -> Result<Self> {
ensure!(
(1..=8).contains(&threads),
"CPU threads must be between 1 and 8"
);
let model_dir = model_dir.as_ref();
verify_artifacts(model_dir)?;
let runtime_path = runtime_library
.as_ref()
.canonicalize()
.context("Cannot resolve explicitly supplied ONNX Runtime library")?;
ensure!(
runtime_path.is_file(),
"ONNX Runtime library must be a file"
);
{
let mut initialized = RUNTIME_PATH
.lock()
.map_err(|_| anyhow::anyhow!("Runtime initialization lock poisoned"))?;
if let Some(existing) = initialized.as_ref() {
ensure!(
*existing == runtime_path,
"ONNX Runtime already initialized with a different library"
);
} else {
let committed = ort::init_from(&runtime_path)?
.with_telemetry(false)
.with_execution_providers([ep::CPU::default().build()])
.commit();
ensure!(
committed,
"ONNX Runtime environment was already initialized outside the CPU adapter"
);
*initialized = Some(runtime_path);
}
}
let session = Session::builder()?
.with_execution_providers([ep::CPU::default().build()])
.map_err(|e| anyhow::anyhow!("CPU session configuration: {e}"))?
.with_intra_threads(threads)
.map_err(|e| anyhow::anyhow!("CPU thread configuration: {e}"))?
.with_inter_threads(1)
.map_err(|e| anyhow::anyhow!("Inter-thread configuration: {e}"))?
.with_parallel_execution(false)
.map_err(|e| anyhow::anyhow!("Sequential session configuration: {e}"))?
.commit_from_file(model_dir.join("onnx/model_quantized.onnx"))?;
let mut tokenizer = Tokenizer::from_file(model_dir.join("tokenizer.json"))
.map_err(|error| anyhow::anyhow!("Cannot load tokenizer: {error}"))?;
tokenizer
.with_truncation(Some(TruncationParams {
max_length: MAX_TOKENS,
..Default::default()
}))
.map_err(|error| anyhow::anyhow!("Cannot configure tokenizer: {error}"))?;
tokenizer.with_padding(Some(PaddingParams {
pad_id: 1,
pad_token: "<pad>".into(),
..Default::default()
}));
Ok(Self { session, tokenizer })
}
/// Produce finite, L2-normalized vectors from genuine local model inference.
///
/// Inputs above 128 tokens are truncated. Production indexing should chunk
/// documents first and keep chunk locations; never silently embed entire PDFs.
pub fn encode(&mut self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
validate_texts(texts)?;
// Encode sequentially so tokenizers cannot create an unbounded global
// Rayon worker pool independently of the caller's inference budget.
let mut encoded = texts
.iter()
.map(|text| self.tokenizer.encode(*text, true))
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(|error| anyhow::anyhow!("Tokenization failed: {error}"))?;
let sequence_len = encoded.iter().map(|item| item.len()).max().unwrap_or(0);
for item in &mut encoded {
item.pad(sequence_len, 1, 0, "<pad>", PaddingDirection::Right);
}
ensure!(
sequence_len > 0 && sequence_len <= MAX_TOKENS,
"Invalid token sequence length"
);
let ids: Vec<i64> = encoded
.iter()
.flat_map(|item| item.get_ids().iter().map(|&v| i64::from(v)))
.collect();
let masks: Vec<i64> = encoded
.iter()
.flat_map(|item| item.get_attention_mask().iter().map(|&v| i64::from(v)))
.collect();
let types: Vec<i64> = encoded
.iter()
.flat_map(|item| item.get_type_ids().iter().map(|&v| i64::from(v)))
.collect();
let shape = [texts.len(), sequence_len];
let outputs = self.session.run(ort::inputs![
"input_ids" => Tensor::from_array((shape, ids))?,
"attention_mask" => Tensor::from_array((shape, masks.clone()))?,
"token_type_ids" => Tensor::from_array((shape, types))?
])?;
let (output_shape, hidden) = outputs[0].try_extract_tensor::<f32>()?;
ensure!(
**output_shape == [texts.len() as i64, sequence_len as i64, DIMENSIONS as i64],
"Unexpected embedding output shape"
);
pool_and_normalize(hidden, &masks, texts.len(), sequence_len)
}
}
fn validate_texts(texts: &[&str]) -> Result<()> {
ensure!(
!texts.is_empty() && texts.len() <= MAX_BATCH,
"Batch size must be between 1 and {MAX_BATCH}"
);
ensure!(
texts
.iter()
.all(|text| !text.trim().is_empty() && text.len() <= MAX_TEXT_BYTES),
"Text must be nonempty and at most {MAX_TEXT_BYTES} UTF-8 bytes"
);
Ok(())
}
fn verify_artifacts(model_dir: &Path) -> Result<()> {
for (relative, bytes, expected) in ARTIFACTS {
let path = model_dir.join(relative);
let file = File::open(&path).with_context(|| {
format!(
"Missing local model artifact {}; run the explicit model downloader first",
path.display()
)
})?;
ensure!(
file.metadata()?.len() == bytes,
"Model artifact size mismatch: {}",
path.display()
);
let mut reader = BufReader::new(file);
let mut hash = Sha256::new();
let mut buffer = [0_u8; 65536];
loop {
let read = reader.read(&mut buffer)?;
if read == 0 {
break;
}
hash.update(&buffer[..read]);
}
let actual = format!("{:x}", hash.finalize());
ensure!(
actual == expected,
"Model artifact SHA256 mismatch: {}",
path.display()
);
}
Ok(())
}
fn pool_and_normalize(
hidden: &[f32],
masks: &[i64],
batch: usize,
tokens: usize,
) -> Result<Vec<Vec<f32>>> {
ensure!(
hidden.len() == batch * tokens * DIMENSIONS && masks.len() == batch * tokens,
"Invalid pooling input dimensions"
);
let mut vectors = Vec::with_capacity(batch);
for item in 0..batch {
let mut vector = vec![0_f32; DIMENSIONS];
let mut count = 0;
for token in 0..tokens {
if masks[item * tokens + token] == 0 {
continue;
}
count += 1;
let offset = (item * tokens + token) * DIMENSIONS;
for dimension in 0..DIMENSIONS {
vector[dimension] += hidden[offset + dimension];
}
}
ensure!(count > 0, "Cannot pool a fully masked sequence");
for value in &mut vector {
*value /= count as f32;
}
let norm = vector.iter().map(|value| value * value).sum::<f32>().sqrt();
if !norm.is_finite() || norm <= f32::EPSILON {
bail!("Model produced nonfinite or zero embeddings");
}
for value in &mut vector {
*value /= norm;
}
ensure!(
vector.iter().all(|value| value.is_finite()),
"Model produced nonfinite embeddings"
);
vectors.push(vector);
}
Ok(vectors)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn padding_is_excluded_and_output_is_normalized() {
let mut hidden = vec![0.0; 2 * DIMENSIONS];
hidden[0] = 3.0;
hidden[1] = 4.0;
hidden[DIMENSIONS] = 999.0;
let pooled = pool_and_normalize(&hidden, &[1, 0], 1, 2).unwrap();
assert!((pooled[0][0] - 0.6).abs() < 1e-6);
assert!((pooled[0][1] - 0.8).abs() < 1e-6);
}
#[test]
fn rejects_unbounded_inputs_and_invalid_outputs() {
assert!(validate_texts(&[]).is_err());
assert!(validate_texts(&["x"; MAX_BATCH + 1]).is_err());
assert!(validate_texts(&[" "]).is_err());
assert!(validate_texts(&[&"x".repeat(MAX_TEXT_BYTES + 1)]).is_err());
assert!(pool_and_normalize(&vec![0.0; DIMENSIONS], &[0], 1, 1).is_err());
assert!(pool_and_normalize(&vec![f32::NAN; DIMENSIONS], &[1], 1, 1).is_err());
}
#[test]
fn missing_model_does_not_load_or_download_runtime() {
assert!(CpuEmbedder::load("this-model-does-not-exist", "missing-runtime", 1).is_err());
}
}
@@ -1,4 +1,5 @@
//! Opt-in real-artifact test. Default tests never acquire model/runtime assets.
#![cfg(feature = "minilm-proof")]
use searchi_semantic::{CpuEmbedder, DIMENSIONS};
use std::path::PathBuf;
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Abdur Rahim
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
File diff suppressed because it is too large. Load diff
+102
View File
@@ -0,0 +1,102 @@
use anyhow::Result;
use searchi_semantic::{Device, GemmaEmbedder, GEMMA_DIMENSIONS};
#[test]
#[ignore = "Requires explicitly provisioned Google model and CPU worker"]
fn google_cpu_real_embeddings_and_verified_tail_windows() -> Result<()> {
let model = std::env::var_os("SEARCHI_GEMMA_MODEL_DIR").expect("Set SEARCHI_GEMMA_MODEL_DIR");
let worker = std::env::var_os("SEARCHI_GEMMA_WORKER").expect("Set SEARCHI_GEMMA_WORKER");
let mut embedder = GemmaEmbedder::load_with_device(model, worker, 1, Device::Cpu)?;
assert_eq!(embedder.info.device, "cpu");
assert_eq!(embedder.info.dimensions, GEMMA_DIMENSIONS);
let reference: serde_json::Value =
serde_json::from_str(include_str!("fixtures/digup-text-reference.json"))?;
let long_reference=(1..=60).map(|i|format!("Note {i}: the review moved to room {} because the projector in the main hall failed again, and the team agreed to send the slides the night before.",i%7+2)).collect::<Vec<_>>().join(" ");
let mut made = Vec::new();
for (name, query) in [
("query", "payment declined error"),
("query-bn", "ইলিশ মাছ রান্না"),
("query-ar", "متى تغلق المكتبة"),
] {
made.push((name, embedder.embed_query(query)?));
}
made.push(("document",embedder.embed_documents(&["Flight to Tokyo Haneda on November 3. Then the train to Kamakura to see the Great Buddha, and back for dinner in Shibuya."],"Tokyo itinerary")?.remove(0)));
made.push((
"document-long",
embedder.embed_documents(&[&long_reference], "")?.remove(0),
));
for (name, vector) in made {
let expected = reference["vectors"][name].as_array().unwrap();
let cosine: f64 = vector
.iter()
.zip(expected)
.map(|(a, b)| f64::from(*a) * b.as_f64().unwrap())
.sum();
println!("DigUp text reference {name}: cosine {cosine:.8}");
assert!(
cosine >= 0.999,
"Google CPU reference parity failed for {name}: {cosine}"
);
}
let documents = [
"The supplier agreement may be ended with sixty days written notice.",
"Business train tickets and hotel stays can be reimbursed by submitting receipts.",
"Die Gartenplanung sieht ein Hochbeet für Gemüse und eine Regentonne vor.",
];
let vectors = embedder.embed_documents(&documents, "none")?;
for (query, expected) in [
("Wie beenden wir die Zusammenarbeit mit dem Lieferanten?", 0),
("How do I claim my accommodation and railway expenses?", 1),
(
"Where is the plan for the vegetable bed and rainwater barrel?",
2,
),
] {
let query = embedder.embed_query(query)?;
let scores: Vec<f32> = vectors
.iter()
.map(|vector| vector.iter().zip(&query).map(|(a, b)| a * b).sum())
.collect();
let best = scores
.iter()
.enumerate()
.max_by(|a, b| a.1.total_cmp(b.1))
.unwrap()
.0;
assert_eq!(
best, expected,
"Semantic cross-language ranking failed: {scores:?}"
);
}
let long = format!("{}\nHotel accommodation on business trips is reimbursed after submitting the overnight stay receipt.",
"The routine maintenance log records ordinary uptime checks and cable inspections. ".repeat(350));
let windows = embedder.split_windows(&long, "maintenance-log.txt")?;
assert!(windows.len() > 1);
assert_eq!(windows.last().unwrap().end_byte, long.len());
assert!(windows
.last()
.unwrap()
.text
.contains("overnight stay receipt"));
let texts: Vec<_> = windows.iter().map(|window| window.text.as_str()).collect();
let vectors = embedder.embed_documents(&texts, "maintenance-log.txt")?;
let query = embedder.embed_query(
"Where does the policy say that lodging costs during work travel can be claimed?",
)?;
let scores: Vec<f32> = vectors
.iter()
.map(|vector| vector.iter().zip(&query).map(|(a, b)| a * b).sum())
.collect();
let best = scores
.iter()
.enumerate()
.max_by(|a, b| a.1.total_cmp(b.1))
.unwrap()
.0;
assert_eq!(
best,
windows.len() - 1,
"Tail retrieval did not find final window: {scores:?}"
);
Ok(())
}
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "searchi-service"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Persistent local hybrid retrieval over Searchi lexical and CPU semantic engines"
[dependencies]
anyhow.workspace = true
serde.workspace = true
searchi-core = { path = "../searchi-core" }
searchi-semantic = { path = "../searchi-semantic" }
rusqlite = { version = "0.32", features = ["bundled"] }
sha2 = "0.10"
fs2 = "0.4"
usearch = { version = "=2.26.4", default-features = false, features = ["numkong"] }
[dev-dependencies]
tempfile = "3"
serde_json.workspace = true
@@ -0,0 +1,16 @@
{
"ann_p95_ms": 1.3722,
"ann_recall_at10": 0.9850000000000001,
"database_ms": 39209.1035,
"dimensions": 768,
"graph_build_ms": 57950.0959,
"graph_memory_bytes": 554453184,
"hardware": "skylake",
"initial_eligibility_ms": 1612.2069999999999,
"kind": "synthetic index-only benchmark, not semantic-model quality",
"queries": 20,
"threads": 1,
"vectors": 100000,
"warm_passages_rehashed": 0,
"warm_service_p95_ms": 6.4033
}
+111
View File
@@ -0,0 +1,111 @@
//! Evaluate the immutable seed corpus with the actual configured Google model.
//! Usage: cargo run -p searchi-service --example evaluate -- MODEL_DIR WORKER OUTPUT_JSON
use anyhow::{ensure, Context, Result};
use searchi_core::SearchIndex;
use searchi_service::{HybridSearch, SearchOptions, SearchResult};
use serde_json::{json, Value};
use std::{fs, path::PathBuf, time::Instant};
fn expected_rank(results: &[SearchResult], filename: &str) -> Option<usize> {
results
.iter()
.position(|result| result.filename == filename)
.map(|rank| rank + 1)
}
fn main() -> Result<()> {
let args: Vec<_> = std::env::args_os().skip(1).collect();
ensure!(
args.len() == 3,
"Supply MODEL_DIR WORKER OUTPUT_JSON; this command never downloads assets"
);
ensure!(
std::env::var("SEARCHI_FORCE_CPU").is_ok_and(|value| value == "1"),
"Windows VM evaluation requires SEARCHI_FORCE_CPU=1"
);
let fixture: Value =
serde_json::from_str(include_str!("../../../fixtures/relevance/fixture.json"))?;
let temp = tempfile::tempdir()?;
let root = temp.path().join("documents");
fs::create_dir(&root)?;
let documents = fixture["documents"]
.as_array()
.context("documents missing")?;
for document in documents {
let filename = document["filename"].as_str().context("filename missing")?;
ensure!(
PathBuf::from(filename).file_name() == Some(std::ffi::OsStr::new(filename)),
"Fixture filename must be a basename"
);
fs::write(
root.join(filename),
document["text"].as_str().context("text missing")?,
)?;
}
let db = temp.path().join("index.db");
let mut lexical = SearchIndex::open(&db)?;
lexical.index_folder(&root)?;
let start = Instant::now();
let mut hybrid = HybridSearch::open(&db, &args[0], &args[1], 1)?;
let load_ms = start.elapsed().as_secs_f64() * 1000.0;
let start = Instant::now();
let indexed = hybrid.index_embeddings()?;
let indexing_ms = start.elapsed().as_secs_f64() * 1000.0;
drop(hybrid);
let start = Instant::now();
let mut hybrid = HybridSearch::open(&db, &args[0], &args[1], 1)?;
let startup_init_ms = start.elapsed().as_secs_f64() * 1000.0;
let start = Instant::now();
hybrid.warmup()?;
let background_prewarm_ms = start.elapsed().as_secs_f64() * 1000.0;
let start = Instant::now();
let first = hybrid.search(
"How can we end our relationship with the supplier?",
20,
&SearchOptions::default(),
)?;
let first_foreground_ms = start.elapsed().as_secs_f64() * 1000.0;
let first_foreground_top1 = first.first().map(|hit| hit.filename.as_str());
let mut rankings = Vec::new();
let mut durations = Vec::new();
for query in fixture["queries"].as_array().context("queries missing")? {
let text = query["text"].as_str().context("query text missing")?;
let expected = query["expected"].as_str().context("expected id missing")?;
let filename = documents
.iter()
.find(|doc| doc["id"] == expected)
.context("Expected document missing")?["filename"]
.as_str()
.unwrap();
let lexical_hits = lexical.search(text, 20)?;
let semantic_hits = hybrid.search_semantic(text, 20, &SearchOptions::default())?;
let start = Instant::now();
let hybrid_hits = hybrid.search(text, 20, &SearchOptions::default())?;
durations.push(start.elapsed().as_secs_f64() * 1000.0);
rankings.push(json!({"query":text,"expected":expected,"language":query["language"],
"lexical_rank":expected_rank(&lexical_hits,filename),
"semantic_rank":expected_rank(&semantic_hits,filename),
"hybrid_rank":expected_rank(&hybrid_hits,filename),
"hybrid_top3":hybrid_hits.iter().take(3).map(|hit| json!({"filename":hit.filename,"match_kind":hit.match_kind,"source":hit.source})).collect::<Vec<_>>()}));
}
durations.sort_by(f64::total_cmp);
let metrics = |lane: &str| {
json!({
"top1_accuracy":rankings.iter().filter(|row| row[lane]==1).count() as f64/rankings.len() as f64,
"recall_at3":rankings.iter().filter(|row| row[lane].as_u64().is_some_and(|rank| rank<=3)).count() as f64/rankings.len() as f64,
"recall_at10":rankings.iter().filter(|row| row[lane].as_u64().is_some_and(|rank| rank<=10)).count() as f64/rankings.len() as f64,
})
};
let report = json!({"model":"google/embeddinggemma-2","device":"cpu","threads":1,
"documents":documents.len(),"queries":rankings.len(),"load_ms":load_ms,"indexing_ms":indexing_ms,
"startup_init_ms":startup_init_ms,"background_prewarm_ms":background_prewarm_ms,"first_foreground_ms":first_foreground_ms,"first_foreground_top1":first_foreground_top1,
"hybrid_p50_ms":durations[durations.len()/2],"hybrid_p95_ms":durations[(durations.len()*95/100).min(durations.len()-1)],
"embedding_report":indexed,"lexical":metrics("lexical_rank"),"semantic":metrics("semantic_rank"),"hybrid":metrics("hybrid_rank"),
"rankings":rankings,"limitation":"Synthetic seed corpus only; no production quality or large-corpus latency certification"});
fs::write(&args[2], serde_json::to_string_pretty(&report)?)?;
println!(
"{}",
json!({"lexical":report["lexical"],"semantic":report["semantic"],"hybrid":report["hybrid"],"p95_ms":report["hybrid_p95_ms"]})
);
Ok(())
}
+163
View File
@@ -0,0 +1,163 @@
//! Explicit index benchmark: synthetic normalized vectors, not neural retrieval
//! certification. Includes the real generation store and warmed eligibility path.
use super::*;
use anyhow::{ensure, Result};
use std::{fs, time::Instant};
fn random(state: &mut u64) -> f32 {
*state ^= *state << 13;
*state ^= *state >> 7;
*state ^= *state << 17;
((*state >> 32) as u32 as f32 / u32::MAX as f32) * 2.0 - 1.0
}
#[test]
#[ignore = "Explicit CPU index benchmark; run cargo test --release -p searchi-service ann_100k -- --ignored --nocapture"]
fn ann_100k_quality_and_warm_metadata_latency() -> Result<()> {
ensure!(
!cfg!(debug_assertions),
"Run this performance benchmark with --release"
);
const COUNT: usize = 100_000;
const DIM: usize = 768;
const CLUSTERS: usize = 100;
let mut state = 0x19cfa024b9817731_u64;
let centers: Vec<Vec<f32>> = (0..CLUSTERS)
.map(|_| (0..DIM).map(|_| random(&mut state)).collect())
.collect();
let mut points = Vec::<Vec<f32>>::with_capacity(COUNT);
for i in 0..COUNT {
let mut vector: Vec<f32> = centers[i % CLUSTERS]
.iter()
.map(|value| value + 0.35 * random(&mut state))
.collect();
let norm = vector.iter().map(|v| v * v).sum::<f32>().sqrt();
for value in &mut vector {
*value /= norm;
}
points.push(vector);
}
let directory = tempfile::tempdir()?;
let db = directory.path().join("benchmark.db");
let lexical = searchi_core::SearchIndex::open(&db)?;
drop(lexical);
let mut store = Store::open(&db, DIM)?;
let generation = store.begin("synthetic-ann-benchmark-not-model-embeddings")?;
let started = Instant::now();
{
let transaction = store.connection.transaction()?;
let mut file=transaction.prepare("INSERT INTO files(path,root,filename,content,size,mtime,extension) VALUES(?1,?2,?3,?4,100,0,?5)")?;
let mut passage = transaction
.prepare("INSERT INTO passages(text,file_path,kind,number) VALUES(?1,?2,'line',1)")?;
let mut chunk=transaction.prepare("INSERT INTO semantic_chunks(generation,passage_id,ordinal,path,kind,number,content_hash,start_byte,end_byte,snippet,vector)
VALUES(?1,?2,0,?3,'line',1,?4,0,?5,?6,?7)")?;
for (i, vector) in points.iter().enumerate() {
let extension = if i % 10 == 0 { "md" } else { "txt" };
let filename = format!("document-{i}.{extension}");
let path = directory
.path()
.join(&filename)
.to_string_lossy()
.into_owned();
let text =
format!("Synthetic index benchmark record {i}. No inferred semantic labels.");
file.execute(params![
path,
directory.path().to_string_lossy(),
filename,
text,
extension
])?;
passage.execute(params![text, path])?;
let passage_id = transaction.last_insert_rowid();
let bytes: Vec<u8> = vector.iter().flat_map(|v| v.to_le_bytes()).collect();
chunk.execute(params![
generation,
passage_id,
path,
content_hash(&text),
text.len() as i64,
text,
bytes
])?;
}
drop(file);
drop(passage);
drop(chunk);
transaction.commit()?;
}
let database_ms = started.elapsed().as_secs_f64() * 1000.0;
let started = Instant::now();
store.activate(generation)?;
let graph_build_ms = started.elapsed().as_secs_f64() * 1000.0;
let ann = &store.ann_cache.as_ref().unwrap().1;
let hardware = ann.hardware_acceleration();
let graph_bytes = ann.memory_usage();
let query_ids: Vec<_> = (0..20).map(|i| (i * 4999 + 37) % COUNT).collect();
let mut recall = 0.0;
let mut ann_times = Vec::new();
for &query_id in &query_ids {
let query = &points[query_id];
let mut truth: Vec<_> = points
.iter()
.enumerate()
.map(|(i, vector)| (i, vector.iter().zip(query).map(|(a, b)| a * b).sum::<f32>()))
.collect();
truth.sort_unstable_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
let expected: HashSet<_> = truth.iter().take(10).map(|(i, _)| *i as u64 + 1).collect();
let started = Instant::now();
let hits = ann.search(query, 10)?;
ann_times.push(started.elapsed().as_secs_f64() * 1000.0);
recall += hits
.keys
.iter()
.filter(|key| expected.contains(key))
.count() as f64
/ 10.0;
}
let started = Instant::now();
let _ = store.search(
&points[query_ids[0]],
"synthetic-ann-benchmark-not-model-embeddings",
10,
&SearchOptions::default(),
)?;
let initial_eligibility_ms = started.elapsed().as_secs_f64() * 1000.0;
let warmed_hashes = HASH_CALLS.with(std::cell::Cell::get);
let mut service_times = Vec::new();
for &query_id in &query_ids {
let started = Instant::now();
let _ = store.search(
&points[query_id],
"synthetic-ann-benchmark-not-model-embeddings",
10,
&SearchOptions::default(),
)?;
service_times.push(started.elapsed().as_secs_f64() * 1000.0);
}
ensure!(
HASH_CALLS.with(std::cell::Cell::get) == warmed_hashes,
"Warm benchmark queries rehashed unchanged passages"
);
ann_times.sort_by(f64::total_cmp);
service_times.sort_by(f64::total_cmp);
let p95 = |values: &[f64]| values[(values.len() * 95 / 100).min(values.len() - 1)];
let report = serde_json::json!({"kind":"synthetic index-only benchmark, not semantic-model quality",
"vectors":COUNT,"dimensions":DIM,"queries":query_ids.len(),"threads":1,"hardware":hardware,
"database_ms":database_ms,"graph_build_ms":graph_build_ms,"graph_memory_bytes":graph_bytes,
"ann_recall_at10":recall/query_ids.len() as f64,"ann_p95_ms":p95(&ann_times),
"initial_eligibility_ms":initial_eligibility_ms,"warm_service_p95_ms":p95(&service_times),"warm_passages_rehashed":0});
println!("{}", serde_json::to_string_pretty(&report)?);
if let Some(path) = std::env::var_os("SEARCHI_ANN_REPORT") {
fs::write(path, serde_json::to_string_pretty(&report)?)?;
}
ensure!(
report["ann_recall_at10"].as_f64().unwrap() >= 0.9,
"ANN recall quality gate failed; preserve report and tune graph parameters"
);
ensure!(
report["warm_service_p95_ms"].as_f64().unwrap() < 50.0,
"Warm retrieval latency gate failed"
);
Ok(())
}
+531
View File
@@ -0,0 +1,531 @@
//! Shared offline hybrid search using real Google embeddings and persisted,
//! filtered HNSW retrieval. Production-scale validation remains a release gate.
mod store;
use anyhow::{ensure, Result};
use searchi_core::SearchIndex;
pub use searchi_core::{SearchOptions, SearchResult};
pub use searchi_semantic::WorkerInfo;
use searchi_semantic::{GemmaEmbedder, TextWindow, GEMMA_DIMENSIONS, GEMMA_FINGERPRINT};
use serde::Serialize;
use std::{collections::HashMap, path::Path};
use store::{Store, MAX_SCAN_CHUNKS};
const MAX_BATCH: usize = 8;
const BYTE_SLICE: usize = 64 * 1024;
const BYTE_OVERLAP: usize = 512;
const MAX_PASSAGE_WINDOWS: usize = 2048;
const CHUNK_POLICY: &str = "byte64k-overlap512-title512-v1";
fn generation_fingerprint(model: &str) -> String {
format!("{model}:{CHUNK_POLICY}")
}
/// Model-specific query/document prompts and actual tokenizer windows belong
/// to the provider. Storage and ranking never assume a particular dimension.
pub trait EmbeddingProvider: Send {
fn fingerprint(&self) -> &str;
fn dimensions(&self) -> usize;
fn split_windows(&mut self, text: &str, title: &str) -> Result<Vec<TextWindow>>;
fn embed_documents(&mut self, texts: &[&str], title: &str) -> Result<Vec<Vec<f32>>>;
fn embed_query(&mut self, query: &str) -> Result<Vec<f32>>;
fn worker_info(&self) -> Option<searchi_semantic::WorkerInfo> {
None
}
}
impl EmbeddingProvider for GemmaEmbedder {
fn fingerprint(&self) -> &str {
GEMMA_FINGERPRINT
}
fn dimensions(&self) -> usize {
GEMMA_DIMENSIONS
}
fn split_windows(&mut self, text: &str, title: &str) -> Result<Vec<TextWindow>> {
self.split_windows(text, title)
}
fn embed_documents(&mut self, texts: &[&str], title: &str) -> Result<Vec<Vec<f32>>> {
self.embed_documents(texts, title)
}
fn embed_query(&mut self, query: &str) -> Result<Vec<f32>> {
self.embed_query(query)
}
fn worker_info(&self) -> Option<searchi_semantic::WorkerInfo> {
Some(self.info.clone())
}
}
#[derive(Debug, Serialize)]
pub struct EmbeddingReport {
pub passages: usize,
pub chunks: usize,
pub generation: i64,
pub model_fingerprint: String,
}
#[derive(Debug, Serialize)]
pub struct EmbeddingStatus {
pub generation: Option<i64>,
pub chunks: usize,
pub model_fingerprint: Option<String>,
pub stale: bool,
/// Rebuild is generation-based: all current passages are pending when stale.
pub pending: usize,
}
/// Shared local hybrid service. Google EmbeddingGemma 2 is the default and
/// only shipped provider; no alternative model is silently substituted.
pub struct HybridSearch {
lexical: SearchIndex,
embedder: Box<dyn EmbeddingProvider>,
store: Store,
generation_fingerprint: String,
}
impl HybridSearch {
/// Actual model/runtime/device reported by the persistent native worker.
pub fn worker_info(&self) -> Option<searchi_semantic::WorkerInfo> {
self.embedder.worker_info()
}
/// Cheap local status check. No model, runtime worker, or network is loaded.
/// Corpus revision triggers detect file inserts, updates, and deletions.
pub fn embedding_status(db: impl AsRef<Path>) -> Result<EmbeddingStatus> {
let connection =
rusqlite::Connection::open_with_flags(db, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?;
connection.busy_timeout(std::time::Duration::from_secs(5))?;
let exists: bool = connection.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE name='semantic_meta')",
[],
|row| row.get(0),
)?;
let pending: usize =
connection.query_row("SELECT count(*) FROM passages", [], |row| row.get(0))?;
if !exists {
return Ok(EmbeddingStatus {
generation: None,
chunks: 0,
model_fingerprint: None,
stale: true,
pending,
});
}
let version: i64 = connection.query_row(
"SELECT schema_version FROM semantic_meta WHERE singleton=1",
[],
|row| row.get(0),
)?;
if version < 5 {
return Ok(EmbeddingStatus {
generation: None,
chunks: 0,
model_fingerprint: None,
stale: true,
pending,
});
}
ensure!(
version == 5,
"Unsupported semantic schema version {version}"
);
let (generation,fingerprint,revision,current):(Option<i64>,Option<String>,Option<i64>,i64)=connection.query_row(
"SELECT g.id,g.fingerprint,CASE WHEN g.ann_blob IS NOT NULL AND g.dimensions=768 THEN g.corpus_revision END,m.corpus_revision FROM semantic_meta m LEFT JOIN semantic_generations g ON g.id=m.active_generation WHERE m.singleton=1",[],
|row| Ok((row.get(0)?,row.get(1)?,row.get(2)?,row.get(3)?)))?;
let chunks: usize = connection.query_row(
"SELECT count(*) FROM semantic_chunks WHERE generation=?1",
[generation],
|row| row.get(0),
)?;
let stale = revision != Some(current)
|| fingerprint.as_deref() != Some(generation_fingerprint(GEMMA_FINGERPRINT).as_str());
Ok(EmbeddingStatus {
generation,
chunks,
model_fingerprint: fingerprint,
stale,
pending: if stale { pending } else { 0 },
})
}
pub fn open(
db: impl AsRef<Path>,
model_dir: impl AsRef<Path>,
runtime_library: impl AsRef<Path>,
threads: usize,
) -> Result<Self> {
let embedder = GemmaEmbedder::load(model_dir, runtime_library, threads)?;
Self::with_provider(db, Box::new(embedder))
}
/// Explicit provider injection for future model generations, never fallback.
pub fn with_provider(
db: impl AsRef<Path>,
embedder: Box<dyn EmbeddingProvider>,
) -> Result<Self> {
let lexical = SearchIndex::open(&db)?;
let store = Store::open(db.as_ref(), embedder.dimensions())?;
let generation_fingerprint = generation_fingerprint(embedder.fingerprint());
Ok(Self {
lexical,
embedder,
store,
generation_fingerprint,
})
}
/// Execute real query inference and load the active ANN generation before
/// foreground use. Call on a background worker after assets are verified.
pub fn warmup(&mut self) -> Result<()> {
self.lexical.prewarm()?;
let vector = self.embedder.embed_query("warm up local file search")?;
let _ = self.store.search(
&vector,
&self.generation_fingerprint,
1,
&SearchOptions::default(),
)?;
Ok(())
}
/// Build a replacement generation in bounded CPU batches. Inference or
/// storage errors leave the previous generation active. A crashed build is
/// reclaimed on the next explicit rebuild under an OS advisory lock.
pub fn index_embeddings(&mut self) -> Result<EmbeddingReport> {
let _lock = self.store.build_lock()?;
let generation = self.store.begin(&self.generation_fingerprint)?;
let built = self.build(generation);
match built {
Ok(report) => {
self.store.activate(generation)?;
Ok(report)
}
Err(error) => {
self.store.abandon(generation)?;
Err(error)
}
}
}
fn build(&mut self, generation: i64) -> Result<EmbeddingReport> {
let mut report = EmbeddingReport {
passages: 0,
chunks: 0,
generation,
model_fingerprint: self.generation_fingerprint.clone(),
};
let mut after_id = 0;
loop {
let passages = self.lexical.passage_batch(after_id, 64)?;
if passages.is_empty() {
break;
}
for passage in passages {
after_id = passage.id;
if passage.text.trim().is_empty() {
continue;
}
let title = bounded_title(&passage.filename);
let windows = passage_windows(&mut *self.embedder, &passage.text, title)?;
ensure!(
report.chunks + windows.len() <= MAX_SCAN_CHUNKS,
"Corpus exceeds ANN generation resource budget of {MAX_SCAN_CHUNKS} chunks"
);
for (batch_index, batch) in windows.chunks(MAX_BATCH).enumerate() {
let texts: Vec<_> = batch.iter().map(|window| window.text.as_str()).collect();
let vectors = self.embedder.embed_documents(&texts, title)?;
self.store.insert(
generation,
&passage,
batch_index * MAX_BATCH,
batch,
&vectors,
)?;
}
report.chunks += windows.len();
report.passages += 1;
}
}
Ok(report)
}
/// Filter each retrieval lane before ranking, preserve filename priority,
/// then fuse keyword and semantic ranks with reciprocal-rank fusion (k=60).
pub fn search(
&mut self,
query: &str,
limit: usize,
options: &SearchOptions,
) -> Result<Vec<SearchResult>> {
let candidates = limit.saturating_mul(4).clamp(20, 1000);
// Core validates filters and query budgets even for empty requests.
let mut lexical = self
.lexical
.search_with_options(query, candidates, options)?;
if query.trim().is_empty() || limit == 0 {
return Ok(Vec::new());
}
promote_identifiers(query.trim(), &mut lexical);
let vector = self.embedder.embed_query(query.trim())?;
let semantic =
self.store
.search(&vector, &self.generation_fingerprint, candidates, options)?;
Ok(fuse(lexical, semantic, limit.min(1000)))
}
/// Semantic lane for reproducible relevance evaluation and diagnostics.
pub fn search_semantic(
&mut self,
query: &str,
limit: usize,
options: &SearchOptions,
) -> Result<Vec<SearchResult>> {
let _ = self.lexical.search_with_options(query, 0, options)?;
if query.trim().is_empty() || limit == 0 {
return Ok(Vec::new());
}
let vector = self.embedder.embed_query(query.trim())?;
self.store.search(
&vector,
&self.generation_fingerprint,
limit.min(1000),
options,
)
}
}
fn bounded_title(title: &str) -> &str {
let mut end = title.len().min(512);
while !title.is_char_boundary(end) {
end -= 1;
}
&title[..end]
}
fn byte_slices(text: &str) -> Result<Vec<(usize, usize)>> {
ensure!(
text.len() <= 2 * 1024 * 1024,
"Passage exceeds 2 MiB service byte budget"
);
let mut slices = Vec::new();
let mut start = 0;
while start < text.len() {
let mut end = (start + BYTE_SLICE).min(text.len());
while !text.is_char_boundary(end) {
end -= 1;
}
ensure!(end > start, "UTF-8 slice made no progress");
slices.push((start, end));
if end == text.len() {
break;
}
start = end.saturating_sub(BYTE_OVERLAP);
while !text.is_char_boundary(start) {
start -= 1;
}
}
Ok(slices)
}
fn passage_windows(
provider: &mut dyn EmbeddingProvider,
text: &str,
title: &str,
) -> Result<Vec<TextWindow>> {
let mut windows = Vec::new();
for (start, end) in byte_slices(text)? {
let fragment = &text[start..end];
if fragment.trim().is_empty() {
continue;
}
let mut slice = provider.split_windows(fragment, title)?;
for window in &mut slice {
window.start_byte += start;
window.end_byte += start;
}
windows.extend(slice);
ensure!(
windows.len() <= MAX_PASSAGE_WINDOWS,
"Passage exceeds {MAX_PASSAGE_WINDOWS} semantic window budget"
);
}
Ok(windows)
}
fn promote_identifiers(query: &str, results: &mut [SearchResult]) {
if query.chars().any(char::is_whitespace)
|| !query.chars().any(|c| c.is_ascii_digit() || c == '_')
{
return;
}
for result in results {
if result.match_kind == "content" && literal_identifier_match(&result.snippet, query) {
result.match_kind = "exact_identifier".into();
}
}
}
fn literal_identifier_match(text: &str, query: &str) -> bool {
text.match_indices(query).any(|(start, _)| {
let end = start + query.len();
let is_identifier = |c: char| c.is_alphanumeric() || c == '_' || c == '-';
!text[..start].chars().next_back().is_some_and(is_identifier)
&& !text[end..].chars().next().is_some_and(is_identifier)
})
}
fn fuse(
lexical: Vec<SearchResult>,
semantic: Vec<SearchResult>,
limit: usize,
) -> Vec<SearchResult> {
let mut results = HashMap::<String, SearchResult>::new();
for (rank, mut result) in lexical.into_iter().enumerate() {
let weight = if result.match_kind == "filename" {
1.15
} else {
1.0
};
result.score = weight / (61.0 + rank as f64);
results.insert(result.path.clone(), result);
}
for (rank, mut result) in semantic.into_iter().enumerate() {
let contribution = 1.0 / (61.0 + rank as f64);
if let Some(existing) = results.get_mut(&result.path) {
existing.score += contribution;
if existing.match_kind == "content" {
existing.match_kind = "hybrid".into();
}
} else {
result.score = contribution;
results.insert(result.path.clone(), result);
}
}
let tier = |result: &SearchResult| match result.match_kind.as_str() {
"exact_filename" => 0,
"exact_identifier" => 1,
_ => 2,
};
let mut results: Vec<_> = results.into_values().collect();
results.sort_by(|a, b| {
tier(a)
.cmp(&tier(b))
.then_with(|| b.score.total_cmp(&a.score))
.then_with(|| a.path.cmp(&b.path))
});
results.truncate(limit);
results
}
#[cfg(test)]
mod tests {
use super::*;
fn result(path: &str, kind: &str) -> SearchResult {
SearchResult {
path: path.into(),
filename: path.into(),
snippet: "evidence".into(),
score: 1.0,
match_kind: kind.into(),
source: None,
extraction_error: None,
}
}
#[test]
fn exact_filename_wins_even_when_another_result_matches_both_lanes() {
let results = fuse(
vec![
result("invoice.pdf", "exact_filename"),
result("notes.txt", "content"),
],
vec![
result("notes.txt", "semantic"),
result("another.txt", "semantic"),
],
10,
);
assert_eq!(results[0].path, "invoice.pdf");
assert_eq!(results[1].match_kind, "hybrid");
assert_eq!(results.len(), 3);
}
#[test]
fn identifiers_use_literal_boundaries_and_survive_semantic_competition() {
let mut lexical = vec![result("support.txt", "content")];
lexical[0].snippet = "Error E_SEARCH_071 means upgrade the app".into();
promote_identifiers("E_SEARCH_071", &mut lexical);
let results = fuse(lexical, vec![result("wrong.txt", "semantic")], 10);
assert_eq!(results[0].match_kind, "exact_identifier");
assert!(!literal_identifier_match(
"E_SEARCH_071_EXTRA",
"E_SEARCH_071"
));
}
#[test]
fn keyword_semantic_consensus_beats_weak_partial_filename() {
let results = fuse(
vec![
result("meeting-cancellation.txt", "filename"),
result("supplier-agreement.txt", "content"),
],
vec![result("supplier-agreement.txt", "semantic")],
10,
);
assert_eq!(results[0].path, "supplier-agreement.txt");
assert_eq!(results[0].match_kind, "hybrid");
assert_eq!(results[1].match_kind, "filename");
}
#[test]
fn byte_slices_preserve_utf8_overlap_offsets_and_complete_tail() {
let text = format!("{}TAIL", "Ä🐈中文 ".repeat(10000));
let slices = byte_slices(&text).unwrap();
assert!(slices.len() > 1);
assert_eq!(slices[0].0, 0);
assert_eq!(slices.last().unwrap().1, text.len());
for (start, end) in &slices {
assert!(text.is_char_boundary(*start) && text.is_char_boundary(*end));
assert!(end - start <= BYTE_SLICE);
}
for pair in slices.windows(2) {
assert!(pair[1].0 < pair[0].1 && pair[1].1 > pair[0].1);
}
assert!(text[slices.last().unwrap().0..].ends_with("TAIL"));
assert!(byte_slices(&"x".repeat(2 * 1024 * 1024 + 1)).is_err());
let title = "中文".repeat(120);
assert!(bounded_title(&title).len() <= 512 && title.starts_with(bounded_title(&title)));
}
#[test]
#[ignore = "Requires explicitly provisioned Google model and CPU worker"]
fn google_cpu_preserves_and_retrieves_content_after_64k_byte_boundary() -> Result<()> {
let model =
std::env::var_os("SEARCHI_GEMMA_MODEL_DIR").expect("Set SEARCHI_GEMMA_MODEL_DIR");
let worker = std::env::var_os("SEARCHI_GEMMA_WORKER").expect("Set SEARCHI_GEMMA_WORKER");
let mut provider =
GemmaEmbedder::load_with_device(model, worker, 1, searchi_semantic::Device::Cpu)?;
let text=format!("{}\nÜbernachtungskosten auf Dienstreisen werden gegen Vorlage der Hotelrechnung erstattet.","Routine system checks were completed successfully. ".repeat(1500));
assert!(text.len() > BYTE_SLICE);
let windows = passage_windows(&mut provider, &text, "routine-log.txt")?;
let last = windows.last().unwrap();
assert_eq!(last.end_byte, text.len());
assert!(last.start_byte >= BYTE_SLICE - BYTE_OVERLAP);
assert!(text.find("Übernachtungskosten").unwrap() > BYTE_SLICE);
assert!(last.text.contains("Hotelrechnung"));
assert_eq!(
text.get(last.start_byte..last.end_byte),
Some(last.text.as_str())
);
let vectors = provider.embed_documents(
&[windows[0].text.as_str(), last.text.as_str()],
"routine-log.txt",
)?;
let query = provider.embed_query(
"Where does it say that hotel stays during work travel can be reimbursed?",
)?;
let cosine = |vector: &[f32]| vector.iter().zip(&query).map(|(a, b)| a * b).sum::<f32>();
assert!(
cosine(&vectors[1]) > cosine(&vectors[0]),
"Real model did not retrieve the tail window"
);
Ok(())
}
}
+855
View File
@@ -0,0 +1,855 @@
use std::{
collections::{HashMap, HashSet},
fs::{File, OpenOptions},
path::{Path, PathBuf},
sync::Arc,
};
use anyhow::{ensure, Context, Result};
use fs2::FileExt;
use rusqlite::{params, Connection, OptionalExtension};
use searchi_core::{IndexedPassage, SearchOptions, SearchResult, SourceLocation};
use searchi_semantic::TextWindow;
use sha2::{Digest, Sha256};
use usearch::{Index, IndexOptions, MetricKind, ScalarKind};
pub(crate) const MAX_SCAN_CHUNKS: usize = 100_000;
pub(crate) const MIN_COSINE: f32 = 0.25;
#[derive(Clone, PartialEq, Eq)]
struct FilterKey {
folder: Option<String>,
extension: Option<String>,
min_size: Option<i64>,
max_size: Option<i64>,
modified_after: Option<i64>,
modified_before: Option<i64>,
}
struct EligibilityCache {
generation: i64,
revision: i64,
filter: FilterKey,
keys: Arc<HashSet<u64>>,
by_path: HashMap<String, Vec<u64>>,
}
pub(crate) struct Store {
connection: Connection,
lock_path: PathBuf,
dimensions: usize,
ann_cache: Option<(i64, Index)>,
eligibility_cache: Option<EligibilityCache>,
}
pub(crate) struct BuildLock(File);
impl Drop for BuildLock {
fn drop(&mut self) {
let _ = FileExt::unlock(&self.0);
}
}
pub(crate) fn content_hash(text: &str) -> String {
#[cfg(test)]
HASH_CALLS.with(|count| count.set(count.get() + 1));
format!("{:x}", Sha256::digest(text.as_bytes()))
}
#[cfg(test)]
thread_local! { static HASH_CALLS:std::cell::Cell<usize>=const {std::cell::Cell::new(0)}; }
impl Store {
pub(crate) fn open(db: &Path, dimensions: usize) -> Result<Self> {
ensure!(
(1..=4096).contains(&dimensions),
"Embedding dimensions must be between 1 and 4096"
);
ensure!(
db != Path::new(":memory:"),
"Hybrid search requires a persistent database path"
);
let mut connection = Connection::open(db)?;
connection.busy_timeout(std::time::Duration::from_secs(5))?;
connection.execute_batch("PRAGMA foreign_keys=ON;")?;
let initialization =
connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
initialization.execute_batch(
"CREATE TABLE IF NOT EXISTS semantic_meta (
singleton INTEGER PRIMARY KEY CHECK(singleton=1), schema_version INTEGER NOT NULL,
active_generation INTEGER, corpus_revision INTEGER NOT NULL DEFAULT 0, dirty_floor INTEGER NOT NULL DEFAULT 0);
INSERT OR IGNORE INTO semantic_meta(singleton,schema_version) VALUES(1,5);",
)?;
let version: i64 = initialization.query_row(
"SELECT schema_version FROM semantic_meta WHERE singleton=1",
[],
|row| row.get(0),
)?;
ensure!(
(1..=5).contains(&version),
"Unsupported semantic schema version {version}"
);
initialization.execute_batch("
CREATE TABLE IF NOT EXISTS semantic_generations(id INTEGER PRIMARY KEY, fingerprint TEXT NOT NULL, dimensions INTEGER NOT NULL, corpus_revision INTEGER NOT NULL DEFAULT 0, ann_blob BLOB, ann_sha256 TEXT);
CREATE TABLE IF NOT EXISTS semantic_chunks(
generation INTEGER NOT NULL REFERENCES semantic_generations(id) ON DELETE CASCADE,
passage_id INTEGER NOT NULL, ordinal INTEGER NOT NULL,
path TEXT NOT NULL, kind TEXT NOT NULL, number INTEGER NOT NULL,
content_hash TEXT NOT NULL, start_byte INTEGER NOT NULL, end_byte INTEGER NOT NULL,
snippet TEXT NOT NULL, vector BLOB NOT NULL,
PRIMARY KEY(generation,passage_id,ordinal));")?;
if version < 5 {
for (table,column,alter) in [
("semantic_meta","corpus_revision","ALTER TABLE semantic_meta ADD COLUMN corpus_revision INTEGER NOT NULL DEFAULT 0"),
("semantic_meta","dirty_floor","ALTER TABLE semantic_meta ADD COLUMN dirty_floor INTEGER NOT NULL DEFAULT 0"),
("semantic_generations","dimensions","ALTER TABLE semantic_generations ADD COLUMN dimensions INTEGER NOT NULL DEFAULT 384"),
("semantic_generations","corpus_revision","ALTER TABLE semantic_generations ADD COLUMN corpus_revision INTEGER NOT NULL DEFAULT 0"),
("semantic_generations","ann_blob","ALTER TABLE semantic_generations ADD COLUMN ann_blob BLOB"),
("semantic_generations","ann_sha256","ALTER TABLE semantic_generations ADD COLUMN ann_sha256 TEXT"),
] {
let exists:bool=initialization.query_row("SELECT EXISTS(SELECT 1 FROM pragma_table_info(?1) WHERE name=?2)",params![table,column],|row| row.get(0))?;
if !exists { initialization.execute_batch(alter)?; }
}
initialization.execute_batch("DROP TRIGGER IF EXISTS semantic_files_insert;
DROP TRIGGER IF EXISTS semantic_files_update; DROP TRIGGER IF EXISTS semantic_files_delete;")?;
}
initialization.execute_batch(
"CREATE TABLE IF NOT EXISTS semantic_dirty_paths(path TEXT PRIMARY KEY, revision INTEGER NOT NULL);
CREATE INDEX IF NOT EXISTS semantic_dirty_revision ON semantic_dirty_paths(revision);
CREATE INDEX IF NOT EXISTS semantic_chunk_path ON semantic_chunks(generation,path);
CREATE TRIGGER IF NOT EXISTS semantic_files_insert AFTER INSERT ON files BEGIN
UPDATE semantic_meta SET corpus_revision=corpus_revision+1 WHERE singleton=1;
INSERT INTO semantic_dirty_paths(path,revision) VALUES(new.path,(SELECT corpus_revision FROM semantic_meta WHERE singleton=1))
ON CONFLICT(path) DO UPDATE SET revision=excluded.revision; END;
CREATE TRIGGER IF NOT EXISTS semantic_files_update AFTER UPDATE ON files BEGIN
UPDATE semantic_meta SET corpus_revision=corpus_revision+1 WHERE singleton=1;
INSERT INTO semantic_dirty_paths(path,revision) VALUES(old.path,(SELECT corpus_revision FROM semantic_meta WHERE singleton=1))
ON CONFLICT(path) DO UPDATE SET revision=excluded.revision;
INSERT INTO semantic_dirty_paths(path,revision) VALUES(new.path,(SELECT corpus_revision FROM semantic_meta WHERE singleton=1))
ON CONFLICT(path) DO UPDATE SET revision=excluded.revision; END;
CREATE TRIGGER IF NOT EXISTS semantic_files_delete AFTER DELETE ON files BEGIN
UPDATE semantic_meta SET corpus_revision=corpus_revision+1 WHERE singleton=1;
INSERT INTO semantic_dirty_paths(path,revision) VALUES(old.path,(SELECT corpus_revision FROM semantic_meta WHERE singleton=1))
ON CONFLICT(path) DO UPDATE SET revision=excluded.revision; END;
UPDATE semantic_meta SET schema_version=5 WHERE singleton=1;",
)?;
initialization.commit()?;
let canonical = db.canonicalize()?;
let lock_path = PathBuf::from(format!("{}.semantic.lock", canonical.to_string_lossy()));
Ok(Self {
connection,
lock_path,
dimensions,
ann_cache: None,
eligibility_cache: None,
})
}
pub(crate) fn build_lock(&self) -> Result<BuildLock> {
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&self.lock_path)?;
file.try_lock_exclusive()
.context("Another semantic rebuild is running")?;
Ok(BuildLock(file))
}
pub(crate) fn begin(&mut self, fingerprint: &str) -> Result<i64> {
let transaction = self.connection.transaction()?;
// Only called while holding the cross-process lock: abandoned staging
// generations may be removed safely after a crash.
transaction.execute("DELETE FROM semantic_generations WHERE id != COALESCE((SELECT active_generation FROM semantic_meta WHERE singleton=1),-1)", [])?;
transaction.execute(
"INSERT INTO semantic_generations(fingerprint,dimensions,corpus_revision) SELECT ?1,?2,corpus_revision FROM semantic_meta WHERE singleton=1",
params![fingerprint, self.dimensions as i64],
)?;
let id = transaction.last_insert_rowid();
transaction.commit()?;
Ok(id)
}
pub(crate) fn insert(
&mut self,
generation: i64,
passage: &IndexedPassage,
first_ordinal: usize,
windows: &[TextWindow],
vectors: &[Vec<f32>],
) -> Result<()> {
ensure!(
windows.len() == vectors.len(),
"Window and embedding counts differ"
);
let hash = content_hash(&passage.text);
let transaction = self.connection.transaction()?;
for (i, (window, vector)) in windows.iter().zip(vectors).enumerate() {
ensure!(
vector.len() == self.dimensions && vector.iter().all(|v| v.is_finite()),
"Invalid persisted vector"
);
let norm = vector.iter().map(|value| value * value).sum::<f32>().sqrt();
ensure!(
norm.is_finite() && (norm - 1.0).abs() < 1e-3,
"Persisted embeddings must be L2 normalized"
);
ensure!(
passage.text.get(window.start_byte..window.end_byte) == Some(window.text.as_str()),
"Window does not match passage source range"
);
let bytes: Vec<u8> = vector
.iter()
.flat_map(|value| value.to_le_bytes())
.collect();
transaction.execute("INSERT INTO semantic_chunks(generation,passage_id,ordinal,path,kind,number,content_hash,start_byte,end_byte,snippet,vector)
VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)", params![generation, passage.id,
(first_ordinal+i) as i64, passage.path, passage.source.kind, passage.source.number,
hash, window.start_byte as i64, window.end_byte as i64, window.text, bytes])?;
}
transaction.commit()?;
Ok(())
}
pub(crate) fn activate(&mut self, generation: i64) -> Result<()> {
// A failed graph build or serialization leaves the old generation active.
let ann = self.build_ann(generation)?;
let length = ann.serialized_length();
ensure!(
length <= 512 * 1024 * 1024,
"Serialized ANN exceeds 512 MiB generation budget"
);
let mut serialized = vec![0_u8; length];
ann.save_to_buffer(&mut serialized)?;
let checksum = format!("{:x}", Sha256::digest(&serialized));
let transaction = self.connection.transaction()?;
ensure!(
transaction.query_row(
"SELECT EXISTS(SELECT 1 FROM semantic_generations WHERE id=?1)",
[generation],
|row| row.get::<_, bool>(0)
)?,
"Missing staging generation"
);
transaction.execute(
"UPDATE semantic_generations SET ann_blob=?1,ann_sha256=?2 WHERE id=?3",
params![serialized, checksum, generation],
)?;
transaction.execute(
"UPDATE semantic_meta SET active_generation=?1 WHERE singleton=1",
[generation],
)?;
transaction.execute(
"DELETE FROM semantic_generations WHERE id!=?1",
[generation],
)?;
transaction.execute_batch("UPDATE semantic_meta SET dirty_floor=max(dirty_floor,corpus_revision-100000) WHERE singleton=1;
DELETE FROM semantic_dirty_paths WHERE revision<(SELECT dirty_floor FROM semantic_meta WHERE singleton=1);")?;
transaction.commit()?;
self.ann_cache = Some((generation, ann));
self.eligibility_cache = None;
Ok(())
}
fn new_ann(&self) -> Result<Index> {
Ok(Index::new(&IndexOptions {
dimensions: self.dimensions,
metric: MetricKind::Cos,
quantization: ScalarKind::F32,
connectivity: 16,
expansion_add: 128,
expansion_search: 64,
multi: false,
})?)
}
fn build_ann(&self, generation: i64) -> Result<Index> {
let count: usize = self.connection.query_row(
"SELECT count(*) FROM semantic_chunks WHERE generation=?1",
[generation],
|row| row.get(0),
)?;
ensure!(
count <= MAX_SCAN_CHUNKS,
"ANN generation exceeds {MAX_SCAN_CHUNKS} chunks"
);
let ann = self.new_ann()?;
ann.reserve_capacity_and_threads(count.max(1), 1)?;
let mut statement = self.connection.prepare(
"SELECT rowid,vector FROM semantic_chunks WHERE generation=?1 ORDER BY rowid",
)?;
let mut rows = statement.query([generation])?;
while let Some(row) = rows.next()? {
let key: i64 = row.get(0)?;
let bytes: Vec<u8> = row.get(1)?;
ensure!(
bytes.len() == self.dimensions * 4,
"Invalid staging embedding length"
);
let vector: Vec<f32> = bytes
.as_chunks::<4>()
.0
.iter()
.map(|bytes| f32::from_le_bytes(*bytes))
.collect();
ensure!(
vector.iter().all(|v| v.is_finite()),
"Invalid staging embedding values"
);
ann.add(u64::try_from(key)?, &vector)?;
}
Ok(ann)
}
pub(crate) fn abandon(&mut self, generation: i64) -> Result<()> {
self.connection.execute("DELETE FROM semantic_generations WHERE id=?1 AND id!=COALESCE((SELECT active_generation FROM semantic_meta WHERE singleton=1),-1)", [generation])?;
Ok(())
}
pub(crate) fn search(
&mut self,
query: &[f32],
fingerprint: &str,
limit: usize,
options: &SearchOptions,
) -> Result<Vec<SearchResult>> {
ensure!(
query.len() == self.dimensions && query.iter().all(|v| v.is_finite()),
"Invalid query vector"
);
if limit == 0 {
return Ok(Vec::new());
}
// A consistent read snapshot covers eligibility and materialization.
let snapshot = self.connection.unchecked_transaction()?;
let active:Option<i64>=snapshot.query_row("SELECT g.id FROM semantic_generations g JOIN semantic_meta m ON m.active_generation=g.id WHERE g.fingerprint=?1 AND g.dimensions=?2",
params![fingerprint,self.dimensions as i64],|row|row.get(0)).optional()?;
let Some(active) = active else {
return Ok(Vec::new());
};
if !self
.ann_cache
.as_ref()
.is_some_and(|(generation, _)| *generation == active)
{
let length: Option<usize> = snapshot.query_row(
"SELECT length(ann_blob) FROM semantic_generations WHERE id=?1",
[active],
|row| row.get(0),
)?;
ensure!(
length.is_some_and(|bytes| bytes <= 512 * 1024 * 1024),
"Missing or oversized ANN generation; rebuild embeddings explicitly"
);
let (serialized, checksum): (Vec<u8>, String) = snapshot.query_row(
"SELECT ann_blob,ann_sha256 FROM semantic_generations WHERE id=?1",
[active],
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
ensure!(
format!("{:x}", Sha256::digest(&serialized)) == checksum,
"ANN generation SHA256 mismatch; rebuild embeddings explicitly"
);
let ann = self.new_ann()?;
ann.load_from_buffer(&serialized)?;
ensure!(
ann.dimensions() == self.dimensions,
"ANN dimensions differ from embedding provider"
);
ann.reserve_capacity_and_threads(ann.size().max(1), 1)?;
self.ann_cache = Some((active, ann));
}
let folder = options
.folder
.as_ref()
.map(std::fs::canonicalize)
.transpose()?
.map(|p| {
format!(
"{}{}",
p.to_string_lossy().trim_end_matches(['/', '\\']),
std::path::MAIN_SEPARATOR
)
});
let extension = options
.extension
.as_ref()
.map(|ext| ext.trim_start_matches('.').to_ascii_lowercase());
let min_size = options.min_size.map(i64::try_from).transpose()?;
let max_size = options.max_size.map(i64::try_from).transpose()?;
let filter = FilterKey {
folder,
extension,
min_size,
max_size,
modified_after: options.modified_after_unix_ms,
modified_before: options.modified_before_unix_ms,
};
let (revision, floor): (i64, i64) = snapshot.query_row(
"SELECT corpus_revision,dirty_floor FROM semantic_meta WHERE singleton=1",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
let existing = self.eligibility_cache.take();
let mut cache = match existing {
Some(mut cache)
if cache.generation == active
&& cache.filter == filter
&& cache.revision >= floor =>
{
if cache.revision != revision {
let mut statement=snapshot.prepare("SELECT path FROM semantic_dirty_paths WHERE revision>?1 AND revision<=?2 ORDER BY path LIMIT 1001")?;
let paths = statement
.query_map(params![cache.revision, revision], |row| {
row.get::<_, String>(0)
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
if paths.len() > 1000 {
cache = collect_eligibility(&snapshot, active, revision, &filter, None)?;
} else {
for path in paths {
if let Some(old) = cache.by_path.remove(&path) {
for key in old {
Arc::make_mut(&mut cache.keys).remove(&key);
}
}
let changed = collect_eligibility(
&snapshot,
active,
revision,
&filter,
Some(&path),
)?;
Arc::make_mut(&mut cache.keys).extend(changed.keys.iter().copied());
cache.by_path.extend(changed.by_path);
}
cache.revision = revision;
}
}
cache
}
_ => collect_eligibility(&snapshot, active, revision, &filter, None)?,
};
ensure!(
cache.keys.len() <= MAX_SCAN_CHUNKS,
"Eligibility cache exceeds chunk resource budget"
);
cache.revision = revision;
let eligible = Arc::clone(&cache.keys);
self.eligibility_cache = Some(cache);
if eligible.is_empty() {
return Ok(Vec::new());
}
let ann = &self.ann_cache.as_ref().expect("loaded ANN cache").1;
let mut candidate_count = limit.saturating_mul(4).min(eligible.len()).max(1);
let mut results;
loop {
let matches =
ann.filtered_search(query, candidate_count, |key| eligible.contains(&key))?;
let mut ranked: Vec<_> = matches.keys.into_iter().zip(matches.distances).collect();
ranked.sort_by(|a, b| a.1.total_cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
let mut best = HashMap::<String, SearchResult>::new();
let mut materialize=snapshot.prepare("SELECT c.path,f.filename,c.snippet,c.kind,c.number FROM semantic_chunks c JOIN files f ON f.path=c.path WHERE c.rowid=?1 AND c.generation=?2")?;
for (key, distance) in ranked {
let cosine = 1.0 - distance;
ensure!(cosine.is_finite(), "ANN produced nonfinite distance");
if cosine < MIN_COSINE {
continue;
}
let result =
materialize.query_row(params![i64::try_from(key)?, active], |row| {
Ok(SearchResult {
path: row.get(0)?,
filename: row.get(1)?,
snippet: row.get(2)?,
score: f64::from(cosine),
match_kind: "semantic".into(),
source: Some(SourceLocation {
kind: row.get(3)?,
number: row.get(4)?,
}),
extraction_error: None,
})
})?;
if best.contains_key(&result.path) {
continue;
}
best.insert(result.path.clone(), result);
}
results = best.into_values().collect::<Vec<_>>();
if results.len() >= limit || candidate_count == eligible.len() {
break;
}
candidate_count = candidate_count.saturating_mul(2).min(eligible.len());
}
results.sort_by(|a, b| {
b.score
.total_cmp(&a.score)
.then_with(|| a.path.cmp(&b.path))
});
results.truncate(limit);
snapshot.commit()?;
Ok(results)
}
#[cfg(test)]
fn exact_search(
&self,
query: &[f32],
fingerprint: &str,
limit: usize,
options: &SearchOptions,
) -> Result<Vec<SearchResult>> {
ensure!(
query.len() == self.dimensions && query.iter().all(|v| v.is_finite()),
"Invalid query vector"
);
let active: Option<i64> = self.connection.query_row("SELECT g.id FROM semantic_generations g JOIN semantic_meta m ON m.active_generation=g.id WHERE g.fingerprint=?1 AND g.dimensions=?2", params![fingerprint,self.dimensions as i64], |row| row.get(0)).optional()?;
let Some(active) = active else {
return Ok(Vec::new());
};
let folder = options
.folder
.as_ref()
.map(std::fs::canonicalize)
.transpose()?
.map(|p| {
format!(
"{}{}",
p.to_string_lossy().trim_end_matches(['/', '\\']),
std::path::MAIN_SEPARATOR
)
});
let extension = options
.extension
.as_ref()
.map(|ext| ext.trim_start_matches('.').to_ascii_lowercase());
let min_size = options.min_size.map(i64::try_from).transpose()?;
let max_size = options.max_size.map(i64::try_from).transpose()?;
let mut statement = self.connection.prepare("SELECT c.passage_id,c.path,f.filename,c.snippet,c.vector,c.content_hash,p.text,c.kind,c.number
FROM semantic_chunks c JOIN passages p ON p.rowid=c.passage_id AND p.file_path=c.path AND p.kind=c.kind AND p.number=c.number
JOIN files f ON f.path=c.path WHERE c.generation=?1
AND (?2 IS NULL OR substr(f.path,1,length(?2))=?2)
AND (?3 IS NULL OR f.extension=?3)
AND (?4 IS NULL OR f.size>=?4) AND (?5 IS NULL OR f.size<=?5)
AND (?6 IS NULL OR f.mtime/1000000>=?6) AND (?7 IS NULL OR f.mtime/1000000<=?7)
ORDER BY c.passage_id,c.ordinal LIMIT ?8")?;
// Fetch one extra to detect the baseline cap instead of silently hiding
// candidates. Metadata filtering precedes this cap and all top-k limits.
let mut rows = statement.query(params![
active,
folder,
extension,
min_size,
max_size,
options.modified_after_unix_ms,
options.modified_before_unix_ms,
MAX_SCAN_CHUNKS as i64 + 1
])?;
let mut hashes = HashMap::<i64, String>::new();
let mut best = HashMap::<String, SearchResult>::new();
let mut scanned = 0;
while let Some(row) = rows.next()? {
scanned += 1;
ensure!(
scanned <= MAX_SCAN_CHUNKS,
"Exact-vector baseline exceeds {MAX_SCAN_CHUNKS} chunks; ANN integration required"
);
let passage_id: i64 = row.get(0)?;
let current_hash = if let Some(hash) = hashes.get(&passage_id) {
hash.clone()
} else {
let hash = content_hash(&row.get::<_, String>(6)?);
hashes.insert(passage_id, hash.clone());
hash
};
if current_hash != row.get::<_, String>(5)? {
continue;
}
let bytes: Vec<u8> = row.get(4)?;
ensure!(
bytes.len() == self.dimensions * 4,
"Corrupt stored embedding length"
);
let mut cosine = 0.0;
for (dimension, value) in bytes.as_chunks::<4>().0.iter().enumerate() {
let value = f32::from_le_bytes(*value);
ensure!(value.is_finite(), "Corrupt stored embedding value");
cosine += value * query[dimension];
}
if cosine < MIN_COSINE {
continue;
}
let path: String = row.get(1)?;
if best
.get(&path)
.is_some_and(|result| result.score >= f64::from(cosine))
{
continue;
}
best.insert(
path.clone(),
SearchResult {
path,
filename: row.get(2)?,
snippet: row.get(3)?,
score: f64::from(cosine),
match_kind: "semantic".into(),
source: Some(SourceLocation {
kind: row.get(7)?,
number: row.get(8)?,
}),
extraction_error: None,
},
);
}
let mut results: Vec<_> = best.into_values().collect();
results.sort_by(|a, b| {
b.score
.total_cmp(&a.score)
.then_with(|| a.path.cmp(&b.path))
});
results.truncate(limit);
Ok(results)
}
}
fn collect_eligibility(
connection: &Connection,
generation: i64,
revision: i64,
filter: &FilterKey,
path: Option<&str>,
) -> Result<EligibilityCache> {
let mut statement=connection.prepare("SELECT c.rowid,c.passage_id,c.content_hash,p.text,c.path FROM semantic_chunks c
JOIN passages p ON p.rowid=c.passage_id AND p.file_path=c.path AND p.kind=c.kind AND p.number=c.number
JOIN files f ON f.path=c.path WHERE c.generation=?1
AND (?2 IS NULL OR substr(f.path,1,length(?2))=?2) AND (?3 IS NULL OR f.extension=?3)
AND (?4 IS NULL OR f.size>=?4) AND (?5 IS NULL OR f.size<=?5)
AND (?6 IS NULL OR f.mtime/1000000>=?6) AND (?7 IS NULL OR f.mtime/1000000<=?7)
AND (?9 IS NULL OR c.path=?9) ORDER BY c.passage_id,c.ordinal LIMIT ?8")?;
let mut rows = statement.query(params![
generation,
filter.folder,
filter.extension,
filter.min_size,
filter.max_size,
filter.modified_after,
filter.modified_before,
MAX_SCAN_CHUNKS as i64 + 1,
path
])?;
let mut hashes = HashMap::<i64, String>::new();
let mut keys = HashSet::new();
let mut by_path = HashMap::<String, Vec<u64>>::new();
let mut count = 0;
while let Some(row) = rows.next()? {
count += 1;
ensure!(
count <= MAX_SCAN_CHUNKS,
"Corpus exceeds {MAX_SCAN_CHUNKS} chunk resource budget"
);
let passage_id: i64 = row.get(1)?;
let hash = if let Some(hash) = hashes.get(&passage_id) {
hash.clone()
} else {
let hash = content_hash(&row.get::<_, String>(3)?);
hashes.insert(passage_id, hash.clone());
hash
};
if hash == row.get::<_, String>(2)? {
let key = u64::try_from(row.get::<_, i64>(0)?)?;
keys.insert(key);
by_path.entry(row.get(4)?).or_default().push(key);
}
}
Ok(EligibilityCache {
generation,
revision,
filter: filter.clone(),
keys: Arc::new(keys),
by_path,
})
}
#[cfg(test)]
mod tests {
use super::*;
use searchi_core::SearchIndex;
const DIMENSIONS: usize = 768;
#[test]
fn generation_persists_failed_staging_preserves_active_and_changes_hide_stale_vectors(
) -> Result<()> {
let directory = tempfile::tempdir()?;
let root = directory.path().join("docs");
std::fs::create_dir(&root)?;
let db = directory.path().join("index.db");
std::fs::write(root.join("notes.txt"), "original relevant content")?;
let mut index = SearchIndex::open(&db)?;
index.index_folder(&root)?;
let passage = index.passage_batch(0, 1)?.remove(0);
let mut vector = vec![0.0; DIMENSIONS];
vector[0] = 1.0;
let windows = [TextWindow {
text: passage.text.clone(),
start_byte: 0,
end_byte: passage.text.len(),
}];
let mut store = Store::open(&db, DIMENSIONS)?;
let generation = store.begin("test-storage-vectors")?;
store.insert(generation, &passage, 0, &windows, &[vector.clone()])?;
store.activate(generation)?;
assert_eq!(
store.exact_search(
&vector,
"test-storage-vectors",
10,
&SearchOptions::default()
)?[0]
.path,
store.search(
&vector,
"test-storage-vectors",
10,
&SearchOptions::default()
)?[0]
.path
);
drop(store);
let mut store = Store::open(&db, DIMENSIONS)?;
assert_eq!(
store
.search(
&vector,
"test-storage-vectors",
10,
&SearchOptions::default()
)?
.len(),
1
);
assert!(store
.search(&vector, "different-model", 10, &SearchOptions::default())?
.is_empty());
let staging = store.begin("new-model")?;
assert_eq!(
store
.search(
&vector,
"test-storage-vectors",
10,
&SearchOptions::default()
)?
.len(),
1
);
store.abandon(staging)?;
std::fs::write(
root.join("notes.txt"),
"replacement unrelated content longer than before",
)?;
index.index_folder(&root)?;
assert!(store
.search(
&vector,
"test-storage-vectors",
10,
&SearchOptions::default()
)?
.is_empty());
std::fs::remove_file(root.join("notes.txt"))?;
index.index_folder(&root)?;
assert!(store
.search(
&vector,
"test-storage-vectors",
10,
&SearchOptions::default()
)?
.is_empty());
Ok(())
}
#[test]
fn ann_filters_before_top_k_checks_checksum_and_status_tracks_corpus_changes() -> Result<()> {
let directory = tempfile::tempdir()?;
let root = directory.path().join("docs");
std::fs::create_dir(&root)?;
let db = directory.path().join("index.db");
std::fs::write(root.join("closest.md"), "closest metadata excluded source")?;
std::fs::write(root.join("allowed.txt"), "allowed metadata source")?;
let mut lexical = SearchIndex::open(&db)?;
lexical.index_folder(&root)?;
let passages = lexical.passage_batch(0, 10)?;
let mut store = Store::open(&db, DIMENSIONS)?;
let fingerprint = crate::generation_fingerprint(searchi_semantic::GEMMA_FINGERPRINT);
let generation = store.begin(&fingerprint)?;
let mut query = vec![0.0; DIMENSIONS];
query[0] = 1.0;
for passage in &passages {
let mut vector = query.clone();
if passage.filename == "allowed.txt" {
vector[0] = 0.8;
vector[1] = 0.6;
}
let windows = [TextWindow {
text: passage.text.clone(),
start_byte: 0,
end_byte: passage.text.len(),
}];
store.insert(generation, passage, 0, &windows, &[vector])?;
}
store.activate(generation)?;
let options = SearchOptions {
extension: Some("txt".into()),
..Default::default()
};
assert_eq!(
store.search(&query, &fingerprint, 1, &options)?[0].filename,
"allowed.txt"
);
let warmed_hashes = HASH_CALLS.with(std::cell::Cell::get);
let _ = store.search(&query, &fingerprint, 1, &options)?;
assert_eq!(
HASH_CALLS.with(std::cell::Cell::get),
warmed_hashes,
"Warm query rehashed unchanged corpus"
);
assert_eq!(
store.exact_search(&query, &fingerprint, 1, &options)?[0].filename,
"allowed.txt"
);
assert!(!crate::HybridSearch::embedding_status(&db)?.stale);
std::fs::write(
root.join("allowed.txt"),
"replacement changes the generation revision",
)?;
lexical.index_folder(&root)?;
let status = crate::HybridSearch::embedding_status(&db)?;
let before_delta = HASH_CALLS.with(std::cell::Cell::get);
assert!(store.search(&query, &fingerprint, 1, &options)?.is_empty());
assert!(
HASH_CALLS.with(std::cell::Cell::get) - before_delta <= 1,
"Single file change rehashed unrelated passages"
);
assert!(status.stale);
assert_eq!(status.pending, 2);
store.connection.execute(
"UPDATE semantic_generations SET ann_blob=x'010203' WHERE id=?1",
[generation],
)?;
drop(store);
let mut store = Store::open(&db, DIMENSIONS)?;
assert!(store
.search(&query, &fingerprint, 1, &SearchOptions::default())
.unwrap_err()
.to_string()
.contains("SHA256 mismatch"));
Ok(())
}
}
#[cfg(test)]
#[path = "ann_benchmark.rs"]
mod ann_benchmark;
@@ -0,0 +1,80 @@
use anyhow::Result;
use searchi_core::SearchIndex;
use searchi_service::{HybridSearch, SearchOptions};
use std::fs;
#[test]
#[ignore = "Requires explicitly provisioned Google model and CPU worker"]
fn real_google_search_persists_and_suppresses_changed_deleted_sources() -> Result<()> {
assert_eq!(
std::env::var("SEARCHI_FORCE_CPU").unwrap(),
"1",
"VM tests require CPU only"
);
let model = std::env::var_os("SEARCHI_GEMMA_MODEL_DIR").expect("Set SEARCHI_GEMMA_MODEL_DIR");
let worker = std::env::var_os("SEARCHI_GEMMA_WORKER").expect("Set SEARCHI_GEMMA_WORKER");
let temp = tempfile::tempdir()?;
let root = temp.path().join("documents");
fs::create_dir(&root)?;
let db = temp.path().join("index.db");
fs::write(
root.join("agreement.txt"),
"The supplier partnership may be ended with sixty days written notice.",
)?;
fs::write(
root.join("garden.txt"),
"Tomatoes need regular watering and lots of sunlight.",
)?;
let mut lexical = SearchIndex::open(&db)?;
lexical.index_folder(&root)?;
let mut hybrid = HybridSearch::open(&db, &model, &worker, 1)?;
let report = hybrid.index_embeddings()?;
assert_eq!(report.passages, 2);
let hits = hybrid.search(
"Wie beenden wir die Zusammenarbeit mit dem Lieferanten?",
10,
&SearchOptions::default(),
)?;
assert_eq!(hits[0].filename, "agreement.txt");
assert!(hits[0].source.is_some());
assert!(hits[0].snippet.contains("sixty days"));
let exact = hybrid.search("garden.txt", 10, &SearchOptions::default())?;
assert_eq!(exact[0].match_kind, "exact_filename");
let restricted = hybrid.search(
"Wie beenden wir die Zusammenarbeit mit dem Lieferanten?",
10,
&SearchOptions {
extension: Some("pdf".into()),
..Default::default()
},
)?;
assert!(restricted.is_empty());
drop(hybrid);
let mut hybrid = HybridSearch::open(&db, &model, &worker, 1)?;
assert_eq!(
hybrid.search(
"How can we stop working with the vendor?",
10,
&SearchOptions::default()
)?[0]
.filename,
"agreement.txt"
);
fs::write(root.join("agreement.txt"),"Entirely unrelated replacement content about repainting a kitchen and changing cupboard handles.")?;
lexical.index_folder(&root)?;
assert!(hybrid
.search_semantic(
"How can we stop working with the vendor?",
10,
&SearchOptions::default()
)?
.iter()
.all(|hit| hit.filename != "agreement.txt"));
fs::remove_file(root.join("garden.txt"))?;
lexical.index_folder(&root)?;
assert!(hybrid
.search_semantic("How do we water vegetables?", 10, &SearchOptions::default())?
.iter()
.all(|hit| hit.filename != "garden.txt"));
Ok(())
}
+11 -1
View File
@@ -4,7 +4,7 @@ final result: blocked
Target: Karl's selected first search/preview concept, combined with Raycast-style
compact interaction and the supplied searchi brand board. Source images were
opened and inspected. The frontend implementation is in progress.
opened and inspected. The frontend implementation builds and its state tests pass.
Rendered comparison is unavailable: the in-app browser bootstrap fails because
its browser-service module cannot be found. Prior native Windows capture attempts
@@ -12,6 +12,16 @@ returned GetCursorPos access denied and monitor capture error 0x80070057.
No matching-state implementation screenshot has been captured. No side-by-side
visual comparison or visual pass is claimed.
The native development app launched in forced-CPU mode. Its accessibility tree
could be read without screenshots and exposed a missing font-import error, which
was corrected. During integration the frontend watcher also attempted to watch a
locked native build executable. The font import and watcher exclusion were
corrected, and a fresh native launch succeeded. Its accessibility tree showed
the real search field, folder/filter/settings controls, empty-state tagline,
and Google model ready with actual CPU device status. Attempting to set the
search field through the supported UI tool failed with GetCursorPos access denied
(0x80070005). These are runtime checks, not a visual comparison or end-to-end pass.
Typography, spacing, colors, asset fidelity, and content must all be evaluated
against a captured native app after access is restored. Keyboard navigation,
progressive results, focus/selection stability, themes, reduced motion, and forced
+1
View File
@@ -26,6 +26,7 @@ Raycast: compact, keyboard-first search with a grouped result list on the left
and a readable passage preview on the right. Search is dominant; settings,
filters, and model details use progressive disclosure. Use native window chrome,
dialogs, theme behavior, and platform shortcut labels.
The selected screenshot is retained at [assets/design/search-reference.jpg](../assets/design/search-reference.jpg).
Apply brief selection/preview transitions without delaying retrieval or input.
Respect reduced motion and forced colors. Preserve focus, selection, and scroll
+98 -26
View File
@@ -1,46 +1,118 @@
# Build and release targets
Install stable Rust, Node 22+, and native Tauri development prerequisites.
On Windows install Visual Studio C++ build tools and WebView2. Linux needs
WebKitGTK 4.1 development libraries. macOS needs Xcode command-line tools.
See https://v2.tauri.app/start/prerequisites/ for platform details.
Use stable Rust, Node 24 LTS, Python 3.11+, Git, CMake, and a native C/C++
toolchain. Python provisions the pinned Google runtime and model; inference
runs in a native process. Default Rust tests do not download model assets.
Windows needs Visual Studio C++ build tools, a Windows SDK, and WebView2.
Linux desktop builds need WebKitGTK 4.1 development libraries; macOS needs
Xcode command-line tools. Follow the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/).
Headless Linux CLI/worker builds do not need WebKitGTK or a display server.
They still need a C/C++ compiler, linker, CMake, and Rust: SQLite, USearch,
NumKong, and the Google worker contain native code.
## Windows CPU development
Run from the repository root. Building the worker clones a pinned llama.cpp
revision and verifies/applies its memory patch. Model provisioning explicitly
uses the network and verifies both Google assets, approximately 865 MB total.
The Python CMake package is needed only when CMake is absent from PATH.
```powershell
python -m pip install cmake
python tools/embeddinggemma2/build_runtime.py --backend cpu --jobs 2
python tools/embeddinggemma2/stage_runtime.py --backend cpu
python tools/embeddinggemma2/download.py
$env:SEARCHI_FORCE_CPU = '1'
$env:SEARCHI_GEMMA_MODEL_DIR = (Resolve-Path .searchi/models/embeddinggemma2).Path
$env:SEARCHI_GEMMA_WORKER = (Resolve-Path .searchi/runtime/cpu/Release/searchi-gemma-worker.exe).Path
cargo fmt --all --check
cargo clippy --workspace --all-targets --locked -- -D warnings
cargo test --workspace --locked
cd apps/desktop
cargo test -p searchi-semantic --test gemma_cpu -- --ignored --nocapture
cargo test -p searchi-service --test google_cpu -- --ignored --nocapture
cargo test -p searchi-service --lib google_cpu_preserves -- --ignored --nocapture
```
The worker is statically linked with the MSVC CRT on Windows. Staging copies
it and the llama.cpp license into the desktop resources directory. Add
`--bundle-model` to staging for an offline model bundle after downloading;
otherwise desktop setup provisions verified models on first launch. Search
itself performs no network inference or downloads. Media weights are staged
for future integration; the current worker supports text only.
The desktop is a standalone Cargo workspace and needs separate validation:
```powershell
cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml --locked
Set-Location apps/desktop
npm ci
npm run format:check
npm test
npm run build
# Windows VM: all rendering and inference tests must use CPU.
$env:WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS='--disable-gpu'
$env:WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS = '--disable-gpu'
npm run tauri -- dev
```
The desktop is a standalone Cargo workspace; validate it separately:
Every Windows VM inference/rendering test must stay CPU only. Production
supports `--backend auto|metal|vulkan|cuda` builds with CPU fallback; do not
exercise those acceleration backends in this VM. Their execution and failure
recovery require validation on the corresponding release hardware.
## Headless Linux Google search
These are the current script and CLI interfaces, not evidence of an executed
Linux release. On a native Linux build host, run from the repository root:
```sh
cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml --locked
python3 tools/embeddinggemma2/build_runtime.py --backend cpu --jobs 2
python3 tools/embeddinggemma2/download.py
export SEARCHI_FORCE_CPU=1
cargo build --release -p searchi-cli --locked
./target/release/searchi index --db searchi.sqlite --root /path/to/documents
./target/release/searchi embed --db searchi.sqlite --model-dir .searchi/models/embeddinggemma2 --embedding-worker .searchi/runtime/cpu/searchi-gemma-worker --cpu-threads 1
./target/release/searchi search --db searchi.sqlite 'supplier cancellation terms' --model-dir .searchi/models/embeddinggemma2 --embedding-worker .searchi/runtime/cpu/searchi-gemma-worker --cpu-threads 1
```
## Cross-compilation
`searchi setup --model-dir PATH` is an alternative verified model-provisioning
command. It provisions model assets, not the native worker. Package the worker
separately and pass its path to headless embedding/search. Each short-lived CLI
search starts a model process; the desktop retains a prewarmed query worker.
The Rust engine has no GUI dependency. Add the destination Rust target with
`rustup target add TARGET`, then use `cargo build -p searchi-cli --target TARGET`.
This requires the target linker, C toolchain and SDK because SQLite is compiled
from bundled C. Target installation alone is insufficient. Document each
verified host/target pair before treating it as supported.
## Cross-compilation and native release matrix
Desktop builds also need the platform webview libraries and packaging tools.
Use native runners for dependable release builds. macOS signing/notarization
needs Apple credentials and tooling. Windows installer cross-builds have
additional Tauri caveats: https://v2.tauri.app/distribute/windows-installer/.
The shared Rust engine/service has no GUI dependency. Install a destination
Rust target with `rustup target add TARGET`, then build using
`cargo build -p searchi-cli --target TARGET --locked`. This also requires the
target C/C++ toolchain, linker, and SDK for bundled SQLite and the vector index.
The separate native Google worker must be compiled for the same destination;
installing a Rust target does not cross-compile it. The current Python runtime
script configures the host toolchain; it does not expose a CMake cross-toolchain
file interface. Record each verified host/target pair before claiming support.
| Release host | Intended artifacts | Remaining validation |
| --- | --- | --- |
| Windows | Windows CLI, worker, desktop installer | Installer, upgrade, signing, GPU hardware |
| macOS | Apple Silicon/Intel CLI, worker, desktop bundle | Native builds, architecture parity, Metal, notarization |
| Linux | CLI, worker, desktop packages | Native builds, distribution ABI, webview, acceleration |
Use native runners for dependable desktop releases. Platform webviews,
packaging tools, SDKs, and signing impose additional constraints. macOS
signing/notarization requires Apple tooling and credentials. See the
[Tauri Windows installer cross-build caveats](https://v2.tauri.app/distribute/windows-installer/).
## CI and packaging gates
Both Gitea and GitHub workflow files are provided. Gitea needs registered runners
matching the configured OS labels; this repository does not provision runners.
No Linux/macOS checks are considered passed until actual job evidence exists.
GitHub and Gitea workflows define native OS matrices. Gitea requires registered
runners matching its configured labels. A workflow declaration does not prove
a platform was tested; Linux/macOS gates remain open until actual job evidence
exists. Current workflow checks do not certify the separately built Google
worker or execute downloaded-model tests automatically.
Current bundles are disabled during development. Enable installers only after
packaging icons, native smoke tests, CPU model distribution and license review,
upgrade/migration tests, signatures and rollback checks are ready. Do not place
private signing keys, repository tokens or model credentials in source control.
Desktop bundles remain disabled during development. Before enabling release
installers, stage the target worker, retain dependency/model notices, verify
clean-machine launch and setup, run native Google smoke tests, and complete
upgrade/migration, signatures, and rollback checks. Optional offline bundles
need both verified model files. See [semantic bounds and evidence](SEMANTIC.md)
for current text-only, corpus, and performance limitations. Keep signing keys,
repository tokens, and model credentials outside source control.
+18
View File
@@ -2,6 +2,21 @@
Status: implementation started; no production release yet. Windows VM tests use CPU only.
## Required product scope (user clarification)
- Desktop UI is primary; semantic setup/indexing/search must require no terminal.
- Google EmbeddingGemma 2 is the required production model, matching DigUp.
- Local document, image, audio and video search are required product milestones.
- A separate CLI-only build supports headless Linux without desktop dependencies.
- Production automatically uses available hardware acceleration with CPU fallback;
this Windows VM is tested in forced-CPU mode only.
- Installer ships the native worker. First launch automatically downloads/verifies
missing model files and manages indexing in the UI; no terminal setup is required.
- Earlier MiniLM proof is an experiment and does not satisfy the model requirement.
- First interaction must be responsive: prepare indexes and a real Google query
in the background, retain ready sessions, and show immediate keyword results.
Model download and initialization must have honest progress and retry states.
## 1. Search foundation
- [x] Rust workspace, reusable engine, CLI, versioned SQLite storage.
- [ ] Filename/path and ranked full-text retrieval with strict filters.
@@ -30,6 +45,9 @@ Status: implementation started; no production release yet. Windows VM tests use
- [ ] Formatting, lint, tests and security/dependency review.
- [ ] CPU benchmarks: filename p95 <50 ms/1M records; text <150 ms/100K documents; semantic <500 ms on declared hardware.
- [ ] Measure cold start, indexing throughput, RAM, disk and idle CPU.
- [ ] Record the very first foreground query and distinct unseen queries after
production background preparation; report initialization time separately.
Warm p95 alone does not satisfy the first-use requirement.
- [ ] Test migrations, interrupted work, invalid documents, inaccessible/offline roots and cloud placeholders.
- [ ] Automated native build matrix and documented supported cross-compilation paths.
- [ ] Execute Linux/macOS platform tests; create Windows/macOS/Linux installers.
+158 -134
View File
@@ -1,155 +1,179 @@
# CPU semantic inference proof
# Local semantic search
This milestone runs genuine neural embeddings locally. It does not yet connect
semantic vectors to the SQLite search index or desktop interface. Production
retrieval quality, large-corpus performance, and Linux/macOS runtime packaging
remain unverified.
Searchi uses **Google EmbeddingGemma 2**, with the pinned Q8 GGUF text encoder
and llama.cpp revision used by DigUp. Production has no MiniLM, simulated-vector,
or remote embedding fallback. The rejected ONNX experiment is retained only
under the opt-in minilm-proof feature and tools/semantic-proof/HISTORICAL.md.
## Model and runtime decision
## Model and runtime
The first working baseline is the quantized ONNX export of
`sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2`, published by
`Xenova`. The upstream model card describes 384-dimensional text embeddings,
attention-mask mean pooling, a 128-token maximum, and Apache-2.0 licensing.
It covers multiple languages. This is a text-only baseline; it does not claim
EmbeddingGemma 2's multimodal features or compatibility.
- Model: google/embeddinggemma-2, **768-dimensional** output.
- GGUF publisher: ggml-org/embeddinggemma-2-GGUF.
- Revision: bfcd298762cc34d0357ece5ebdd31791a3a374d8.
- llama.cpp: b11461, commit 4d756bc72bf00a4aacf410ae15a2d315f3db400d,
with DigUp's no-logits memory patch.
- Query prompt: task: search result | query: TEXT.
- Document prompt: title: TITLE_OR_none | text: TEXT.
- Noncausal encoder, mean pooling, finite L2-normalized vectors.
- Query/document token budgets: 512/2048.
Sources reviewed on 2026-10-12:
The persistent native helper uses bounded NDJSON over stdin/stdout. It has no
HTTP server or download code. Rust verifies the text model's length and SHA256
before starting it. Setup provisions assets; search never downloads them.
- [Original model card and pooling architecture](https://huggingface.co/sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2)
- [ONNX export publisher](https://huggingface.co/Xenova/paraphrase-multilingual-MiniLM-L12-v2)
- [ONNX Runtime Python session API](https://onnxruntime.ai/docs/api/python/api_summary.html)
- [Rust ort dynamic-library initialization](https://docs.rs/ort/2.0.0-rc.13/ort/environment/fn.init_from.html)
- [Rust ort session configuration](https://docs.rs/ort/2.0.0-rc.13/ort/session/builder/struct.SessionBuilder.html)
Production device mode is auto, with available compiled acceleration and CPU
fallback. Model/context initialization and returned inference/nonfinite-output
failures retry on CPU once. A lost GPU worker can be restarted as CPU for one
retry of that request. WorkerInfo and responses report the actual device.
**Windows VM tests remain CPU only**, via SEARCHI_FORCE_CPU=1 and Device::Cpu.
GPU execution/failure recovery has not been exercised here.
Inference explicitly selects `CPUExecutionProvider`. Python uses the CPU
`onnxruntime` wheel, never `onnxruntime-gpu`. The native Rust crate disables ort
default features: there are no binary downloads, GPU provider features, or model
HTTP client features. It loads an explicitly supplied local runtime library and
disables runtime telemetry. Tokenization is sequential; ONNX sessions use
sequential graph execution with bounded intra-op threads and one inter-op thread.
## Reproduce on Windows
Run these commands from the repository root. Provisioning commands intentionally
use the network; the inference command does not provision anything.
```powershell
python -m venv tools/semantic-proof/.venv
& tools/semantic-proof/.venv/Scripts/python.exe -m pip install --only-binary=:all: -r tools/semantic-proof/requirements.txt
& tools/semantic-proof/.venv/Scripts/python.exe tools/semantic-proof/download.py
& tools/semantic-proof/.venv/Scripts/python.exe tools/semantic-proof/proof.py --threads 1 --output tools/semantic-proof/windows-cpu-result.json
```
On Linux/macOS, use `.venv/bin/python` instead of `.venv/Scripts/python.exe`.
These instructions have only been executed on Windows. Wheel availability and
native library distribution must be checked on other platforms.
`download.py` downloads exactly two revision-pinned artifacts, streams them to a
temporary file, rejects oversized downloads, checks byte length and SHA256, then
atomically replaces the local asset. Assets occupy approximately 136 MB in
decimal units and are ignored by Git. Inference validates both files again and
fails clearly when either is missing or damaged. It never contacts the model
hub. The Python proof sets offline environment flags and rejects Python socket
connect, name-resolution, and bind audit events throughout import and inference.
This guard checks Python socket activity; it is not an operating-system firewall
against arbitrary native libraries.
The proof intentionally exits nonzero if any fixture query misses top-1. Its
JSON report is written before the quality gate, preserving failures for review.
## Pinned artifacts and licensing
Revision: `2c4055b12046f11709e9df2c122e59ffbdc2f900`.
The manifest in `tools/semantic-proof/model-manifest.json` records the same
model contract and hashes used by the native adapter.
The Windows worker links a static MSVC CRT and static llama libraries, without
an external OpenMP runtime. Production CMake supports cpu, auto, metal, vulkan,
and cuda; CPU builds force GPU flags off. Runtime/provider parity and packaging
on other operating systems remain independent release checks.
| Artifact | Bytes | SHA256 |
| --- | ---: | --- |
| `onnx/model_quantized.onnx` | 118,308,126 | `66fc00f5f29afcaff34092e1bdd20008ca3918265a82fb9695a551e510cc4ebc` |
| `tokenizer.json` | 17,082,913 | `b60b6b43406a48bf3638526314f3d232d97058bc93472ff2de930d43686fa441` |
| embeddinggemma-2-Q8_0.gguf | 309,855,456 | 2188ac1deca4b77dffefd603c2776a9d76d9d74ec01841392982ebb840b09135 |
| mmproj-embeddinggemma-2-Q8_0.gguf | 554,821,024 | c4a8a52691ecef40618438928bdf9e68379b854e24166f292592353db0aab64f |
The base model card specifies Apache-2.0. The export card identifies that base
model but does not display its own license declaration. Before redistributing
the ONNX export in installers, confirm export licensing and preserve the base
license, attribution, and applicable notices. Current code explicitly downloads
user-local assets and does not ship model weights or runtime DLLs in Git.
ONNX Runtime uses MIT licensing; ort uses MIT or Apache-2.0 and tokenizers uses
Apache-2.0. Release packaging must retain third-party notices and validate
platform-specific runtime hashes independently; a model hash does not attest
to the runtime library.
Setup downloads both pinned files. **The current worker implements text**;
image/audio projection loading and image/audio/video embeddings remain required
work. Downloaded media weights do not establish functioning multimodal search.
The tested Windows ONNX Runtime 1.31.0 DLL is 19,288,888 bytes with SHA256
`90497e3d406c09f4d59d364c696a2f93dc02f6ef13741b3b89eec95bdb4a3806`.
The adapter currently trusts the explicitly supplied runtime path; it validates
model hashes, not runtime-library hashes. A release distribution must add a
platform runtime manifest before shipping. `requirements-lock-windows.txt`
captures the exact Python environment used for this proof.
Weights use Apache-2.0; llama.cpp and DigUp use MIT; USearch and NumKong use
Apache-2.0. Retain notices with distributions. Five DigUp text reference vectors
are copied under MIT in crates/searchi-semantic/tests/fixtures with the upstream
license and source commit alongside them.
## Measured Windows VM result
Primary sources:
`tools/semantic-proof/windows-cpu-result.json` contains the full measurement and
rankings. The run used Windows 11 AMD64, Python 3.14.8, ONNX Runtime 1.31.0,
four visible logical CPUs, and **one inference thread with only the CPU provider**.
- [Google developer guide](https://developers.googleblog.com/embeddinggemma-2-the-developer-guide/)
- [Model documentation](https://huggingface.co/docs/transformers/model_doc/embedding_gemma2)
- [Pinned GGUF publisher](https://huggingface.co/ggml-org/embeddinggemma-2-GGUF/tree/bfcd298762cc34d0357ece5ebdd31791a3a374d8)
- [Pinned DigUp reference](https://github.com/ARahim3/DigUp/tree/6faa29e4c315a3a591de1b41bd78751bdf39588d)
- [USearch HNSW and filtered retrieval](https://github.com/unum-cloud/USearch)
## Shared API and bounds
GemmaEmbedder owns actual tokenization, document/query prompts, and inference.
HybridSearch is the shared CLI/desktop service:
~~~rust
HybridSearch::open(db, model_dir, worker_executable, threads)
HybridSearch::embedding_status(db) // No model/runtime load or network
service.warmup() // Real query inference + ANN cache
service.index_embeddings()
service.search(query, limit, &SearchOptions)
service.search_semantic(query, limit, &SearchOptions) // Diagnostics
service.worker_info() // Actual model/runtime/device/threads
~~~
Threads are bounded to 1–8; VM tests use one. Document batches contain at most
eight windows. Request/response budgets are 2/4 MiB, startup has a 120-second
deadline, and each request has a 180-second deadline. Malformed or interrupted
IPC and invalid embeddings fail explicitly.
The service makes 64 KiB UTF-8 slices with 512-byte overlap before genuine model
token windows. It preserves global byte ranges and page/paragraph/line sources,
handles 2 MiB passages, caps each passage at 2048 windows, and bounds titles to
512 UTF-8 bytes. Generation fingerprints include this chunk policy as well as
model/runtime/prompts/token budgets/normalization.
Keep a prewarmed query service and a separate background builder. Readiness
requires actual inference, rather than merely finding verified model files.
## Persistence, ANN, and ranking
Semantic storage has an independent schema version within the shared SQLite
database. Chunks store generation, passage/path identity, original-source hash,
location, byte range, snippet, and vector. HNSW uses USearch 2.26.4, cosine/F32,
16 connections, construction expansion 128, and search expansion 64. NumKong
dispatches supported CPU SIMD kernels with portable fallback. The Cargo config
contains the Windows NK_DYNAMIC override required for static NumKong linkage.
Staging vectors and a real HNSW graph are persisted with an ANN blob SHA256.
Activation is atomic. Failed rebuilds preserve the active generation; the next
rebuild reclaims abandoned staging under a cross-process lock. Search uses a
consistent read snapshot.
Metadata filters and source hashes establish eligible keys before ANN top-k.
The cache is keyed by generation, corpus revision, and normalized filters.
Persistent dirty-path triggers invalidate affected files; unchanged warm
queries hash zero passages. Large change batches or expired history rebuild
eligibility. Default-filter prewarm includes that initial work.
Fusion uses reciprocal-rank fusion with k = 60. Exact filenames and literal
identifiers retain priority. Ordinary partial filenames receive lexical
weight 1.15 and can be outranked by keyword/semantic consensus. File results
retain source snippets and deterministic path tie breaks.
Current caps are 100,000 chunks and a 512 MiB serialized ANN blob per generation.
Full rebuilds still regenerate embeddings and the graph. Incremental reuse/jobs
and efficient ANN updates remain production work. One million filename records
does not imply one million semantic chunks under these defaults.
## Executed Windows CPU evidence
Actual Google CPU vectors match five pinned DigUp text references at cosine
**0.99979–0.99994**, exceeding the 0.999 gate. These cover English, Bengali,
Arabic, a short document, and a document beyond the sliding attention window.
Actual tests also cover English/German retrieval, persisted vectors, changed/
deleted-source suppression, verified long windows, and a tail **after 64 KiB**.
The immutable 20-document/24-query seed corpus produced:
| Lane | Top 1 | Recall at 3 |
| --- | ---: | ---: |
| Lexical | 5/24 | 5/24 |
| Google semantic | 24/24 | 24/24 |
| Hybrid HNSW | 24/24 | 24/24 |
With one CPU thread, fresh model/context startup took approximately **7 seconds
in the background**, actual query/ANN prewarm **128 ms**, and first foreground
natural-language search **99 ms** with the correct supplier document first.
Warm seed-corpus hybrid p50/p95 was **92/107 ms**. Model downloads are excluded.
A separate release-profile benchmark used 100,000 synthetic normalized 768-dimensional
vectors and 20 queries against exact nearest neighbors:
| Metric | Measurement |
| --- | ---: |
| Fixture documents / queries | 8 / 6 |
| Timed warm query runs | 60 |
| Artifact verification plus runtime/model load | approximately 3.4 seconds |
| Corpus batch embedding | approximately 145 ms |
| Warm query p50 / p95 | approximately 3.1 / 11.9 ms |
| Top-1 accuracy | 5/6 (83.3%) |
| Recall at 3 | 6/6 (100%) |
| Recall at 10 vs exact | 98.5% |
| HNSW query p95 | 1.37 ms |
| Warm service retrieval p95 | 6.40 ms |
| Warm passages rehashed | 0 |
| Initial eligibility work | 1.61 seconds |
| Graph construction/serialization | 57.95 seconds |
| Reported graph memory | 554 MB |
| Dispatched CPU kernel | skylake |
The corpus includes English and German documents, same-language semantic
queries, and cross-language queries. A question about a postponed product
release ranks the supplier contract above the expected meeting notes. That
failure remains in the fixture and blocks the strict top-1 quality gate.
This small demonstration supports the feasibility of CPU inference; it does
not establish large-corpus latency, robust multilingual quality, or production
readiness. Quantization and batch shape can affect similarity scores.
This is **index-only performance**, not neural quality across 100,000 documents.
The seed corpus and synthetic vectors do not certify production relevance.
Representative corpora, cold/selective-filter latency, concurrent indexing,
resource profiles, GPU parity/fallback, multimodal retrieval, and platform
releases remain validation gates.
## Native integration contract
## Reproduce
`searchi-semantic` exposes:
Run from the repository root. Provisioning uses the network explicitly:
```rust
CpuEmbedder::load(model_dir, runtime_library, threads) -> Result<CpuEmbedder>
CpuEmbedder::encode(&mut self, texts: &[&str]) -> Result<Vec<Vec<f32>>>
DIMENSIONS // 384
MODEL_FINGERPRINT // revision + quantization + pooling + normalization + token cap
```
~~~powershell
python tools/embeddinggemma2/download.py
python tools/embeddinggemma2/build_runtime.py --backend cpu
$env:SEARCHI_FORCE_CPU = '1'
$env:SEARCHI_GEMMA_MODEL_DIR = (Resolve-Path .searchi/models/embeddinggemma2).Path
$env:SEARCHI_GEMMA_WORKER = (Resolve-Path .searchi/runtime/cpu/Release/searchi-gemma-worker.exe).Path
cargo test -p searchi-semantic --test gemma_cpu -- --ignored --nocapture
cargo test -p searchi-service --test google_cpu -- --ignored --nocapture
cargo test -p searchi-service --lib google_cpu_preserves -- --ignored --nocapture
cargo run -p searchi-service --example evaluate -- .searchi/models/embeddinggemma2 .searchi/runtime/cpu/Release/searchi-gemma-worker.exe .searchi/google-relevance-ann.json
$env:SEARCHI_ANN_REPORT = (Join-Path (Get-Location) '.searchi/ann-100k.json')
cargo test --release -p searchi-service ann_100k -- --ignored --nocapture
~~~
Inputs are bounded to eight texts per batch, 64 KiB UTF-8 per text, and 128
tokens. Outputs are finite, normalized `f32` vectors. The runtime library is
process-global; adapter initialization rejects attempts to switch libraries or
silently accept an environment initialized outside the adapter. Supply a known,
trusted runtime before calling any other ort APIs in the process.
Default unit tests need no model or runtime. The real-artifact test is opt-in:
```powershell
$env:SEARCHI_MODEL_DIR = (Resolve-Path tools/semantic-proof/model).Path
$env:SEARCHI_ORT_LIBRARY = (Resolve-Path tools/semantic-proof/.venv/Lib/site-packages/onnxruntime/capi/onnxruntime.dll).Path
cargo test -p searchi-semantic --test cpu_model -- --ignored --nocapture
```
The smoke test checks actual local inference, vector normalization, English and
German retrieval, recall at three, and the measured five-of-six top-1 baseline.
It does not replace the strict production quality gate.
The native real-artifact smoke was executed successfully on the Windows VM,
using that CPU-only DLL. It reproduced five-of-six top-1 and six-of-six recall
at three. Three default unit tests passed; targeted Clippy passed with warnings
denied. Linux and macOS native checks have not been executed.
The next integration should read bounded `SearchIndex::passage_batch` rows,
split long passages into overlapping tokenizer windows, preserve source page or
paragraph locations, and persist each vector with a content fingerprint and
`MODEL_FINGERPRINT`. Passage IDs are not immutable content identities: changes
must invalidate stale vectors. Queries should combine lexical and semantic
retrieval while preserving exact filename priority. Model upgrades build a
separate vector generation before an atomic swap. Cancellation, persistent
embedding jobs, ANN retrieval, representative quality corpora, and signed
cross-platform runtime packaging are still required.
The ignored ANN test deliberately requires release mode. Default tests require
no model/network and cover protocol bounds, invalid vectors, UTF-8 coverage,
fusion, lifecycle, checksums, pre-top-k filtering, and dirty-path caching.
Linux/macOS and GPU checks are not inferred from this Windows CPU run.
+10 -1
View File
@@ -5,7 +5,7 @@ All local tests run on CPU; no GPU provider is configured.
## Current integration checkpoint (2026-10-12)
- `cargo test --workspace --locked`: 39 default tests passed. Four asset-dependent
- `cargo test --workspace --locked`: 41 default tests passed after final core integration. Four asset-dependent
or explicit benchmark tests remain opt-in; a default pass does not run them.
- Native desktop tests: 7 passed with SEARCHI_FORCE_CPU=1 and WebView2 GPU disabled.
- Frontend: 7 async-state tests passed, Prettier passed, TypeScript/Vite build passed.
@@ -18,6 +18,15 @@ All local tests run on CPU; no GPU provider is configured.
blocked because browser bootstrap and native screen capture are unavailable.
No visual match or complete UI interaction verification is claimed.
Final UI integration: the branded compact launcher, local Inter/Fluent assets,
themes and motion implementation passed frontend build, formatting, and seven
state tests. Native launch succeeded; accessibility text showed real controls
and a warmed Google CPU worker. UI input and screenshots remain blocked by
Windows access/capture errors, as recorded in design-qa.md. Workspace formatting
and strict all-target Clippy passed. The fixed full-corpus first-input report
is in crates/searchi-core/benchmarks/results/windows-cpu-full-candidate-first-final.json;
all 40 measured filename inputs were below 50 ms in that run.
The active parallel development branches of work may add tests or change counts
after this checkpoint. Run the documented checks again after integration.
+14
View File
@@ -0,0 +1,14 @@
# Retrieval regression corpus
This synthetic seed corpus contains 20 documents and 24 predetermined queries:
exact filenames, error/invoice identifiers, remembered concepts and English/German
cross-language retrieval. Each query labels one expected file. Supplier-contract
termination and meeting cancellation deliberately share words but differ in intent.
Report top-1 accuracy, recall@10, per-query rank and latency. Keep failures in
reports; do not rewrite expected answers or remove difficult queries to make a
model pass. Evaluate lexical, semantic and hybrid modes on the same corpus.
This corpus is a regression fixture, not a representative user dataset. Production
quality needs a larger independently labelled corpus, varied document lengths,
PDF/OCR/media cases, ambiguous/no-answer queries and measured filter correctness.
+51
View File
@@ -0,0 +1,51 @@
{
"description": "Synthetic seed corpus for hybrid retrieval regression, not production quality certification.",
"documents": [
{"id":"supplier-contract","filename":"supplier-agreement.txt","text":"Our supplier agreement requires sixty days written notice to terminate the partnership. Notify the procurement contact before ending the contract."},
{"id":"apartment-lease","filename":"mietvertrag.txt","text":"Im Mietvertrag steht: Haustiere sind nach schriftlicher Zustimmung des Vermieters erlaubt. Die Kaution beträgt drei Monatsmieten."},
{"id":"invoice","filename":"INV-2026-1042.txt","text":"Invoice INV-2026-1042. Office chairs purchased in October. Payment reference OFFICE-CHAIRS-42. Total amount 420 euros."},
{"id":"travel-expenses","filename":"reisekosten.txt","text":"Reisekosten für die Dienstreise nach Berlin: Hotelrechnung und Bahnticket bitte zusammen einreichen. Die Erstattung erfolgt nach Freigabe."},
{"id":"conference-agenda","filename":"conference-agenda.txt","text":"The engineering conference begins with registration at nine, followed by a database performance workshop. The afternoon session discusses accessible interfaces."},
{"id":"deployment","filename":"deployment-runbook.txt","text":"Deploy a release by checking migrations, preparing the new build, switching traffic, and monitoring errors. Roll back to the previous build if health checks fail."},
{"id":"backup-restore","filename":"backup-recovery.txt","text":"To recover deleted files, select the most recent successful backup snapshot. Restore into a separate folder, verify file hashes, and only then replace the damaged originals."},
{"id":"api-retry","filename":"retry-design.txt","text":"Retry temporary API failures using exponential backoff and random jitter. Stop after five attempts. Do not retry authentication errors or requests that could duplicate a payment."},
{"id":"oauth","filename":"authentication-notes.txt","text":"Access tokens expire quickly. A refresh token obtains a new access token after expiry. Store refresh tokens securely and revoke them when a user signs out."},
{"id":"network-outage","filename":"network-incident.txt","text":"The network outage was caused by a failed switch power supply. Replacement power restored connectivity. Monitoring should alert when the switch becomes unreachable."},
{"id":"launch-plan","filename":"product-launch.txt","text":"The product launch plan schedules the public announcement after the beta feedback review. Publish the release notes and prepare the customer onboarding guide."},
{"id":"research-budget","filename":"research-budget.txt","text":"The research budget allocates funds to user interviews, prototype testing and accessibility studies. Review spending with the research lead each month."},
{"id":"vacation-policy","filename":"urlaubsrichtlinie.txt","text":"Urlaub muss im Teamkalender eingetragen und von der Teamleitung genehmigt werden. Während der Abwesenheit übernimmt die benannte Vertretung dringende Aufgaben."},
{"id":"recipe","filename":"tomato-soup.txt","text":"For tomato soup, roast tomatoes with garlic, blend with vegetable stock, and finish with fresh basil. Serve with toasted bread."},
{"id":"renovation","filename":"renovation-estimate.txt","text":"The renovation estimate covers replacing kitchen cabinets, repainting the walls and installing new lights. Flooring work is excluded from this quote."},
{"id":"warranty","filename":"monitor-warranty.txt","text":"The monitor warranty covers manufacturing defects for two years. Keep the receipt and serial number. Accidental damage is excluded."},
{"id":"search-architecture","filename":"search-engine-design.txt","text":"The file search engine combines exact filenames, ranked keyword matches and semantic embeddings. Indexing runs in bounded background workers while queries use the committed index."},
{"id":"garden","filename":"gartenplanung.txt","text":"Die Gartenplanung sieht ein Hochbeet für Tomaten, Kräuter und Salat vor. Die Regentonne versorgt das Beet bei trockenem Wetter mit Wasser."},
{"id":"cancellation-email","filename":"meeting-cancellation.txt","text":"Cancel tomorrow's planning meeting because the participants are unavailable. Send a new invitation for next week. This note does not change any supplier contract."},
{"id":"error-code","filename":"support-error.txt","text":"Error E_SEARCH_071 means the local search database schema is newer than this app. Upgrade the application before opening that index."}
],
"queries": [
{"text":"supplier-agreement.txt","expected":"supplier-contract","language":"exact"},
{"text":"INV-2026-1042","expected":"invoice","language":"identifier"},
{"text":"E_SEARCH_071","expected":"error-code","language":"identifier"},
{"text":"How can we end our relationship with the supplier?","expected":"supplier-contract","language":"en"},
{"text":"Wie kündigen wir die Zusammenarbeit mit unserem Lieferanten?","expected":"supplier-contract","language":"de-crosslingual"},
{"text":"Can I keep a pet in the rented apartment?","expected":"apartment-lease","language":"en-crosslingual"},
{"text":"Wo steht etwas über die Kaution für die Wohnung?","expected":"apartment-lease","language":"de"},
{"text":"How do I claim my train and hotel costs?","expected":"travel-expenses","language":"en-crosslingual"},
{"text":"Wann ist der Workshop zur Datenbankleistung?","expected":"conference-agenda","language":"de-crosslingual"},
{"text":"What should we do if the new release fails its health check?","expected":"deployment","language":"en"},
{"text":"Ich habe Dateien gelöscht und brauche sie aus der Sicherung zurück","expected":"backup-restore","language":"de-crosslingual"},
{"text":"How should the app retry a temporary server failure?","expected":"api-retry","language":"en"},
{"text":"How do we obtain another access token after it expires?","expected":"oauth","language":"en"},
{"text":"Warum war der Netzwerkswitch nicht erreichbar?","expected":"network-outage","language":"de-crosslingual"},
{"text":"The plan for announcing the new product after beta testing","expected":"launch-plan","language":"en"},
{"text":"Money allocated for interviews and prototype usability studies","expected":"research-budget","language":"en"},
{"text":"Who approves my time off and who covers urgent work?","expected":"vacation-policy","language":"en-crosslingual"},
{"text":"A soup made with roasted tomatoes and basil","expected":"recipe","language":"en"},
{"text":"Does the kitchen renovation quote include new flooring?","expected":"renovation","language":"en"},
{"text":"The document about replacing a defective display under warranty","expected":"warranty","language":"en"},
{"text":"How can keyword results and embeddings be combined for file search?","expected":"search-architecture","language":"en"},
{"text":"Where is the plan for the raised vegetable bed and rainwater barrel?","expected":"garden","language":"en-crosslingual"},
{"text":"Cancel tomorrow's planning meeting","expected":"cancellation-email","language":"en"},
{"text":"OFFICE-CHAIRS-42","expected":"invoice","language":"identifier"}
]
}
+852
View File
@@ -0,0 +1,852 @@
{
"background_prewarm_ms": 128.4059,
"device": "cpu",
"documents": 20,
"embedding_report": {
"chunks": 20,
"generation": 1,
"model_fingerprint": "google/embeddinggemma-2:bfcd298762cc34d0357ece5ebdd31791a3a374d8:q8_0:llama-b11461-no-logits-v1:mean:l2:query512:doc2048:windows-overlap128:prompts-v1",
"passages": 20
},
"first_foreground_ms": 99.1915,
"first_foreground_top1": "supplier-agreement.txt",
"hybrid": {
"recall_at10": 1.0,
"recall_at3": 1.0,
"top1_accuracy": 1.0
},
"hybrid_p50_ms": 92.2909,
"hybrid_p95_ms": 106.83239999999999,
"indexing_ms": 3765.0646,
"lexical": {
"recall_at10": 0.20833333333333334,
"recall_at3": 0.20833333333333334,
"top1_accuracy": 0.20833333333333334
},
"limitation": "Synthetic seed corpus only; no production quality or large-corpus latency certification",
"load_ms": 7215.3678,
"model": "google/embeddinggemma-2",
"queries": 24,
"rankings": [
{
"expected": "supplier-contract",
"hybrid_rank": 1,
"hybrid_top3": [
{
"filename": "supplier-agreement.txt",
"match_kind": "exact_filename",
"source": null
},
{
"filename": "meeting-cancellation.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "INV-2026-1042.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
}
],
"language": "exact",
"lexical_rank": 1,
"query": "supplier-agreement.txt",
"semantic_rank": 1
},
{
"expected": "invoice",
"hybrid_rank": 1,
"hybrid_top3": [
{
"filename": "INV-2026-1042.txt",
"match_kind": "filename",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "meeting-cancellation.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "network-incident.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
}
],
"language": "identifier",
"lexical_rank": 1,
"query": "INV-2026-1042",
"semantic_rank": 1
},
{
"expected": "error-code",
"hybrid_rank": 1,
"hybrid_top3": [
{
"filename": "support-error.txt",
"match_kind": "exact_identifier",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "search-engine-design.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "INV-2026-1042.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
}
],
"language": "identifier",
"lexical_rank": 1,
"query": "E_SEARCH_071",
"semantic_rank": 1
},
{
"expected": "supplier-contract",
"hybrid_rank": 1,
"hybrid_top3": [
{
"filename": "supplier-agreement.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "meeting-cancellation.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "retry-design.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
}
],
"language": "en",
"lexical_rank": null,
"query": "How can we end our relationship with the supplier?",
"semantic_rank": 1
},
{
"expected": "supplier-contract",
"hybrid_rank": 1,
"hybrid_top3": [
{
"filename": "supplier-agreement.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "meeting-cancellation.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "mietvertrag.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
}
],
"language": "de-crosslingual",
"lexical_rank": null,
"query": "Wie kündigen wir die Zusammenarbeit mit unserem Lieferanten?",
"semantic_rank": 1
},
{
"expected": "apartment-lease",
"hybrid_rank": 1,
"hybrid_top3": [
{
"filename": "mietvertrag.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "support-error.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "urlaubsrichtlinie.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
}
],
"language": "en-crosslingual",
"lexical_rank": null,
"query": "Can I keep a pet in the rented apartment?",
"semantic_rank": 1
},
{
"expected": "apartment-lease",
"hybrid_rank": 1,
"hybrid_top3": [
{
"filename": "mietvertrag.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "reisekosten.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "monitor-warranty.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
}
],
"language": "de",
"lexical_rank": null,
"query": "Wo steht etwas über die Kaution für die Wohnung?",
"semantic_rank": 1
},
{
"expected": "travel-expenses",
"hybrid_rank": 1,
"hybrid_top3": [
{
"filename": "reisekosten.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "INV-2026-1042.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "conference-agenda.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
}
],
"language": "en-crosslingual",
"lexical_rank": null,
"query": "How do I claim my train and hotel costs?",
"semantic_rank": 1
},
{
"expected": "conference-agenda",
"hybrid_rank": 1,
"hybrid_top3": [
{
"filename": "conference-agenda.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "support-error.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "search-engine-design.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
}
],
"language": "de-crosslingual",
"lexical_rank": null,
"query": "Wann ist der Workshop zur Datenbankleistung?",
"semantic_rank": 1
},
{
"expected": "deployment",
"hybrid_rank": 1,
"hybrid_top3": [
{
"filename": "deployment-runbook.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "product-launch.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "support-error.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
}
],
"language": "en",
"lexical_rank": null,
"query": "What should we do if the new release fails its health check?",
"semantic_rank": 1
},
{
"expected": "backup-restore",
"hybrid_rank": 1,
"hybrid_top3": [
{
"filename": "backup-recovery.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "network-incident.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "meeting-cancellation.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
}
],
"language": "de-crosslingual",
"lexical_rank": null,
"query": "Ich habe Dateien gelöscht und brauche sie aus der Sicherung zurück",
"semantic_rank": 1
},
{
"expected": "api-retry",
"hybrid_rank": 1,
"hybrid_top3": [
{
"filename": "retry-design.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "deployment-runbook.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "network-incident.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
}
],
"language": "en",
"lexical_rank": null,
"query": "How should the app retry a temporary server failure?",
"semantic_rank": 1
},
{
"expected": "oauth",
"hybrid_rank": 1,
"hybrid_top3": [
{
"filename": "authentication-notes.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "retry-design.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "backup-recovery.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
}
],
"language": "en",
"lexical_rank": null,
"query": "How do we obtain another access token after it expires?",
"semantic_rank": 1
},
{
"expected": "network-outage",
"hybrid_rank": 1,
"hybrid_top3": [
{
"filename": "network-incident.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "meeting-cancellation.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "backup-recovery.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
}
],
"language": "de-crosslingual",
"lexical_rank": null,
"query": "Warum war der Netzwerkswitch nicht erreichbar?",
"semantic_rank": 1
},
{
"expected": "launch-plan",
"hybrid_rank": 1,
"hybrid_top3": [
{
"filename": "product-launch.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "research-budget.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "deployment-runbook.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
}
],
"language": "en",
"lexical_rank": null,
"query": "The plan for announcing the new product after beta testing",
"semantic_rank": 1
},
{
"expected": "research-budget",
"hybrid_rank": 1,
"hybrid_top3": [
{
"filename": "research-budget.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "INV-2026-1042.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "retry-design.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
}
],
"language": "en",
"lexical_rank": null,
"query": "Money allocated for interviews and prototype usability studies",
"semantic_rank": 1
},
{
"expected": "vacation-policy",
"hybrid_rank": 1,
"hybrid_top3": [
{
"filename": "urlaubsrichtlinie.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "meeting-cancellation.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "INV-2026-1042.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
}
],
"language": "en-crosslingual",
"lexical_rank": null,
"query": "Who approves my time off and who covers urgent work?",
"semantic_rank": 1
},
{
"expected": "recipe",
"hybrid_rank": 1,
"hybrid_top3": [
{
"filename": "tomato-soup.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "gartenplanung.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "INV-2026-1042.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
}
],
"language": "en",
"lexical_rank": null,
"query": "A soup made with roasted tomatoes and basil",
"semantic_rank": 1
},
{
"expected": "renovation",
"hybrid_rank": 1,
"hybrid_top3": [
{
"filename": "renovation-estimate.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "INV-2026-1042.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "meeting-cancellation.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
}
],
"language": "en",
"lexical_rank": null,
"query": "Does the kitchen renovation quote include new flooring?",
"semantic_rank": 1
},
{
"expected": "warranty",
"hybrid_rank": 1,
"hybrid_top3": [
{
"filename": "monitor-warranty.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "network-incident.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "backup-recovery.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
}
],
"language": "en",
"lexical_rank": null,
"query": "The document about replacing a defective display under warranty",
"semantic_rank": 1
},
{
"expected": "search-architecture",
"hybrid_rank": 1,
"hybrid_top3": [
{
"filename": "search-engine-design.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "support-error.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "backup-recovery.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
}
],
"language": "en",
"lexical_rank": null,
"query": "How can keyword results and embeddings be combined for file search?",
"semantic_rank": 1
},
{
"expected": "garden",
"hybrid_rank": 1,
"hybrid_top3": [
{
"filename": "gartenplanung.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "meeting-cancellation.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "renovation-estimate.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
}
],
"language": "en-crosslingual",
"lexical_rank": null,
"query": "Where is the plan for the raised vegetable bed and rainwater barrel?",
"semantic_rank": 1
},
{
"expected": "cancellation-email",
"hybrid_rank": 1,
"hybrid_top3": [
{
"filename": "meeting-cancellation.txt",
"match_kind": "hybrid",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "urlaubsrichtlinie.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "conference-agenda.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
}
],
"language": "en",
"lexical_rank": 1,
"query": "Cancel tomorrow's planning meeting",
"semantic_rank": 1
},
{
"expected": "invoice",
"hybrid_rank": 1,
"hybrid_top3": [
{
"filename": "INV-2026-1042.txt",
"match_kind": "exact_identifier",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "meeting-cancellation.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
},
{
"filename": "search-engine-design.txt",
"match_kind": "semantic",
"source": {
"kind": "line",
"number": 1
}
}
],
"language": "identifier",
"lexical_rank": 1,
"query": "OFFICE-CHAIRS-42",
"semantic_rank": 1
}
],
"semantic": {
"recall_at10": 1.0,
"recall_at3": 1.0,
"top1_accuracy": 1.0
},
"startup_init_ms": 6966.2571,
"threads": 1
}
+58
View File
@@ -0,0 +1,58 @@
"""Build a pinned isolated native worker; development VM builds use --backend cpu."""
import argparse
import os
from pathlib import Path
import shutil
import subprocess
import sys
ROOT = Path(__file__).resolve().parents[2]
TAG = "b11461"
COMMIT = "4d756bc72bf00a4aacf410ae15a2d315f3db400d"
def run(arguments, cwd=None):
subprocess.run([str(value) for value in arguments], cwd=cwd, check=True)
def main():
parser = argparse.ArgumentParser(description="Build Searchi's pinned Google embedding worker")
parser.add_argument("--backend", choices=["cpu", "auto", "metal", "vulkan", "cuda"], default="cpu")
parser.add_argument("--jobs", type=int, default=2)
args = parser.parse_args()
if not 1 <= args.jobs <= 16:
parser.error("jobs must be 1–16")
source = ROOT / ".searchi/references/llama.cpp"
build = ROOT / ".searchi/runtime" / args.backend
if not source.exists():
source.parent.mkdir(parents=True, exist_ok=True)
run(["git", "clone", "--depth", "1", "--branch", TAG,
"https://github.com/ggml-org/llama.cpp.git", source])
actual = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=source, text=True).strip()
if actual != COMMIT:
raise RuntimeError("Runtime reference checkout differs from pinned commit; refusing to reset it")
patch = Path(__file__).with_name("no-logits.patch")
check = subprocess.run(["git", "apply", "--check", str(patch)], cwd=source, capture_output=True)
if check.returncode == 0:
run(["git", "apply", patch], cwd=source)
else:
reverse = subprocess.run(["git", "apply", "--reverse", "--check", str(patch)], cwd=source, capture_output=True)
if reverse.returncode:
raise RuntimeError("Runtime memory patch cannot be applied or verified")
cmake_exe = shutil.which("cmake")
if not cmake_exe:
try:
import cmake
cmake_exe = str(Path(cmake.CMAKE_BIN_DIR) / ("cmake.exe" if os.name == "nt" else "cmake"))
except ImportError as error:
raise RuntimeError("Install CMake first (python -m pip install cmake)") from error
worker_source = ROOT / "crates/searchi-semantic/native"
run([cmake_exe, "-S", worker_source, "-B", build,
"-DCMAKE_BUILD_TYPE=Release", f"-DLLAMA_CPP_DIR={source}",
f"-DSEARCHI_ACCELERATION={args.backend}"])
run([cmake_exe, "--build", build, "--config", "Release", "--parallel", args.jobs])
print(f"Runtime build complete: {build}")
if __name__ == "__main__":
main()
+54
View File
@@ -0,0 +1,54 @@
"""Explicitly provision the pinned Google model; search never calls this script."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import urllib.request
def verified(path, spec):
if not path.is_file() or path.stat().st_size != spec["bytes"]:
return False
with path.open("rb") as source:
return hashlib.file_digest(source, "sha256").hexdigest() == spec["sha256"]
def main():
parser = argparse.ArgumentParser(description="Download the verified Google EmbeddingGemma 2 Q8 model (865 MB)")
parser.add_argument("--model-dir", type=Path, default=Path(".searchi/models/embeddinggemma2"))
parser.add_argument("--text-only", action="store_true", help="Download only the 310 MB text backbone")
args = parser.parse_args()
manifest = json.loads(Path(__file__).with_name("model-manifest.json").read_text())
for spec in manifest["files"][:1] if args.text_only else manifest["files"]:
destination = args.model_dir / spec["path"]
if verified(destination, spec):
print(f"Verified {spec['path']}", flush=True)
continue
destination.parent.mkdir(parents=True, exist_ok=True)
temporary = destination.with_suffix(".part")
url = f"https://huggingface.co/{manifest['export_repository']}/resolve/{manifest['revision']}/{spec['path']}"
try:
request = urllib.request.Request(url, headers={"User-Agent": "Searchi/0.1"})
print(f"Downloading {spec['path']} ({spec['bytes']} bytes)", flush=True)
with urllib.request.urlopen(request, timeout=60) as response, temporary.open("wb") as output:
total = 0
last_report = 0
while chunk := response.read(1024 * 1024):
total += len(chunk)
if total > spec["bytes"]:
raise ValueError("Model download exceeds pinned size")
output.write(chunk)
if total - last_report >= 50 * 1024 * 1024:
print(f"Downloaded {total}/{spec['bytes']} bytes", flush=True)
last_report = total
if not verified(temporary, spec):
raise ValueError("Model size or SHA256 mismatch")
os.replace(temporary, destination)
print(f"Verified SHA256: {spec['sha256']}", flush=True)
finally:
temporary.unlink(missing_ok=True)
if __name__ == "__main__":
main()
+15
View File
@@ -0,0 +1,15 @@
{
"model_id": "google/embeddinggemma-2",
"export_repository": "ggml-org/embeddinggemma-2-GGUF",
"revision": "bfcd298762cc34d0357ece5ebdd31791a3a374d8",
"dimensions": 768,
"quantization": "Q8_0",
"license": "Apache-2.0",
"runtime_tag": "b11461",
"query_prefix": "task: search result | query: ",
"document_template": "title: {title} | text: {text}",
"files": [
{"path":"embeddinggemma-2-Q8_0.gguf","bytes":309855456,"sha256":"2188ac1deca4b77dffefd603c2776a9d76d9d74ec01841392982ebb840b09135"},
{"path":"mmproj-embeddinggemma-2-Q8_0.gguf","bytes":554821024,"sha256":"c4a8a52691ecef40618438928bdf9e68379b854e24166f292592353db0aab64f"}
]
}
+12
View File
@@ -0,0 +1,12 @@
diff --git a/src/llama-context.cpp b/src/llama-context.cpp
--- a/src/llama-context.cpp
+++ b/src/llama-context.cpp
@@ -2133,7 +2133,7 @@ uint32_t llama_context::output_reserve(int32_t n_outputs) {
const auto n_embd = hparams.n_embd;
const auto n_embd_out = hparams.n_embd_out();
- bool has_logits = true;
+ bool has_logits = !(cparams.embeddings && !cparams.causal_attn);
bool has_embd = cparams.embeddings;
bool has_embd_nextn = cparams.embeddings_nextn;
+45
View File
@@ -0,0 +1,45 @@
"""Stage the native worker for installers; optional offline model bundle."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import shutil
ROOT = Path(__file__).resolve().parents[2]
def main():
parser = argparse.ArgumentParser(description="Stage Searchi runtime resources")
parser.add_argument("--backend", choices=["cpu", "auto", "metal", "vulkan", "cuda"], default="cpu")
parser.add_argument("--bundle-model", action="store_true")
args = parser.parse_args()
name = "searchi-gemma-worker.exe" if os.name == "nt" else "searchi-gemma-worker"
source = ROOT / ".searchi/runtime" / args.backend
if os.name == "nt":
source /= "Release"
source /= name
if not source.is_file():
raise RuntimeError("Build the pinned native worker before staging")
destination = ROOT / "apps/desktop/src-tauri/resources"
destination.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, destination / name)
# Include the upstream runtime license with the statically linked helper.
shutil.copy2(ROOT / ".searchi/references/llama.cpp/LICENSE", destination / "LLAMA-LICENSE.txt")
if args.bundle_model:
manifest = json.loads(Path(__file__).with_name("model-manifest.json").read_text())
model_destination = destination / "embeddinggemma2"
model_destination.mkdir(exist_ok=True)
for spec in manifest["files"]:
artifact = ROOT / ".searchi/models/embeddinggemma2" / spec["path"]
if artifact.stat().st_size != spec["bytes"]:
raise RuntimeError("Model size mismatch")
with artifact.open("rb") as stream:
if hashlib.file_digest(stream, "sha256").hexdigest() != spec["sha256"]:
raise RuntimeError("Model checksum mismatch")
shutil.copy2(artifact, model_destination / spec["path"])
print(f"Staged native worker: {destination / name}")
if __name__ == "__main__":
main()
+160
View File
@@ -0,0 +1,160 @@
# CPU semantic inference proof
**Historical experiment, not the accepted Searchi backend.** The user requires
Google EmbeddingGemma 2 as used by DigUp. MiniLM results below establish only
runtime plumbing and must not be presented as completed product semantic search.
The implementation is being corrected to use the required Google model.
This milestone runs genuine neural embeddings locally. It does not yet connect
semantic vectors to the SQLite search index or desktop interface. Production
retrieval quality, large-corpus performance, and Linux/macOS runtime packaging
remain unverified.
## Model and runtime decision
The first working baseline is the quantized ONNX export of
`sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2`, published by
`Xenova`. The upstream model card describes 384-dimensional text embeddings,
attention-mask mean pooling, a 128-token maximum, and Apache-2.0 licensing.
It covers multiple languages. This is a text-only baseline; it does not claim
EmbeddingGemma 2's multimodal features or compatibility.
Sources reviewed on 2026-10-12:
- [Original model card and pooling architecture](https://huggingface.co/sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2)
- [ONNX export publisher](https://huggingface.co/Xenova/paraphrase-multilingual-MiniLM-L12-v2)
- [ONNX Runtime Python session API](https://onnxruntime.ai/docs/api/python/api_summary.html)
- [Rust ort dynamic-library initialization](https://docs.rs/ort/2.0.0-rc.13/ort/environment/fn.init_from.html)
- [Rust ort session configuration](https://docs.rs/ort/2.0.0-rc.13/ort/session/builder/struct.SessionBuilder.html)
Inference explicitly selects `CPUExecutionProvider`. Python uses the CPU
`onnxruntime` wheel, never `onnxruntime-gpu`. The native Rust crate disables ort
default features: there are no binary downloads, GPU provider features, or model
HTTP client features. It loads an explicitly supplied local runtime library and
disables runtime telemetry. Tokenization is sequential; ONNX sessions use
sequential graph execution with bounded intra-op threads and one inter-op thread.
## Reproduce on Windows
Run these commands from the repository root. Provisioning commands intentionally
use the network; the inference command does not provision anything.
```powershell
python -m venv tools/semantic-proof/.venv
& tools/semantic-proof/.venv/Scripts/python.exe -m pip install --only-binary=:all: -r tools/semantic-proof/requirements.txt
& tools/semantic-proof/.venv/Scripts/python.exe tools/semantic-proof/download.py
& tools/semantic-proof/.venv/Scripts/python.exe tools/semantic-proof/proof.py --threads 1 --output tools/semantic-proof/windows-cpu-result.json
```
On Linux/macOS, use `.venv/bin/python` instead of `.venv/Scripts/python.exe`.
These instructions have only been executed on Windows. Wheel availability and
native library distribution must be checked on other platforms.
`download.py` downloads exactly two revision-pinned artifacts, streams them to a
temporary file, rejects oversized downloads, checks byte length and SHA256, then
atomically replaces the local asset. Assets occupy approximately 136 MB in
decimal units and are ignored by Git. Inference validates both files again and
fails clearly when either is missing or damaged. It never contacts the model
hub. The Python proof sets offline environment flags and rejects Python socket
connect, name-resolution, and bind audit events throughout import and inference.
This guard checks Python socket activity; it is not an operating-system firewall
against arbitrary native libraries.
The proof intentionally exits nonzero if any fixture query misses top-1. Its
JSON report is written before the quality gate, preserving failures for review.
## Pinned artifacts and licensing
Revision: `2c4055b12046f11709e9df2c122e59ffbdc2f900`.
The manifest in `tools/semantic-proof/model-manifest.json` records the same
model contract and hashes used by the native adapter.
| Artifact | Bytes | SHA256 |
| --- | ---: | --- |
| `onnx/model_quantized.onnx` | 118,308,126 | `66fc00f5f29afcaff34092e1bdd20008ca3918265a82fb9695a551e510cc4ebc` |
| `tokenizer.json` | 17,082,913 | `b60b6b43406a48bf3638526314f3d232d97058bc93472ff2de930d43686fa441` |
The base model card specifies Apache-2.0. The export card identifies that base
model but does not display its own license declaration. Before redistributing
the ONNX export in installers, confirm export licensing and preserve the base
license, attribution, and applicable notices. Current code explicitly downloads
user-local assets and does not ship model weights or runtime DLLs in Git.
ONNX Runtime uses MIT licensing; ort uses MIT or Apache-2.0 and tokenizers uses
Apache-2.0. Release packaging must retain third-party notices and validate
platform-specific runtime hashes independently; a model hash does not attest
to the runtime library.
The tested Windows ONNX Runtime 1.31.0 DLL is 19,288,888 bytes with SHA256
`90497e3d406c09f4d59d364c696a2f93dc02f6ef13741b3b89eec95bdb4a3806`.
The adapter currently trusts the explicitly supplied runtime path; it validates
model hashes, not runtime-library hashes. A release distribution must add a
platform runtime manifest before shipping. `requirements-lock-windows.txt`
captures the exact Python environment used for this proof.
## Measured Windows VM result
`tools/semantic-proof/windows-cpu-result.json` contains the full measurement and
rankings. The run used Windows 11 AMD64, Python 3.14.8, ONNX Runtime 1.31.0,
four visible logical CPUs, and **one inference thread with only the CPU provider**.
| Metric | Measurement |
| --- | ---: |
| Fixture documents / queries | 8 / 6 |
| Timed warm query runs | 60 |
| Artifact verification plus runtime/model load | approximately 3.4 seconds |
| Corpus batch embedding | approximately 145 ms |
| Warm query p50 / p95 | approximately 3.1 / 11.9 ms |
| Top-1 accuracy | 5/6 (83.3%) |
| Recall at 3 | 6/6 (100%) |
The corpus includes English and German documents, same-language semantic
queries, and cross-language queries. A question about a postponed product
release ranks the supplier contract above the expected meeting notes. That
failure remains in the fixture and blocks the strict top-1 quality gate.
This small demonstration supports the feasibility of CPU inference; it does
not establish large-corpus latency, robust multilingual quality, or production
readiness. Quantization and batch shape can affect similarity scores.
## Native integration contract
`searchi-semantic` exposes:
```rust
CpuEmbedder::load(model_dir, runtime_library, threads) -> Result<CpuEmbedder>
CpuEmbedder::encode(&mut self, texts: &[&str]) -> Result<Vec<Vec<f32>>>
DIMENSIONS // 384
MODEL_FINGERPRINT // revision + quantization + pooling + normalization + token cap
```
Inputs are bounded to eight texts per batch, 64 KiB UTF-8 per text, and 128
tokens. Outputs are finite, normalized `f32` vectors. The runtime library is
process-global; adapter initialization rejects attempts to switch libraries or
silently accept an environment initialized outside the adapter. Supply a known,
trusted runtime before calling any other ort APIs in the process.
Default unit tests need no model or runtime. The real-artifact test is opt-in:
```powershell
$env:SEARCHI_MODEL_DIR = (Resolve-Path tools/semantic-proof/model).Path
$env:SEARCHI_ORT_LIBRARY = (Resolve-Path tools/semantic-proof/.venv/Lib/site-packages/onnxruntime/capi/onnxruntime.dll).Path
cargo test -p searchi-semantic --test cpu_model -- --ignored --nocapture
```
The smoke test checks actual local inference, vector normalization, English and
German retrieval, recall at three, and the measured five-of-six top-1 baseline.
It does not replace the strict production quality gate.
The native real-artifact smoke was executed successfully on the Windows VM,
using that CPU-only DLL. It reproduced five-of-six top-1 and six-of-six recall
at three. Three default unit tests passed; targeted Clippy passed with warnings
denied. Linux and macOS native checks have not been executed.
The next integration should read bounded `SearchIndex::passage_batch` rows,
split long passages into overlapping tokenizer windows, preserve source page or
paragraph locations, and persist each vector with a content fingerprint and
`MODEL_FINGERPRINT`. Passage IDs are not immutable content identities: changes
must invalidate stale vectors. Queries should combine lexical and semantic
retrieval while preserving exact filename priority. Model upgrades build a
separate vector generation before an atomic swap. Cancellation, persistent
embedding jobs, ANN retrieval, representative quality corpora, and signed
cross-platform runtime packaging are still required.