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
Binary file modified assets/images/speed_vs_ndcg_cold.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/images/speed_vs_ndcg_warm.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
71 changes: 43 additions & 28 deletions benchmarks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Quality and speed across all methods.
| CodeRankEmbed | 0.839 | 116 s | 16 ms |
| ColGREP | 0.693 | 5.4 s | 122 ms |
| BM25 | 0.673 | 47 ms | 0.17 ms |
| zvec-grep | 0.670 | 3.4 s | 391 ms |
| ck | 0.642 | 96 s | 187 ms |
| codebase-memory-mcp | 0.630 | 454 ms | 46 ms |
| grepai | 0.561 | 35 s | 48 ms |
Expand Down Expand Up @@ -71,28 +72,28 @@ semble returns the top-50 ranked chunks. `ripgrep+read` splits the query into ke

NDCG@10 per language, sorted by CodeRankEmbed (CRE in the table). Best score per row is bolded.

| Language | semble | CRE | ColGREP | ck | cbm | grepai | probe | cs | ripgrep |
| ----------- | --------: | --------: | --------: | --------: | --------: | --------: | --------: | --------: | --------: |
| javascript | 0.917 | **0.925** | 0.823 | 0.772 | 0.770 | 0.675 | 0.588 | 0.171 | 0.176 |
| scala | 0.909 | **0.925** | 0.765 | 0.717 | 0.704 | 0.330 | 0.392 | 0.111 | 0.180 |
| zig | **0.913** | 0.911 | 0.474 | 0.511 | 0.766 | 0.755 | 0.369 | 0.121 | 0.000 |
| ruby | **0.909** | 0.905 | 0.708 | 0.738 | 0.689 | 0.643 | 0.382 | 0.255 | 0.230 |
| cpp | **0.915** | 0.897 | 0.626 | 0.687 | 0.630 | 0.731 | 0.375 | 0.262 | 0.126 |
| elixir | **0.894** | 0.893 | 0.808 | 0.786 | 0.506 | 0.669 | 0.412 | 0.397 | 0.134 |
| python | 0.867 | **0.878** | 0.777 | 0.721 | 0.643 | 0.634 | 0.488 | 0.305 | 0.202 |
| csharp | **0.885** | 0.848 | 0.614 | 0.548 | 0.775 | 0.277 | 0.392 | 0.248 | 0.117 |
| php | **0.858** | 0.847 | 0.663 | 0.615 | 0.608 | 0.402 | 0.340 | 0.180 | 0.123 |
| swift | **0.860** | 0.845 | 0.710 | 0.672 | 0.630 | 0.429 | 0.280 | 0.151 | 0.160 |
| bash | 0.825 | **0.834** | 0.706 | 0.677 | 0.768 | 0.723 | 0.226 | 0.170 | 0.000 |
| lua | 0.823 | **0.829** | 0.798 | 0.738 | 0.591 | 0.699 | 0.336 | 0.050 | 0.000 |
| kotlin | 0.821 | **0.823** | 0.637 | 0.587 | 0.611 | 0.478 | 0.335 | 0.170 | 0.166 |
| haskell | 0.765 | **0.811** | 0.683 | 0.733 | 0.624 | 0.483 | 0.313 | 0.160 | 0.000 |
| java | **0.849** | 0.790 | 0.641 | 0.606 | 0.554 | 0.386 | 0.536 | 0.136 | 0.198 |
| c | 0.741 | **0.771** | 0.676 | 0.606 | 0.655 | 0.555 | 0.384 | 0.175 | 0.000 |
| rust | **0.856** | 0.754 | 0.662 | 0.419 | 0.454 | 0.519 | 0.242 | 0.193 | 0.162 |
| go | **0.895** | 0.713 | 0.785 | 0.458 | 0.506 | 0.722 | 0.410 | 0.183 | 0.133 |
| typescript | **0.706** | 0.671 | 0.430 | 0.456 | 0.455 | 0.394 | 0.354 | 0.145 | 0.128 |
| **overall** | **0.854** | 0.839 | **0.693** | **0.634** | **0.630** | **0.561** | **0.387** | **0.200** | **0.126** |
| Language | semble | CRE | ColGREP | zvec-grep | ck | cbm | grepai | probe | cs | ripgrep |
| ----------- | --------: | --------: | --------: | ---------: | --------: | --------: | --------: | --------: | --------: | --------: |
| javascript | 0.917 | **0.925** | 0.823 | 0.760 | 0.772 | 0.770 | 0.675 | 0.588 | 0.171 | 0.176 |
| scala | 0.909 | **0.925** | 0.765 | 0.799 | 0.717 | 0.704 | 0.330 | 0.392 | 0.111 | 0.180 |
| zig | **0.913** | 0.911 | 0.474 | 0.664 | 0.511 | 0.766 | 0.755 | 0.369 | 0.121 | 0.000 |
| ruby | **0.909** | 0.905 | 0.708 | 0.758 | 0.738 | 0.689 | 0.643 | 0.382 | 0.255 | 0.230 |
| cpp | **0.915** | 0.897 | 0.626 | 0.677 | 0.687 | 0.630 | 0.731 | 0.375 | 0.262 | 0.126 |
| elixir | **0.894** | 0.893 | 0.808 | 0.811 | 0.786 | 0.506 | 0.669 | 0.412 | 0.397 | 0.134 |
| python | 0.867 | **0.878** | 0.777 | 0.704 | 0.721 | 0.643 | 0.634 | 0.488 | 0.305 | 0.202 |
| csharp | **0.885** | 0.848 | 0.614 | 0.609 | 0.548 | 0.775 | 0.277 | 0.392 | 0.248 | 0.117 |
| php | **0.858** | 0.847 | 0.663 | 0.583 | 0.615 | 0.608 | 0.402 | 0.340 | 0.180 | 0.123 |
| swift | **0.860** | 0.845 | 0.710 | 0.709 | 0.672 | 0.630 | 0.429 | 0.280 | 0.151 | 0.160 |
| bash | 0.825 | **0.834** | 0.706 | 0.725 | 0.677 | 0.768 | 0.723 | 0.226 | 0.170 | 0.000 |
| lua | 0.823 | **0.829** | 0.798 | 0.736 | 0.738 | 0.591 | 0.699 | 0.336 | 0.050 | 0.000 |
| kotlin | 0.821 | **0.823** | 0.637 | 0.628 | 0.587 | 0.611 | 0.478 | 0.335 | 0.170 | 0.166 |
| haskell | 0.765 | **0.811** | 0.683 | 0.689 | 0.733 | 0.624 | 0.483 | 0.313 | 0.160 | 0.000 |
| java | **0.849** | 0.790 | 0.641 | 0.685 | 0.606 | 0.554 | 0.386 | 0.536 | 0.136 | 0.198 |
| c | 0.741 | **0.771** | 0.676 | 0.598 | 0.606 | 0.655 | 0.555 | 0.384 | 0.175 | 0.000 |
| rust | **0.856** | 0.754 | 0.662 | 0.541 | 0.419 | 0.454 | 0.519 | 0.242 | 0.193 | 0.162 |
| go | **0.895** | 0.713 | 0.785 | 0.512 | 0.458 | 0.506 | 0.722 | 0.410 | 0.183 | 0.133 |
| typescript | **0.706** | 0.671 | 0.430 | 0.476 | 0.456 | 0.455 | 0.394 | 0.354 | 0.145 | 0.128 |
| **overall** | **0.854** | 0.839 | **0.693** | **0.670** | **0.634** | **0.630** | **0.561** | **0.387** | **0.200** | **0.126** |

