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
48 changes: 37 additions & 11 deletions .github/workflows/changelog.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,36 @@ on:
push:
branches: [main]

permissions:
contents: write
permissions: {}

concurrency:
group: changelog
cancel-in-progress: true

jobs:
regenerate:
if: github.actor != 'github-actions[bot]'
if: ${{ !endsWith(github.actor, '[bot]') }}
runs-on: ubuntu-latest
permissions: {} # all repo access comes from the minted App token below
steps:
- name: Mint a changelog-bot token
id: app-token
uses: actions/create-github-app-token@v3
with:
client-id: ${{ vars.CHANGELOG_APP_CLIENT_ID }}
private-key: ${{ secrets.CHANGELOG_APP_PRIVATE_KEY }}
permission-contents: write
permission-pull-requests: write
- name: Get changelog-bot user id
id: app-user
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
run: echo "id=$(gh api "/users/${APP_SLUG}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v5
with:
fetch-depth: 0 # surface diff worktree + previous-tag lookup need history
token: ${{ steps.app-token.outputs.token }}
- uses: dtolnay/rust-toolchain@stable
# rustdoc JSON for the API surface diff is nightly-only
- run: rustup toolchain install nightly --profile minimal
Expand All @@ -34,11 +49,22 @@ jobs:
[ -n "$PREV" ] && AGAINST="--against-ref $PREV"
# shellcheck disable=SC2086 # deliberate flag list
cargo soothfast report changelog -p scry-core -p scry-server --baseline base $AGAINST
- name: Commit if changed
run: |
git diff --quiet CHANGELOG.md && exit 0
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add CHANGELOG.md
git commit -m "docs: regenerate CHANGELOG.md"
git push
- uses: peter-evans/create-pull-request@v8
id: cpr
with:
token: ${{ steps.app-token.outputs.token }}
add-paths: CHANGELOG.md
commit-message: "docs: regenerate CHANGELOG.md"
title: "docs: regenerate CHANGELOG.md"
body: Automated CHANGELOG regeneration.
branch: bot/changelog-update
delete-branch: true
author: >-
${{ steps.app-token.outputs.app-slug }}[bot]
<${{ steps.app-user.outputs.id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com>
- name: Merge pull request
if: steps.cpr.outputs.pull-request-number
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
PR_NUMBER: ${{ steps.cpr.outputs.pull-request-number }}
run: gh pr merge --auto --squash --delete-branch "$PR_NUMBER"
47 changes: 41 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,40 @@
# Changelog

## Unreleased (draft vs v0.1.0)
## 0.2.0 - 2026-09-05

<!-- soothfast:notes -->
<!-- ### Overview -->
<!-- What this release means for someone using it. One paragraph. -->
### Overview

Retrieval quality release. Tree-sitter chunks now keep the doc comment
and attributes above each definition, and spans under four lines merge
into a neighbour, so a bare `#[cfg]` line or a one-line `mod` declaration
never surfaces as a hit on its own; on finance-query that removed a fifth
of all chunks and lifted recall@10 on the golden set from 0.717 to 0.833.
Answers go through the same rerank as search and say what the sources do
not cover instead of guessing. The reranker reads at most `max_chars`
(3000) of each document, which is both faster and more accurate than full
length, and the server caches the last 256 query vectors so a repeated
query skips HyDE and embedding. Files marked `@generated` are no longer
indexed. A cross-encoder is still optional; nothing changes for a server
without `[rerank]`.

### Upgrade notes

<!-- ### Upgrade notes -->
<!-- What a consumer has to do. "Nothing" is a useful answer. -->
Run `scry index --full` once per repo after upgrading the server. Files
are only re-chunked when their content changes, so an existing index
keeps the old short chunks until every file is re-uploaded; a plain
`scry index` reports everything unchanged. The full pass re-embeds the
repo, which took eight minutes for a 1,200-file repo on an iGPU. The new
`[rerank] max_chars` key defaults to 3000 and the `rerank` field on
`/v1/answer` defaults to true, so no config change is required.
<!-- /soothfast:notes -->

### ✨ Features

- Rerank answer sources and admit gaps
- Add scry index --full to re-chunk a repo
- Skip @generated files when chunking
- Keep trivia with defs and merge short chunks
- Rerank stage fused as a weighted RRF leg (#4)
- Greedy chat completions, HyDE capped at 120 tokens
- Eval --limit for recall at deeper cutoffs
Expand All @@ -28,18 +51,23 @@

### ⚡ Performance

- Cache query vectors on the server
- Cut reranker input to max_chars
- One allocation for the rescore id list, exact alloc claim
- Allocation-free exact scan and schema guards
- Float vectors in a plain table, coarse pass rescored from it
- Rescore coarse candidates in one KNN query

### 📝 Documentation

- Release notes for 0.2.0
- Allocation claim for the dense search path
- Add CLAUDE.md

### 🔧 Internal

- Bump version to 0.2.0
- Open a PR for CHANGELOG regeneration
- Tie-aware recall and recall@10 in refiner_curve
- Store pragmas, optimize on shutdown, vacuum on prune
- Store-layer benches and refiner recall harness
Expand All @@ -50,21 +78,28 @@

```
# scry-core
ADDED scry_core::chunker::is_generated
ADDED scry_core::config::RerankConfig
ADDED scry_core::rerank
ADDED scry_core::rerank::RerankClient
ADDED scry_core::rerank::RerankResult
ADDED scry_core::rerank::fuse
ADDED scry_core::search::RRF_K
REMOVED scry_core::chunker::line_window::chunk_lines
CHANGED scry_core::Error (body)
CHANGED scry_core::chunker::chunk_file (body)
CHANGED scry_core::chunker::line_window::chunk (body)
CHANGED scry_core::config::Config (body)
CHANGED scry_core::error::Error (body)
CHANGED scry_core::search::expand_symbols (body)
CHANGED scry_core::search::fts_query (body)
CHANGED scry_core::search::query_vector (body)

# scry-server
ADDED scry_server::QueryCache
ADDED scry_server::query_cache::QueryCache
CHANGED scry_server::AppState (body)
CHANGED scry_server::api::AnswerRequest (body)
CHANGED scry_server::api::SearchRequest (body)
CHANGED scry_server::serve (body)
```
Expand All @@ -73,7 +108,7 @@ CHANGED scry_server::serve (body)

| item | metric | was | now | delta |
|---|---|---:|---:|---:|
| `scry_core::bench_expand_symbols` | instructions | 58528.0 | 31276.0 | -46.6% |
| `scry_core::bench_expand_symbols` | instructions | 50170.0 | 27518.0 | -45.2% |
| `scry_core::bench_expand_symbols` | allocs | 88.0 | 55.0 | -37.5% |


Expand Down
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ edits elsewhere in a file, but renaming a file or changing the repo key
deliberately re-embeds. Directories at or above `$HOME` are never treated
as repos; search there goes cross-repo (`repo_id = None` end to end).

**Chunking happens server-side** in `prepare_file`, and a file is only
re-chunked when its content hash changes. After touching `chunker/`, run
`scry index --full` in a test repo to see the effect; plain `scry index`
will report everything unchanged. `@generated` files yield no chunks.

**One SQLite file** holds everything: repos/files/chunks + FTS5 +
sqlite-vec tables + memories. The vec tables are created at `Store::open`
because their DDL carries the embedding dimension; `meta` stamps the
Expand Down
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/scry-core", "crates/scry-server", "crates/scry"]

[workspace.package]
version = "0.1.0"
version = "0.2.0"
edition = "2024"
license = "MIT"
repository = "https://github.com/Verdenroz/scry"
Expand Down
106 changes: 106 additions & 0 deletions crates/scry-core/src/chunker/coalesce.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
//! Merges undersized spans into a neighbour so a bare attribute, a `use`
//! group, or a one-line `mod` declaration never embeds on its own.

use super::Span;

/// Spans shorter than this merge forward into the next span; a short
/// trailing span merges back into the previous one.
pub(crate) const MIN_LINES: usize = 4;

const SEPARATOR: &str = " > ";

pub(crate) fn coalesce(spans: Vec<Span>) -> Vec<Span> {
let mut out: Vec<Span> = Vec::new();
let mut pending: Option<Span> = None;
for span in spans {
let span = match pending.take() {
Some(short) => merge(short, span),
None => span,
};
if span.end - span.start < MIN_LINES {
pending = Some(span);
} else {
out.push(span);
}
}
if let Some(short) = pending {
let merged = match out.pop() {
Some(last) => merge(last, short),
None => short,
};
out.push(merged);
}
out
}

fn merge(first: Span, second: Span) -> Span {
Span {
start: first.start.min(second.start),
end: first.end.max(second.end),
symbol: shared_symbol(first.symbol.as_deref(), second.symbol.as_deref()),
}
}

/// `S > a` and `S > b` merge under `S`; unrelated symbols merge under none.
fn shared_symbol(a: Option<&str>, b: Option<&str>) -> Option<String> {
match (a, b) {
(None, other) | (other, None) => other.map(str::to_string),
(Some(a), Some(b)) => {
let shared = a
.split(" > ")
.zip(b.split(" > "))
.take_while(|(x, y)| x == y)
.fold(0, |len, (x, _)| len + x.len() + SEPARATOR.len());
(shared > 0).then(|| a[..shared - SEPARATOR.len()].to_string())
}
}
}

#[cfg(test)]
mod tests {
use super::*;

fn span(start: usize, end: usize, symbol: Option<&str>) -> Span {
Span {
start,
end,
symbol: symbol.map(str::to_string),
}
}

#[test]
fn short_spans_merge_forward_and_keep_the_definition_symbol() {
let out = coalesce(vec![span(0, 2, None), span(2, 10, Some("alpha"))]);
assert_eq!(out, vec![span(0, 10, Some("alpha"))]);
}

#[test]
fn short_trailing_span_merges_back() {
let out = coalesce(vec![span(0, 10, Some("alpha")), span(10, 11, None)]);
assert_eq!(out, vec![span(0, 11, Some("alpha"))]);
}

#[test]
fn runs_of_one_liners_accumulate() {
let out = coalesce(vec![
span(0, 1, Some("a")),
span(1, 2, Some("b")),
span(2, 3, Some("c")),
span(3, 4, Some("d")),
span(4, 12, Some("e")),
]);
assert_eq!(out, vec![span(0, 4, None), span(4, 12, Some("e"))]);
}

#[test]
fn sibling_methods_merge_under_their_parent() {
let out = coalesce(vec![span(0, 2, Some("S > a")), span(2, 5, Some("S > b"))]);
assert_eq!(out, vec![span(0, 5, Some("S"))]);
}

#[test]
fn long_spans_pass_through() {
let spans = vec![span(0, 10, Some("a")), span(10, 20, Some("b"))];
assert_eq!(coalesce(spans.clone()), spans);
}
}
23 changes: 15 additions & 8 deletions crates/scry-core/src/chunker/line_window.rs
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
use super::{Chunk, lines_chunk};
use super::{Chunk, Span, lines_chunk};

pub const WINDOW: usize = 40;
pub const OVERLAP: usize = 10;
const SNAP: usize = 5;

pub fn chunk(content: &str) -> Vec<Chunk> {
let lines: Vec<&str> = content.lines().collect();
chunk_lines(&lines, 0, lines.len(), None)
spans(&lines, 0, lines.len())
.into_iter()
.map(|span| lines_chunk(&lines, span))
.collect()
}

/// Windows `lines[from..to]` into chunks, snapping each cut back to the
/// nearest blank line within [`SNAP`] lines.
pub fn chunk_lines(lines: &[&str], from: usize, to: usize, symbol: Option<&str>) -> Vec<Chunk> {
let mut chunks = Vec::new();
/// Windows `lines[from..to]`, snapping each cut back to the nearest blank
/// line within [`SNAP`] lines. Windows holding only blank lines are dropped.
pub(crate) fn spans(lines: &[&str], from: usize, to: usize) -> Vec<Span> {
let mut spans = Vec::new();
let mut start = from;
while start < to {
let mut end = (start + WINDOW).min(to);
Expand All @@ -29,14 +32,18 @@ pub fn chunk_lines(lines: &[&str], from: usize, to: usize, symbol: Option<&str>)
end = (start + WINDOW).min(to);
}
if lines[start..end].iter().any(|line| !line.trim().is_empty()) {
chunks.push(lines_chunk(lines, start, end, symbol.map(str::to_string)));
spans.push(Span {
start,
end,
symbol: None,
});
}
if end >= to {
break;
}
start = end.saturating_sub(OVERLAP).max(start + 1);
}
chunks
spans
}

#[cfg(test)]
Expand Down
Loading