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
27 changes: 27 additions & 0 deletions AGENT_DIARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,33 @@
- *nit 4–6 исправлены в том же проходе:* `None`/отрицательные счётчики теперь
fail-open («неизвестно» ≠ «отсутствует»), мёртвая ветка убрана.
- P5 снят и записан до мержа: requests 10→12/25 при планке ≥10.
- **Dense-сборка 37 мин: найдена причина (2026-11-12, `a25627a`)**
- *Симптом:* 7 307 чанков за 2 210.8 s (3.3 ch/s), время не зависит от длины
текста. Батч из 32 коротких текстов стоил столько же (7.9 s), сколько из 32
длинных (6.2 s).
- *Корень:* `onnx_server.py` настраивал токенизатор на `enable_padding(length=512)`
и оба пути (`_embed_single`, `_embed_batch`) добивали каждый вход до 512;
короткий чанк платил за 512 токенов. Замер прямой сессии: seq=32 → 190.7 ch/s,
seq=512 → 5.1 ch/s (37x).
- *Корпус:* 2.14 M токенов, медиана 287, p99 554; фиксированный padding —
3.74 M токенов, по-батчевый (сортировка по длине уже есть) — 2.18 M.
- *Fix:* padding до самого длинного текста В БАТЧЕ, truncation 512 сохранён;
`ONNX_FIXED_PADDING=512` возвращает старое поведение для совместимости.
Guard: `tests/test_onnx_dynamic_padding.py` (3 теста).
- *Замер после:* короткий батч 0.06 s (464 ch/s), средний 0.63 s (50 ch/s),
длинный без изменений 6.16 s. Полная пересборка: **7 419 чанков за 1 014.7 s**
(было 2 210.8 s; «до» шло частично под параллельным pytest — сравнение не
чистое, чистый эффект подтверждён коротким батчем и оценкой 2.13x).
- *Корректность:* векторы не бит-в-бит (косинус p50 0.998, p5 0.996, min 0.863
на 7 059 одинаковых чанках); на одном индексе top-1 совпал 14/15. Полный
quality-panel не гонялся — записано как условие перед включением по умолчанию;
dense `smoke_e2e` PASSED (шаг 4 = vector, onnx, 384).
- *Quality-panel (48 замороженных запросов, тот же hybrid-путь):* control
hit@1/5/MRR = 0.562/0.688/0.601, treatment = 0.583/0.688/0.617; парно
+1/−0, 0/0 (33/48 в обоих), +2/−0; p50 274→258 ms. Ни один запрос не стал
хуже → на этой панели фикс не ухудшает качество (n=48 — не общий вывод).
- *Отчёт:* `experiments/onnx_dense_2026_11/REPORT.md` (+ `quality_panel.py`,
`results_control.json`, `results_treatment.json`).
- **После мержей 2026-11-12: main собран, доки проверены, аудит чист**
- *Мержи (по указанному порядку):* #116 → `a5135d0`, #115 → `83229e1`,
#117 → `183a5f0`, #118 → `8149cb2`, #120 → `64341de`, #121 → `6f60c51`,
Expand Down
1 change: 1 addition & 0 deletions docs/en/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ path and LSP.
| `EMBEDDING_MODEL` / `MODEL_NAME` / `EMBEDDING_DIMENSION` | `qwen3-embedding` / `multilingual-e5-small-int8` / `384` | Provider model selection |
| `MSCODEBASE_EMBEDDER_PROVIDER` | `auto` | Pin the embedder (`onnx`, `llama_cpp`, `lm_studio`, `ollama`); external endpoints are only used when named |
| `MSCODEBASE_ONNX_PROVIDER` | — | Force a specific ONNX execution provider |
| `ONNX_FIXED_PADDING` | `0` | `512` restores the legacy fixed-width ONNX inputs (old-index parity); `0` pads per batch (faster) |
| `MSCODEBASE_REMOTE_TOKEN` / `_REMOTE_RATE_LIMIT_RPS` | — | Remote provider auth and rate limit |
| `MSCODEBASE_LANGUAGE_PACK` | `false` | Enable tree-sitter-language-pack (downloads parsers on first use) |

