Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Changelog

## 1.0.1 — 2026-09-23

### Changed

- Local vector search is now off by default. Existing `concept` and `hybrid` calls require `"vectorSearchEnabled": true` in the active `sift-light.json` and an installed model; otherwise they fail explicitly. Ordinary search remains available. Installing the model or enabling the optional Jev judge alone does not opt in.

### Fixed

- Improve semantic ranking when very short passages compete with more detailed source evidence. Raw cosine similarity remains available alongside the ranking score; results are still relevance candidates, not verified behavior.
- Give `files` requests that mistakenly use a plain `pattern` a complete, scope-preserving `query` recovery request. Reduce repeated request-error text.
- Condense hybrid and Concept diagnostics in model-facing output while keeping coverage, progress, recovery, and semantic-judge status visible.
- Ask the optional Jev semantic judge to classify each candidate by its own excerpt, avoiding a shared classification instruction across candidates.

### Performance and compatibility

- Use length-aware inference ordering, batches of four, four ONNX CPU threads, and a shorter token window to reduce cold semantic search time on the tested workloads. The changed windowing invalidates older embedding cache entries once; subsequent searches refill the cache.
- Cold semantic inference can still take tens of seconds and use more than 1 GiB of worker memory. The improvement is workload-dependent and is not a memory cap or a subsecond cold-start guarantee.
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,18 @@ Looking for an error message, a sentence or a name is like giving a librarian a

### Recover when the remembered wording is not exact

Use `mode: "hybrid"` with one natural-language `query` when a sentence may have been remembered with different wording. Hybrid always runs an exact literal search and the installed local Concept model under one owned request. Exact evidence appears first; semantic candidates are clearly labeled, ranked only by similarity and removed when they overlap an exact match. The initial page shares counts, coverage, source references, one inspection cursor and a compact preview instead of concatenating two complete responses. `conceptLimit` changes only the non-overlapping semantic supplement (default 3, maximum 20); it never displaces literal evidence. The returned matches request opens the same snapshot's complete exact-first pagination without rerunning either search.
For routine development work, searches with a known name, symbol, filename or error text use the fast exact path by default; omitting `mode` does not load the local embedding model. Vector search is disabled by default for Pi, OMP and MCP clients. `concept` and `hybrid` fail clearly while it is disabled; neither silently becomes an exact-only search. This behavior is built into the plugin and does not depend on a personal `AGENTS.md`. An uncached semantic search can take tens of seconds, so it should not be part of every code lookup.

To opt in, set `"vectorSearchEnabled": true` in the active `sift-light.json`, install the local model with `npx -y --package sift-light@latest sift-light-model --install-model`, and restart the host. Pi and OMP use the configuration paths below; MCP needs `SIFT_LIGHT_CONFIG` set to that file in its server environment. Installing the model alone does not enable vector search. `semanticJudge.enabled` is a separate option for remote candidate classification: it does not enable vector search and remains inactive while vector search is disabled.

Use `mode: "hybrid"` with one natural-language `query` when a sentence may have been remembered with different wording. Hybrid always runs an exact literal search and the installed local Concept model under one owned request. Exact evidence appears first; semantic candidates are clearly labeled as relevance candidates and removed when they overlap an exact match. Semantic candidates retain raw cosine scores and use a bounded short-passage correction for ranking; both scores remain visible in structured details and neither proves runtime behavior. The initial page shares counts, coverage, source references, one inspection cursor and a compact preview instead of concatenating two complete responses. `conceptLimit` changes only the non-overlapping semantic supplement (default 3, maximum 20); it never displaces literal evidence. The returned matches request opens the same snapshot's complete exact-first pagination without rerunning either search.

