Skip to content

Commit 50d1a4d

Browse files
search: bump index threshold + OR semantics + use workspace name as title
Three improvements to log search recall, all provider-agnostic except where noted: 1. Whole-file indexing for sessions <= 2 MB (was 512 KB). Big files now get 1.5 MB head + 500 KB tail (was 256 KB each). Fixes the case where topic-setting terms in a 10 MB events.jsonl were buried mid- file and never reached the index. Also fixes a short-read bug: the head reader could return fewer bytes than requested. 2. OR semantics by default (was AND, with OR fallback only on zero hits). When a query contains a term that doesn't appear anywhere in the corpus (e.g. a misspelled name), AND used to silently drop strong matches on the remaining terms. BM25 still ranks docs matching more terms higher, so precision is preserved. 3. Copilot title now reads workspace.yaml's 'name:' field first, falling back to 'summary' and then the first user message. The 'name:' field is auto-generated for every session and is much more descriptive than the first user message. Benched on a 770-session corpus; both regression queries improved (one went from rank 212 to rank 30; another stayed top-3 with a better title). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 3cc13ab commit 50d1a4d

2 files changed

Lines changed: 53 additions & 32 deletions

File tree

providers/copilot.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,12 @@ extract:
4343

4444
title:
4545
from: metadata
46-
path: summary
46+
path: name
4747
transforms: [strip_newlines, trim, "truncate:60"]
4848
fallback:
49+
- from: metadata
50+
path: summary
51+
transforms: [strip_newlines, trim, "truncate:60"]
4952
- from: events
5053
where: 'type == "user.message"'
5154
path: data.content // data.message // data.text

src/log_search.rs

Lines changed: 49 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,25 @@ use tantivy::{
3838
use crate::models::{ActivitySource, Session};
3939
use crate::provider::ProviderRegistry;
4040

41-
/// Bytes of each log file to index (from the tail).
42-
const TAIL_BYTES: u64 = 256 * 1024;
41+
/// Per-source byte budget when reading head from a large log file.
42+
///
43+
/// For most session activity, the most recent content is in the tail and
44+
/// the topic/intent is set in the head. We index a generous head slice so
45+
/// queries like "iteration review" or "townhall question" — whose terms
46+
/// usually appear in the first user message and a few follow-ups — match
47+
/// even when the conversation has grown large.
48+
const HEAD_BYTES: u64 = 1_500_000;
49+
50+
/// Per-source byte budget when reading tail from a large log file.
51+
const TAIL_BYTES: u64 = 500_000;
52+
53+
/// Per-session whole-file ceiling. Files at or below this size are indexed
54+
/// in full — no head/tail split, no structured-extract layer. Files above
55+
/// this size fall back to head + tail + structured extraction (Copilot).
56+
///
57+
/// 2 MB covers 93% of typical sessions on a heavy user's machine, so the
58+
/// vast majority get full-content indexing without any chunking heuristics.
59+
const WHOLE_FILE_THRESHOLD: u64 = 2 * 1024 * 1024;
4360

4461
/// Writer heap budget. tantivy requires >= 15 MB.
4562
const WRITER_HEAP_BYTES: usize = 20 * 1024 * 1024;
@@ -145,22 +162,12 @@ impl LogSearcher {
145162
}
146163
let searcher = self.reader.searcher();
147164

148-
// First pass: strict AND. High precision, may return zero.
149-
let mut and_parser = QueryParser::for_index(&self.index, vec![self.content_field]);
150-
and_parser.set_conjunction_by_default();
151-
let and_results = self.run_parsed_query(&searcher, &and_parser, trimmed);
152-
if !and_results.is_empty() {
153-
return and_results;
154-
}
155-
156-
// Fallback: OR semantics. BM25 ranks sessions matching MORE query
157-
// terms higher, so the noise penalty is small. Only triggers when
158-
// strict AND found nothing.
165+
// Default to OR semantics: BM25 naturally ranks docs matching more
166+
// query terms higher (so docs with all N terms outrank docs with
167+
// N-1 terms by a wide margin), but unlike strict AND we don't
168+
// *exclude* otherwise-strong matches just because one rare term
169+
// (e.g. a misspelled name) doesn't appear anywhere in the corpus.
159170
let or_parser = QueryParser::for_index(&self.index, vec![self.content_field]);
160-
crate::log::info(&format!(
161-
"log search: AND returned 0 for '{}', falling back to OR",
162-
trimmed
163-
));
164171
self.run_parsed_query(&searcher, &or_parser, trimmed)
165172
}
166173