Expand Down
1 change: 1 addition & 0 deletions docs/ru/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ dense-пути и LSP.
| `EMBEDDING_MODEL` / `MODEL_NAME` / `EMBEDDING_DIMENSION` | `qwen3-embedding` / `multilingual-e5-small-int8` / `384` | Выбор модели провайдера |
| `MSCODEBASE_EMBEDDER_PROVIDER` | `auto` | Закрепить эмбеддер (`onnx`, `llama_cpp`, `lm_studio`, `ollama`); внешние эндпоинты используются только по имени |
| `MSCODEBASE_ONNX_PROVIDER` | — | Принудительно задать конкретный execution provider ONNX |
| `ONNX_FIXED_PADDING` | `0` | `512` возвращает прежнюю фиксированную ширину входов ONNX (совместимость со старым индексом); `0` — padding по батчу (быстрее) |
| `MSCODEBASE_REMOTE_TOKEN` / `_REMOTE_RATE_LIMIT_RPS` | — | Аутентификация и лимит частоты для удалённого провайдера |
| `MSCODEBASE_LANGUAGE_PACK` | `false` | Включить tree-sitter-language-pack (скачивает парсеры при первом использовании) |

Expand Down
1 change: 1 addition & 0 deletions docs/zh/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
| `EMBEDDING_MODEL` / `MODEL_NAME` / `EMBEDDING_DIMENSION` | `qwen3-embedding` / `multilingual-e5-small-int8` / `384` | provider 模型选择 |
| `MSCODEBASE_EMBEDDER_PROVIDER` | `auto` | 固定 embedder(`onnx`、`llama_cpp`、`lm_studio`、`ollama`);外部端点仅在显式指定时使用 |
| `MSCODEBASE_ONNX_PROVIDER` | — | 强制指定 ONNX 执行 provider |
| `ONNX_FIXED_PADDING` | `0` | `512` 恢复旧的固定宽度 ONNX 输入(旧索引兼容);`0` 按批次填充(更快) |
| `MSCODEBASE_REMOTE_TOKEN` / `_REMOTE_RATE_LIMIT_RPS` | — | 远程 provider 认证与速率限制 |
| `MSCODEBASE_LANGUAGE_PACK` | `false` | 启用 tree-sitter-language-pack(首次使用时下载 parser) |

Expand Down
127 changes: 127 additions & 0 deletions experiments/onnx_dense_2026_11/REPORT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# ONNX dense build: where the 37 minutes went

- Date: 2026-11-12
- Base: `main` @ `a25627a`
- Machine: 12 logical CPUs, Windows
- Provider: `onnx:multilingual-e5-small-int8`, output 384-d
- Trigger: the dense build of this repository took **2 210.8 s for 7 307 chunks**
(3.3 chunks/s) and was recorded as "not optimised".

## Question

Is the cost in the indexer, the HTTP layer, or the model — and is it fixable
without changing the answer?

## Measurements

### 1. Inference scales with sequence length (direct session, batch=32)

```
seq= 32 : 0.17 s 190.7 ch/s 6 103 tok/s
seq=128 : 0.86 s 37.1 ch/s 4 747 tok/s
seq=256 : 2.30 s 13.9 ch/s 3 569 tok/s
seq=512 : 6.33 s 5.1 ch/s 2 587 tok/s
```

The model is healthy: shorter inputs are up to 37x cheaper. Therefore a build
that costs the same for every chunk is padding every chunk to 512.

### 2. Before the fix, batch time did not depend on text length

```
32 x short (~30 chars) 7.93 s 4.0 ch/s
32 x medium (~300 chars) 6.63 s 4.8 ch/s
32 x long (~2000 chars) 6.21 s 5.2 ch/s
batch 8 / 16 / 32 / 64 4.2 / 5.1 / 5.2 / 4.6 ch/s (no batch-size win)
```

Root cause in `src/core/embedder/onnx_server.py`: the tokenizer was configured
with `enable_padding(length=512)` and both `_embed_single` and `_embed_batch`
padded every input to `max_length` (512) before `session.run`. A 31-token chunk
paid for 512 tokens.

### 3. What the real corpus looks like (7 307 chunks)

```
total tokens 2 144 633
tokens min/median/p90/p99/max = 31 / 287 / 400 / 554 / 971
fixed 512 padding 3 741 184 padded tokens
dynamic, sorted batches ~2.18 M padded tokens
estimated embedding time: 1 450 s -> 679 s (2.13x)
```

The indexer already sorts chunks by length (`index_project_runner.py`,
`_flat_chunks.sort(key=lambda x: len(x[1]))`), so a per-batch maximum is very
close to the true lengths.

### 4. After the fix (per-batch padding)

```
32 x short 0.06 s 464 ch/s
32 x medium 0.63 s 50 ch/s
32 x long 6.16 s 5.2 ch/s (unchanged, as expected)
```