cbm = [codebase-memory-mcp](#methods).

Expand All @@ -102,9 +103,9 @@ cbm = [codebase-memory-mcp](#methods).

| Retrieval | Raw | + ranking |
| ---------------------- | ----: | --------: |
| BM25 | 0.675 | 0.834 |
| potion-code-16M | 0.650 | 0.821 |
| BM25 + potion-code-16M | — | **0.854** |
| BM25 | 0.675 | 0.834 |
| potion-code-16M-v2 | 0.650 | 0.821 |
| BM25 + potion-code-16M-v2 | — | **0.854** |

<details>
<summary>By query category</summary>
Expand Down Expand Up @@ -147,8 +148,9 @@ cbm = [codebase-memory-mcp](#methods).
- **[grepai](https://github.com/nicholasgasior/grepai)**: semantic search using [nomic-embed-text](https://huggingface.co/nomic-ai/nomic-embed-text-v1) (137M params) via a local Ollama daemon.
- **[codebase-memory-mcp](https://github.com/DeusData/codebase-memory-mcp)**: code intelligence engine that indexes a repo into a SQLite/graph store. We benchmark its `search_graph` tool in `fast` mode, which does BM25 full-text search with structural boosting.
- **[ck](https://github.com/BeaconBay/ck)**: hybrid regex + semantic search using [BAAI/bge-small-en-v1.5](https://huggingface.co/BAAI/bge-small-en-v1.5).
- **[zvec-grep](https://github.com/zvec-ai/zvec-grep)**: [potion-code-16M-v2](https://huggingface.co/minishlab/potion-code-16M-v2) static embeddings + BM25. Benchmarked in `direct` mode.
- **[CodeRankEmbed](https://huggingface.co/nomic-ai/CodeRankEmbed)**: 137M-param transformer embedding model for code retrieval, used for semantic-only dense search.
- **[semble](https://github.com/your-repo/semble)**: this library. [potion-code-16M](https://huggingface.co/minishlab/potion-code-16M) static embeddings + BM25 + the semble reranking stack.
- **[semble](https://github.com/your-repo/semble)**: this library. [potion-code-16M-v2](https://huggingface.co/minishlab/potion-code-16M-v2) static embeddings + BM25 + the semble reranking stack.
Comment thread
Pringled marked this conversation as resolved.

## Excluded methods

Expand All @@ -168,7 +170,7 @@ uv run python -m benchmarks.sync_repos # clone / update
uv run python -m benchmarks.sync_repos --check # verify only
```

All tools run CPU-only. semble uses `minishlab/potion-code-16M`; CodeRankEmbed uses `nomic-ai/CodeRankEmbed` (137M params). The speed benchmark touches one repo per language with a cold-start index and 5 query runs per repo.
All tools run CPU-only. semble uses `minishlab/potion-code-16M-v2`; CodeRankEmbed uses `nomic-ai/CodeRankEmbed` (137M params). The speed benchmark touches one repo per language with a cold-start index and 5 query runs per repo.

<details>
<summary>semble</summary>
Expand All @@ -188,7 +190,8 @@ Full runs write to `benchmarks/results/semble-hybrid-<sha12>.json`.

```bash
uv run python -m benchmarks.speed_benchmark
uv run python -m benchmarks.speed_benchmark --semble-only # skip the baselines
uv run python -m benchmarks.speed_benchmark --tools semble zvec-grep # subset of tools
uv run python -m benchmarks.speed_benchmark --repo flask --repo axios # subset of repos
```

Writes to `benchmarks/results/speed-<sha12>.json`.
Expand Down Expand Up @@ -280,6 +283,18 @@ uv run python -m benchmarks.baselines.ck --repo fastapi --repo axios

</details>

<details>
<summary>zvec-grep</summary>

Needs `zg` on `$PATH` (`npm install -g @zvec/zvec-grep@0.2.1`, requires Node.js 22+). Downloads the `potion-code-16m-v2` embedding model on first index. The published numbers were measured with 0.2.1; the runner records the installed version in its results file, and pinning matters because it parses `zg`'s human-readable output.

```bash
uv run python -m benchmarks.baselines.zvecgrep
uv run python -m benchmarks.baselines.zvecgrep --repo fastapi --repo axios
```

</details>

<details>
<summary>ripgrep</summary>

Expand Down
214 changes: 214 additions & 0 deletions benchmarks/baselines/zvecgrep.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import argparse
import json
import re
import shutil
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path

from benchmarks.data import (
RepoSpec,
Task,
add_filter_args,
grouped_tasks,
load_filtered_tasks,
save_results,
)
from benchmarks.metrics import file_rank, ndcg_at_k

_ZG = "zg"
_EMBEDDING = "local/potion-code-16m-v2"
_TOP_K = 10
_LATENCY_RUNS = 3
_INDEX_TIMEOUT = 1800
_SEARCH_TIMEOUT = 60
_HIT_RE = re.compile(r"^#\d+\s+matchedBy=\S+\s+(?P<path>.+):(?P<start>\d+)-(?P<end>\d+)$")


@dataclass(frozen=True)
class RepoResult:
"""Per-repo benchmark result."""

repo: str
language: str
ndcg10: float
p50_ms: float
index_ms: float


def _cleanup_index(benchmark_dir: Path) -> None:
shutil.rmtree(benchmark_dir / ".zvec-grep", ignore_errors=True)


def _build_index(benchmark_dir: Path) -> tuple[bool, float]:
"""Build a zvec-grep hybrid (FTS + vector) index for a repo; return (success, elapsed_ms)."""
_cleanup_index(benchmark_dir)
started = time.perf_counter()
try:
proc = subprocess.run(
[_ZG, "index", str(benchmark_dir), "--embedding", _EMBEDDING, "--mode", "direct"],
capture_output=True,
text=True,
timeout=_INDEX_TIMEOUT,
)
except subprocess.TimeoutExpired:
print(f" WARNING: zg index timed out after {_INDEX_TIMEOUT}s", file=sys.stderr)
return False, (time.perf_counter() - started) * 1000
elapsed_ms = (time.perf_counter() - started) * 1000
if proc.returncode != 0:
print(f" WARNING: zg index failed: {proc.stderr.strip()[:300]}", file=sys.stderr)
return False, elapsed_ms
return True, elapsed_ms


def _run_search(query: str, benchmark_dir: Path, *, top_k: int) -> list[str]:
"""Return absolute file paths ranked by zvec-grep's hybrid (FTS + vector) result order."""
cmd = [_ZG, "query", query, "--limit", str(top_k), "--mode", "direct", "--refresh", "off"]
try:
proc = subprocess.run(cmd, cwd=benchmark_dir, capture_output=True, text=True, timeout=_SEARCH_TIMEOUT)
except subprocess.TimeoutExpired:
return []
if proc.returncode != 0:
return []
seen: dict[str, None] = {}
for line in proc.stdout.splitlines():
match = _HIT_RE.match(line.strip())
if not match:
continue
abs_path = str((benchmark_dir / match.group("path")).resolve())
seen[abs_path] = None
return list(seen)[:top_k]


def _evaluate_repo(
tasks: list[Task],
benchmark_dir: Path,
*,
verbose: bool = False,
) -> tuple[float, float]:
"""Return (mean ndcg@10, p50 latency ms) for a list of tasks."""
ndcg10_sum = 0.0
latencies: list[float] = []

for task in tasks:
query_latencies: list[float] = []
file_paths: list[str] = []
for _ in range(_LATENCY_RUNS):
started = time.perf_counter()
file_paths = _run_search(task.query, benchmark_dir, top_k=_TOP_K)
query_latencies.append((time.perf_counter() - started) * 1000)
latencies.append(sorted(query_latencies)[_LATENCY_RUNS // 2])

relevant_ranks = [rank for t in task.all_relevant if (rank := file_rank(file_paths, t.path)) is not None]
q_ndcg10 = ndcg_at_k(relevant_ranks, len(task.all_relevant), _TOP_K)
ndcg10_sum += q_ndcg10

if verbose:
print(
f" ndcg@10={q_ndcg10:.3f} ranks={relevant_ranks} n_rel={len(task.all_relevant)} q={task.query!r}",
file=sys.stderr,
)
print(f" targets: {', '.join(t.path for t in task.all_relevant)}", file=sys.stderr)
print(f" top-5: {[Path(fp).name for fp in file_paths[:5]]}", file=sys.stderr)

latencies.sort()
return ndcg10_sum / len(tasks), latencies[len(latencies) // 2]


def _run_repo(spec: RepoSpec, tasks: list[Task], *, verbose: bool) -> RepoResult | None:
"""Index, evaluate, and clean up a single repo."""
benchmark_dir = spec.benchmark_dir
ok, index_ms = _build_index(benchmark_dir)
if not ok:
print(f" SKIP: {spec.name} — zg index failed", file=sys.stderr)
_cleanup_index(benchmark_dir)
return None

try:
ndcg10, p50_ms = _evaluate_repo(tasks, benchmark_dir, verbose=verbose)
finally:
_cleanup_index(benchmark_dir)

return RepoResult(repo=spec.name, language=spec.language, ndcg10=ndcg10, p50_ms=p50_ms, index_ms=index_ms)


def _zg_version() -> str:
"""Return the installed zvec-grep version, or 'unknown' if zg cannot be queried."""
try:
proc = subprocess.run([_ZG, "--version"], capture_output=True, text=True, timeout=30)
except (OSError, subprocess.TimeoutExpired):
return "unknown"
return proc.stdout.strip() or "unknown" if proc.returncode == 0 else "unknown"


def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Benchmark zvec-grep on the semble benchmark suite.")
add_filter_args(parser, verbose=True)
return parser.parse_args()


def main() -> None:
"""Run the zvec-grep baseline benchmark."""
args = _parse_args()
repo_specs, tasks = load_filtered_tasks(args.repo or None, args.language or None)

print(f"zvec-grep (hybrid: FTS + {_EMBEDDING} vector search)", file=sys.stderr)
print(f"{'Repo':<22} {'Language':<12} {'Index':>9} {'NDCG@10':>8} {'p50':>8}", file=sys.stderr)
print(f"{'-' * 22} {'-' * 12} {'-' * 9} {'-' * 8} {'-' * 8}", file=sys.stderr)

results: list[RepoResult] = []
for repo, repo_task_list in sorted(grouped_tasks(tasks).items()):
spec = repo_specs[repo]
if args.verbose:
print(f"\n--- {repo} ---", file=sys.stderr)
result = _run_repo(spec, repo_task_list, verbose=args.verbose)
if result is None:
continue
results.append(result)
print(
f"{repo:<22} {spec.language:<12} {result.index_ms:>8.0f}ms {result.ndcg10:>8.3f} {result.p50_ms:>7.1f}ms",
file=sys.stderr,
)

if not results:
return

avg_ndcg10 = sum(r.ndcg10 for r in results) / len(results)
avg_p50 = sum(r.p50_ms for r in results) / len(results)
avg_index = sum(r.index_ms for r in results) / len(results)
print(f"{'-' * 22} {'-' * 12} {'-' * 9} {'-' * 8} {'-' * 8}", file=sys.stderr)
avg_label = f"Average ({len(results)})"
print(
f"{avg_label:<22} {'':<12} {avg_index:>8.0f}ms {avg_ndcg10:>8.3f} {avg_p50:>7.1f}ms",
file=sys.stderr,
)

summary = {
"tool": "zvec-grep",
"version": _zg_version(),
"note": f"hybrid FTS + {_EMBEDDING} vector search",
"repos": [
{
"repo": r.repo,
"language": r.language,
"ndcg10": round(r.ndcg10, 4),
"p50_ms": round(r.p50_ms, 1),
"index_ms": round(r.index_ms, 0),
}
for r in results
],
"avg_ndcg10": round(avg_ndcg10, 4),
"avg_p50_ms": round(avg_p50, 1),
"avg_index_ms": round(avg_index, 0),
}
print(json.dumps(summary, indent=2))

if not args.repo and not args.language:
out = save_results("zvecgrep", summary)
print(f"\nResults saved to {out}", file=sys.stderr)


if __name__ == "__main__":
main()
Loading
Loading