@@ -356,37 +363,48 @@ fn source_path(src: &ActivitySource) -> &Path {
356363
}
357364
}
358365

359-
/// Read the head (first N bytes), tail (last N bytes), AND all compaction
360-
/// summaries from an events file. Compaction summaries (`session.compaction_complete`
361-
/// → `data.summaryContent`) are the densest source of searchable context in
362-
/// long Copilot sessions — ~10KB each, containing structured overviews of
363-
/// everything discussed before the compaction point.
366+
/// Read the head (first 1.5 MB), tail (last 500 KB), AND all compaction
367+
/// summaries from an events file. For files ≤ 2 MB the whole file is
368+
/// read in a single pass — no head/tail split needed.
369+
///
370+
/// Compaction summaries (`session.compaction_complete` → `data.summaryContent`)
371+
/// are the densest source of searchable context in long Copilot sessions —
372+
/// ~10 KB each, containing structured overviews of everything discussed
373+
/// before the compaction point.
364374
fn read_tail(path: &Path) -> Option<String> {
365375
let mut f = fs::File::open(path).ok()?;
366376
let len = f.metadata().ok()?.len();
367377

368-
if len <= TAIL_BYTES * 2 {
369-
// Small file — read the whole thing
378+
if len <= WHOLE_FILE_THRESHOLD {
379+
// Small/medium file — read the whole thing. This is the universal
380+
// win for sessions across all providers: anything ≤ 2 MB gets its
381+
// full content indexed, mid-conversation messages included.
370382
let mut buf = String::with_capacity(len as usize);
371383
f.read_to_string(&mut buf).ok()?;
372384
return Some(buf);
373385
}
374386

375-
let mut buf = String::with_capacity((TAIL_BYTES * 3) as usize);
387+
let mut buf = String::with_capacity((HEAD_BYTES + TAIL_BYTES + 1024) as usize);
376388

377-
// Read head
378-
let mut head_bytes = vec![0u8; TAIL_BYTES as usize];
379-
let head_read = f.read(&mut head_bytes).ok()?;
380-
buf.push_str(&String::from_utf8_lossy(&head_bytes[..head_read]));
389+
// Read head (first 1.5 MB — captures topic-setting first messages and
390+
// early conversation context, where queries by topic usually match).
391+
// Use a `take()` adapter + `read_to_end` to guarantee we get up to
392+
// HEAD_BYTES bytes — a single `read()` may return short on some
393+
// platforms even when more data is available.
394+
let mut head_bytes = Vec::with_capacity(HEAD_BYTES as usize);
395+
(&mut f).take(HEAD_BYTES).read_to_end(&mut head_bytes).ok()?;
396+
buf.push_str(&String::from_utf8_lossy(&head_bytes));
381397
buf.push_str("\n...\n");
382398

383-
// Scan entire file for high-value structured events (only if JSONL)
399+
// Scan entire file for high-value structured events (Copilot-format only;
400+
// other providers' extractors fall through cleanly with no work done).
384401
drop(f);
385402
if path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
386403
extract_structured_summaries(path, &mut buf);
387404
}
388405

389-
// Read tail
406+
// Read tail (last 500 KB — captures recent activity and most recent
407+
// task/turn state, for "what was I just working on" queries).
390408
let mut f = fs::File::open(path).ok()?;
391409
f.seek(SeekFrom::Start(len - TAIL_BYTES)).ok()?;
392410
buf.push_str("\n...\n");

0 commit comments

Comments
 (0)