Full rebuild of the same repository (fresh data root):

```
chunks 7 419 (main gained files since the first build)
wall 1 014.7 s vs 2 210.8 s before (2.18x)
```

Caveat: the "before" build ran with a full pytest suite in parallel for part of
its duration, so 2.18x is the wall-clock delta on this machine, not a clean A/B.
The clean evidence for the padding effect is the 0.06 s vs ~6.4 s short-batch
measurement and the 2.13x estimate on the corpus.

## Quality panel (frozen 48-query set)

Control: index built with fixed 512 padding, queries padded to 512.
Treatment: index built with per-batch padding, queries dynamic.
Both run the same hybrid `quality` path and the same text index; only the vector
table and the query padding differ.

```
control n=48 hit@1=0.562 hit@5=0.688 MRR=0.601 p50=274ms
treatment n=48 hit@1=0.583 hit@5=0.688 MRR=0.617 p50=258ms
paired: hit@1 +1/-0, hit@5 0/0 (33/48 both), MRR +2/-0
```

No query became worse; one moved into the top-1 and two improved in rank. The
panel is small, so this is "non-inferior on the frozen panel", not a general
quality claim. Command: `experiments/onnx_dense_2026_11/quality_panel.py
--arm control|treatment` with the matching `MSCODEBASE_DATA_DIR`.

## Correctness

Per-batch padding changes the tensor width, so quantised inference is not
bit-identical to fixed 512:

- same text, direct session: cosine 0.996 (12 tokens) / 0.998 (93 tokens);
- 7 059 identical chunks in two indexes: p50 0.998, p5 0.996, p1 0.925,
min 0.863;
- same index, fixed-512 query vs dynamic query: top-1 file identical 14/15,
overlap@5 = 0.48 (vector-only top-5 is unstable on adjacent chunks).

No retrieval-quality panel was run. `ONNX_FIXED_PADDING=512` restores the old
behaviour for deployments that must match an existing index exactly.

## Fix

- `src/core/embedder/onnx_server.py`: pad to the longest text **in the batch**;
keep truncation at 512; `ONNX_FIXED_PADDING` (default 0) restores fixed width.
- Guard: `tests/test_onnx_dynamic_padding.py` (3 cases: per-batch max, truncation,
legacy toggle).
- Verified: dense `smoke_e2e` PASSED (4 checks, step 4 = vector, provider=onnx,
dim=384, results=3).

## Follow-ups

1. ~~Run the search-quality panel on a dynamic-padding index before making it
the only mode~~ — done: hit@5 33/48 in both arms, no losing query.
2. Warm/cold build caching: a full rebuild still recomputes every chunk; the
incremental path already skips unchanged files.
3. Consider length-bucketed batching in the indexer instead of sorting by chars
(token-aware), which would tighten batch maxima further.
4. The 48-query panel is small; re-run `experiments/bench_2026_11` on a larger
corpus before removing `ONNX_FIXED_PADDING` from the compatibility surface.
187 changes: 187 additions & 0 deletions experiments/onnx_dense_2026_11/quality_panel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
r"""Quality panel for the ONNX dynamic-padding change (frozen 48-query panel).

Control: dense index built with fixed-512 padding (`g4data_onnx`).
Treatment: dense index built with per-batch padding (`g4data_dyn`).
Same frozen queries, same hybrid `quality` path, same text index; only the
vector table and the query embedding mode differ.

Usage (from the repository root):
$env:MSCODEBASE_DATA_DIR='...\g4data_onnx'
$env:MSCODEBASE_DENSE_OFF='false'
$env:MSCODEBASE_EMBEDDER_PROVIDER='onnx'
python experiments/onnx_dense_2026_11/quality_panel.py --arm control
"""

from __future__ import annotations

import argparse
import json
import os
import statistics
import sys
import time
from pathlib import Path

if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")

ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
FROZEN = ROOT / "experiments" / "e57_auto_intent" / "frozen" / "QUERIES.jsonl"
OUT = Path(__file__).resolve().parent


class FakeIndexer:
"""Minimal indexer surface Searcher needs for the hybrid path."""

def __init__(self, project_path: Path):
self.project_path = project_path
self.db_manager = None
self.table = object()
self._symbol_index = None

async def search_async(self, query_vector, limit: int = 5, filter_expr: str = ""):
"""Vector search against the data root in MSCODEBASE_DATA_DIR."""
import lancedb