Concept ranking covers every UTF-8 passage admitted by the request's documented source budget; it no longer samples a fixed prefix of the scope. Concept and hybrid searches automatically admit up to 2,000 files and process them sequentially in bounded 200-file batches, then merge every batch into one global ranking and one coverage result. Batches share the request's 32 MiB read budget, so raising the file ceiling does not multiply the content budget. Users do not need to plan or continue batches themselves. `maxFilesToParse` remains an optional advanced hard ceiling when a smaller scope is intentional. Passages that exceed the model token window are ranked through overlapping token-safe windows, so later text is not silently discarded. Offline embeddings are cached by content, model revision and chunking revision in a bounded 512 MiB local cache. Repeated content is reused, changed content misses naturally, and cache write or cleanup failures remain visible in the result.

Slow Concept and hybrid requests return within the default five-second wait window with `status: "waiting"` or `"running"`, an `operationId`, progress and an exact `nextRequest` such as `{ "mode": "await", "operationId": "..." }`. Copy that request unchanged: it resumes the same computation and never restarts the query or downgrades to a literal-only result. A final result remains available for stable re-fetch for 10 minutes, with up to 32 terminal results retained per service session, and `mode: "cancel"` stops the owned work and waits for cleanup. Each service session admits at most eight pending operations; an operation has one total deadline controlled by `SIFT_LIGHT_CONCEPT_TIMEOUT_MS` (integer milliseconds from 1000 through 3600000; default 600000) and a 120-second idle continuation lease. A real model, source or resource failure is returned as a failure with its diagnostic. Source generation is re-enumerated and re-verified before publication, so changes refresh the operation and mixed versions are never marked complete. Admission planning counts (`filesEnumerated`, `filesAdmitted`, `filesSkippedEmpty`, `filesUnavailable`, `passagesQueued`, `batchesPlanned`, `batchesCompleted`) stay visible. Empty files are a normal skip and do not mark the result partial.

The first uncached Concept or hybrid search loads the local model and may take tens of seconds and more than 1 GiB of inference-worker memory, depending on the machine and search scope. Later searches reuse cached passage embeddings even when the query wording changes, although a new query still loads the model briefly to embed that query. Changed passages are recomputed; cache eviction, deletion or broad source changes can cause another cold run. Exact search modes do not load the model. These are workload-dependent observations, not a latency or memory guarantee. If the result is waiting during `model-loading`, follow its `nextRequest` to continue the same operation.

### Give several search conditions together

“Find files mentioning both the customer and a refund” works like selecting documents with two labels. “Any of these words will do” works like handing over a shortlist. Multiple conditions can be expressed together to reduce repeated searches.
Expand Down Expand Up @@ -120,6 +126,7 @@ The feature is enabled only when `semanticJudge.enabled` is explicitly set to `t
{
"locale": "en",
"enforceSearch": "hard",
"vectorSearchEnabled": false,
"semanticJudge": {
"enabled": false,
"provider": "jev",
Expand Down
9 changes: 8 additions & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,18 @@

### 记得不完全准确,也能一次找回

当一句话可能记错了措辞时,可以用一个自然语言 `query` 调用 `mode: "hybrid"`。Hybrid 会在同一个受控请求中始终执行精确字面搜索和已安装的本地 Concept 模型:精确证据固定排在前面,语义候选明确标注且只表示相似度,与精确命中范围重叠的候选会被去重。初始页面共享计数、覆盖状态、来源引用和一个检查游标,以紧凑预览代替拼接两份完整响应。`conceptLimit` 只调整不重叠的语义补充数量(默认 3,最大 20),不会挤占字面证据;返回的 matches 请求从同一个快照开始完整的精确优先分页,不会重新执行任一搜索。
日常开发时,已知名称、符号、文件名或报错文本的搜索默认走快速的精确路径;省略 `mode` 不会加载本地向量模型。Pi、OMP 和 MCP 的向量搜索默认关闭;关闭时 `concept` 和 `hybrid` 会明确报错,不会悄悄降级成只有精确结果。这是插件自身的行为,不依赖个人的 `AGENTS.md`。未缓存的语义搜索可能耗时数十秒,不应成为每次查代码的默认成本。

需要时,在当前生效的 `sift-light.json` 中设置 `"vectorSearchEnabled": true`,执行 `npx -y --package sift-light@latest sift-light-model --install-model` 安装本地模型,然后重启宿主。Pi 和 OMP 的配置路径见下文;MCP 需要在服务进程环境中用 `SIFT_LIGHT_CONFIG` 指向该文件。只安装模型不会自动启用向量搜索。`semanticJudge.enabled` 是远程候选分类的独立开关,不会启用向量搜索;向量关闭时它也不会运行。

当一句话可能记错了措辞时,可以用一个自然语言 `query` 调用 `mode: "hybrid"`。Hybrid 会在同一个受控请求中始终执行精确字面搜索和已安装的本地 Concept 模型:精确证据固定排在前面,语义候选明确标注且只表示相关性候选,与精确命中范围重叠的候选会被去重。语义候选保留原始余弦分数,并用有界短段修正分数排序;两种分数均在结构化详情中可见,不能当作运行时证明。初始页面共享计数、覆盖状态、来源引用和一个检查游标,以紧凑预览代替拼接两份完整响应。`conceptLimit` 只调整不重叠的语义补充数量(默认 3,最大 20),不会挤占字面证据;返回的 matches 请求从同一个快照开始完整的精确优先分页,不会重新执行任一搜索。

Concept 排名会覆盖请求所声明源码预算内接纳的全部 UTF-8 段落,不再固定抽取范围开头的一小部分。Concept 和 hybrid 默认会自动接纳最多 2,000 个文件,在内部按每批 200 个文件顺序处理,再合并成一次全局排名和一份覆盖结果。所有批次共享同一个请求的 32 MiB 读取预算,扩大文件上限不会把内容预算成倍放大。用户不需要自己计算或续接批次;只有确实想主动缩小范围时,才需要把 `maxFilesToParse` 作为可选的高级硬上限。超过模型 token 窗口的段落会拆成带重叠、且保证不截断的窗口参与排名,后半段内容不会被静默丢弃。离线 embedding 按内容、模型版本和分段版本缓存在本地,缓存上限为 512 MiB;重复内容直接复用,内容变化自然失效,缓存写入或清理失败会在结果中明确显示。

Concept 或 hybrid 较慢时,会在默认五秒等待窗口内返回 `status: "waiting"` 或 `"running"`、`operationId`、进度和精确的 `nextRequest`,例如 `{ "mode": "await", "operationId": "..." }`。请原样复制这个请求:它会续接同一个计算,不会重启查询,也不会降级成只有字面的结果。最终结果可稳定复取十分钟;每个服务会话最多保留 32 个终态结果。`mode: "cancel"` 会停止自有任务并等待清理完成。每个服务会话最多同时接纳八个 pending operation;单个 operation 使用 `SIFT_LIGHT_CONCEPT_TIMEOUT_MS` 指定一个总执行时限(整数毫秒,1000–3600000,默认 600000),另有 120 秒无人续接租期。真实模型、来源或资源故障会以明确失败返回。发布结果前会重新枚举并校验同一来源 generation;源文件变化会刷新 operation,混合版本不会被标成 complete。接纳计划计数(`filesEnumerated`、`filesAdmitted`、`filesSkippedEmpty`、`filesUnavailable`、`passagesQueued`、`batchesPlanned`、`batchesCompleted`)会在结果里明确显示。空文件属于正常跳过,不会把结果标成 partial。

首次运行尚未缓存的 Concept 或 hybrid 搜索时需要加载本地模型;耗时可能达到数十秒,推理 worker 内存也可能超过 1 GiB,具体取决于机器和搜索范围。后续搜索即使换了问题,也会复用已缓存的段落向量;新问题仍会短暂加载模型来计算问题向量。修改过的段落需要重算;缓存被清理、淘汰或大量源码变化时可能再次冷运行。精确搜索模式不会加载模型。这些都是随负载变化的观察,不是延迟或内存保证。结果在 `model-loading` 阶段等待时,请按返回的 `nextRequest` 续接同一个 operation。

### 几个条件,可以一起交代

“找同时提到客户和退款的文件”,就像请管理员挑出同时贴着两张标签的资料;“这几个词任意一个出现都算”,则像列出一张候选清单。可以一次表达多个查找条件,减少反复搜索。
Expand Down Expand Up @@ -120,6 +126,7 @@ Hybrid 搜索默认只使用本地能力。可选的语义判断器可以对保
{
"locale": "zh-CN",
"enforceSearch": "hard",
"vectorSearchEnabled": false,
"semanticJudge": {
"enabled": false,
"provider": "jev",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "sift-light",
"version": "1.0.0",
"version": "1.0.1",
"description": "Context-efficient local search for files, documents, notes and logs across Pi, OMP and MCP clients",
"keywords": [
"ai-agent",
Expand Down
2 changes: 1 addition & 1 deletion plugins/sift-light/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "sift-light",
"version": "1.0.0",
"version": "1.0.1",
"description": "Require sift-light for conventional local searches while keeping development tools available.",
"author": {
"name": "baoer"
Expand Down
2 changes: 1 addition & 1 deletion plugins/sift-light/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "sift-light",
"version": "1.0.0",
"version": "1.0.1",
"description": "Require sift-light for conventional local searches while keeping development tools available.",
"author": {
"name": "baoer"
Expand Down
2 changes: 1 addition & 1 deletion plugins/sift-light/.mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"args": [
"--yes",
"--package",
"sift-light-runtime@npm:sift-light@1.0.0",
"sift-light-runtime@npm:sift-light@1.0.1",
"sift-light-mcp",
"--stdio"
],
Expand Down
51 changes: 39 additions & 12 deletions plugins/sift-light/concept-worker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ class SiftLightError extends Error {
var CONCEPT_MODEL = "Xenova/multilingual-e5-small";
var CONCEPT_REVISION = "761b726dd34fb83930e26aab4e9ac3899aa1fa78";
var MAX_CONCEPT_CHARS = 1000;
var CONCEPT_MODEL_TOKENS = 512;
var CONCEPT_MODEL_TOKENS = 256;
var CONCEPT_WINDOW_OVERLAP_TOKENS = 64;
var CONCEPT_EMBEDDING_DIMENSIONS = 384;
var CONCEPT_CACHE_VERSION = 1;
var CONCEPT_CACHE_VERSION = 2;
var CONCEPT_CACHE_MAX_BYTES = 512 * 1024 * 1024;
var CONCEPT_TIMEOUT_MS = 10 * 60000;
var MAX_CONCEPT_TIMEOUT_MS = 60 * 60000;
Expand Down Expand Up @@ -222,7 +222,7 @@ async function enforceConceptCacheLimit(root, maximumBytes = CONCEPT_CACHE_MAX_B
}

// src/concept-inference.ts
var INFERENCE_BATCH_SIZE = 16;
var INFERENCE_BATCH_SIZE = 4;
function safeUtf16End(text, end) {
if (end <= 0 || end >= text.length)
return end;
Expand All @@ -247,6 +247,8 @@ function binarySearchBudget(span) {
return 2 * Math.max(span, 1) + 32;
}
function maximumTokenSafeEnd(extractor, prefix, text, start) {
if (tokenCount(extractor, `${prefix}${text.slice(start)}`) <= CONCEPT_MODEL_TOKENS)
return text.length;
let low = start + 1;
let high = text.length;
let accepted = start;
Expand Down Expand Up @@ -312,25 +314,49 @@ function tokenSafeWindows(extractor, pending) {
async function embedConceptInputs(extractor, pending, callbacks = {}) {
const layouts = pending.map((item) => tokenSafeWindows(extractor, item));
const inputs = layouts.flatMap((windows) => windows.map((window) => window.input));
const vectors = [];
const vectors = Array(inputs.length);
const layoutEnds = [];
const remainingWindows = layouts.map((windows) => windows.length);
let layoutEnd = 0;
for (const windows of layouts) {
layoutEnd += windows.length;
layoutEnds.push(layoutEnd);
}
const orderedInputs = inputs.map((input, index) => ({ input, index, tokens: tokenCount(extractor, input) })).toSorted((left, right) => right.tokens - left.tokens || left.index - right.index);
const vectorFor = (index) => {
const vector = vectors[index];
if (!vector)
throw new Error("Missing concept embedding vector");
return vector;
};
const ownerOf = (index) => {
let low = 0;
let high = layoutEnds.length;
while (low < high) {
const middle = Math.floor((low + high) / 2);
if (index < layoutEnds[middle])
high = middle;
else
low = middle + 1;
}
return low;
};
let completedEmbedding = 0;
for (let offset = 0;offset < inputs.length; offset += INFERENCE_BATCH_SIZE) {
const batch = inputs.slice(offset, offset + INFERENCE_BATCH_SIZE);
let completedWindows = 0;
for (let offset = 0;offset < orderedInputs.length; ) {
const orderedBatch = orderedInputs.slice(offset, offset + INFERENCE_BATCH_SIZE);
const batch = orderedBatch.map((item) => item.input);
const tensor = await extractor(batch, { pooling: "mean", normalize: true });
if (tensor.data.length !== batch.length * CONCEPT_EMBEDDING_DIMENSIONS)
throw new Error("Unexpected concept embedding dimensions");
for (let index = 0;index < batch.length; index += 1) {
const start = index * CONCEPT_EMBEDDING_DIMENSIONS;
vectors.push(Array.from(tensor.data.slice(start, start + CONCEPT_EMBEDDING_DIMENSIONS), Number));
const inputIndex = orderedBatch[index].index;
vectors[inputIndex] = Array.from(tensor.data.slice(start, start + CONCEPT_EMBEDDING_DIMENSIONS), Number);
remainingWindows[ownerOf(inputIndex)]--;
}
const completedWindows = Math.min(offset + batch.length, inputs.length);
while (layoutEnds[completedEmbedding] !== undefined && layoutEnds[completedEmbedding] <= completedWindows) {
completedWindows += batch.length;
while (remainingWindows[completedEmbedding] !== undefined && remainingWindows[completedEmbedding] === 0) {
const start = completedEmbedding === 0 ? 0 : layoutEnds[completedEmbedding - 1];
const item = pending[completedEmbedding];
if (!item)
Expand All @@ -340,20 +366,21 @@ async function embedConceptInputs(extractor, pending, callbacks = {}) {
windows: (layouts[completedEmbedding] ?? []).map((window, index) => ({
start: window.start,
end: window.end,
vector: vectors[start + index] ?? []
vector: vectorFor(start + index)
}))
}, completedEmbedding + 1, pending.length);
completedEmbedding += 1;
}
callbacks.onBatch?.(completedWindows, inputs.length);
offset += batch.length;
}
let vectorIndex = 0;
return pending.map((item, index) => ({
key: item.key,
windows: (layouts[index] ?? []).map((window) => ({
start: window.start,
end: window.end,
vector: vectors[vectorIndex++] ?? []
vector: vectorFor(vectorIndex++)
}))
}));
}
Expand Down Expand Up @@ -504,7 +531,7 @@ async function search() {
local_files_only: true,
dtype: "q8",
device: "cpu",
session_options: { intraOpNumThreads: 2, interOpNumThreads: 1 }
session_options: { intraOpNumThreads: 4, interOpNumThreads: 1 }
});
try {
created = await embedConceptInputs(extractor, missing, {
Expand Down
Loading
Loading