from src.core.artifact_paths import get_db_path

db = lancedb.connect(str(get_db_path(self.project_path)))
table = db.open_table("codebase_chunks")
builder = table.search(query_vector, vector_column_name="vector")
df = builder.limit(limit).to_pandas()
out = []
for _, row in df.iterrows():
out.append(
{
"text": row["text"],
"text_full": row.get("text_full", row["text"]),
"metadata": {
"file": row["file_path"],
"chunk_index": row["chunk_index"],
"indexed_at": row.get("indexed_at", ""),
"layer": row.get("layer", ""),
"hierarchy_level": row.get("hierarchy_level", ""),
"parent_id": row.get("parent_id", ""),
"start_line": row.get("start_line", 0),
"end_line": row.get("end_line", 0),
"symbol_type": row.get("symbol_type", ""),
"module_name": row.get("module_name", ""),
},
}
)
return out


def _files(rows):
return [
str((r.get("metadata") or {}).get("file") or "").replace("\\", "/")
for r in rows
]


def _hit(files, gold: str, k: int) -> bool:
return any(f == gold or f.endswith(gold) for f in files[:k])


def _mrr(files, gold: str) -> float:
for rank, f in enumerate(files, 1):
if f == gold or f.endswith(gold):
return 1.0 / rank
return 0.0


def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--arm", required=True, help="control | treatment label")
args = parser.parse_args()

if os.getenv("MSCODEBASE_DENSE_OFF", "true").lower() == "true":
print("REFUSING: MSCODEBASE_DENSE_OFF=true — no vector table would be used")
return 2
if os.getenv("MSCODEBASE_EMBEDDER_PROVIDER", "").lower() != "onnx":
print("REFUSING: pin MSCODEBASE_EMBEDDER_PROVIDER=onnx")
return 2

queries = [
json.loads(line)
for line in FROZEN.read_text(encoding="utf-8").splitlines()
if line.strip()
]

from src.core.search.engine import Searcher
from src.core.search.scoring import auto_detect_intent
from src.providers.embedder.remote_embedder import RemoteEmbedder

embedder = RemoteEmbedder()
deadline = time.time() + 120
while time.time() < deadline and not embedder.is_ready():
time.sleep(2)
if not embedder.is_ready():
print(f"REFUSING: embedder not ready (mode={getattr(embedder, 'mode', '?')})")
return 2
print(f"embedder: {embedder.provider_name} dim={embedder.embedding_dim}")

searcher = Searcher(FakeIndexer(ROOT), embedder)
searcher._ondemand_rerank = False
searcher._multi_reranker_initialized = True
searcher._multi_reranker = None
searcher._demote_non_code = True
searcher._demote_intent_aware = False
searcher._p3_anchor = False

rows, base_h1, base_h5, mrr, lat = [], [], [], [], []
for q in queries:
intent = auto_detect_intent(q["query"])
t0 = time.perf_counter()
code = searcher.search_with_mode(q["query"], mode="quality", limit=5)
t_code = (time.perf_counter() - t0) * 1000
code_files = _files(code.get("results", []) if isinstance(code, dict) else [])

t0 = time.perf_counter()
docs_files = _files(searcher.search_docs(q["query"], limit=5))
t_docs = (time.perf_counter() - t0) * 1000

auto_files = docs_files if intent == "docs" else code_files
gold = q["gold_file"]
base_h1.append(_hit(auto_files, gold, 1))
base_h5.append(_hit(auto_files, gold, 5))
mrr.append(_mrr(auto_files, gold))
lat.append(t_docs if intent == "docs" else t_code)
rows.append(
{
"id": q["id"],
"gold": gold,
"intent": intent,
"auto": auto_files,
"h1": base_h1[-1],
"h5": base_h5[-1],
"mrr": mrr[-1],
}
)

n = len(queries)
result = {
"arm": args.arm,
"n": n,
"data_dir": os.getenv("MSCODEBASE_DATA_DIR", ""),
"hit@1": sum(base_h1) / n,
"hit@5": sum(base_h5) / n,
"mrr": sum(mrr) / n,
"p50_ms": statistics.median(lat),
"rows": rows,
}
print(
f"{args.arm:<10} n={n} hit@1={result['hit@1']:.3f} "
f"hit@5={result['hit@5']:.3f} MRR={result['mrr']:.3f} "
f"p50={result['p50_ms']:.0f}ms"
)
(OUT / f"results_{args.arm}.json").write_text(
json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8"
)
return 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading