Skip to content

Commit ec547fb

Browse files
feat: FTS5 search index with background rebuild and 30-day default wi… (#120)
* feat: FTS5 search index with background rebuild and 30-day default window * fix search index edge cases from review Clamp oversized since_days instead of dropping them back to the 30-day default. Keep paging FTS hits when exclusion rules filter out the first batch so limit still fills. Skip session files that vanish during the fingerprint walk, drop dead index path constants, and print the real regression limit in the benchmark gate. Refresh test_search_full_corpus baseline for the indexed search path. * style: ruff format check_benchmark_regression * Fixed Timon's feedback on feat/search-fts-index. Summary: utils/search_index.py Added ORDER BY m.timestamp_ms DESC so index hits are newest-first Replaced the 20× over-fetch with a fixed _FTS_BATCH_SIZE = 200; _search_via_index keeps paging until results are filled or FTS is exhausted Fixed offset: sql_rows_fetched is now rows actually scanned (not full batch size), so early break no longer skips unprocessed rows api/search.py Added _rank_search_hits() for timestamp-desc ordering Index and live-scan paths both rank before returning (live scan collects all matches, then sorts and slices — correct global newest-first on fallback) Tests test_results_ordered_newest_first — verifies DESC ordering test_phrase_filter_paginates_past_decoy_token_matches — 250 token-only decoys + real phrase at end; would fail with the old offset bug 30 tests passed (ruff + mypy clean). Suggested commit message: fix: FTS search ordering and pagination for phrase filtering Add ORDER BY timestamp_ms DESC, advance SQL offset by rows scanned, and page in fixed batches until enough hits or FTS exhausted. Rank live-scan results the same way for parity. Want me to commit and push to PR #120? /humanizer commit message fix: FTS search ordering and pagination
1 parent a05fd29 commit ec547fb

10 files changed

Lines changed: 1421 additions & 54 deletions

File tree

api/search.py

Lines changed: 215 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,38 @@
1-
"""Search endpoint. Brute-force substring match across all sessions."""
1+
"""Search endpoint — FTS index with live-scan fallback."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from datetime import datetime, timezone
7+
from typing import Any
28

39
from flask import Blueprint, current_app, request
410

511
from api._flask_types import FlaskReturn, json_response
612
from api.error_codes import ErrorCode, error_response
713
from models.search import SearchHitDict
14+
from models.session import MessageDict
815
from utils.exclusion_rules import is_session_excluded
16+
from utils.search_index import (
17+
index_is_usable,
18+
index_search_enabled,
19+
query_index_hits,
20+
resolve_search_since_ms,
21+
search_snippet,
22+
timestamp_in_search_window_iso,
23+
timestamp_to_ms,
24+
tool_result_searchable_text,
25+
)
926
from utils.session_cache import get_cached_session
1027
from utils.session_path import get_claude_projects_dir, list_projects, list_sessions
28+
from utils.session_summary_cache import get_summary, rules_fingerprint
1129

1230
search_bp = Blueprint("search", __name__)
31+
_logger = logging.getLogger(__name__)
1332

1433
_DEFAULT_LIMIT = 50
1534
_MAX_LIMIT = 500
35+
_MAX_SEARCH_SINCE_DAYS = 36_500
1636

1737

1838
def _parse_limit(raw: str | None, default: int = _DEFAULT_LIMIT) -> int:
@@ -28,60 +48,215 @@ def _parse_limit(raw: str | None, default: int = _DEFAULT_LIMIT) -> int:
2848
return min(value, _MAX_LIMIT)
2949

3050

31-
@search_bp.route("/api/search")
32-
def search() -> FlaskReturn:
33-
query = request.args.get("q", "").strip().lower()
34-
if not query:
35-
return json_response([])
36-
51+
def _parse_since_days(raw: str | None) -> int | None:
52+
if raw is None or not str(raw).strip():
53+
return None
3754
try:
38-
max_results = _parse_limit(request.args.get("limit"))
55+
days = int(str(raw).strip())
3956
except ValueError:
40-
return error_response(
41-
ErrorCode.SEARCH_INVALID_LIMIT,
42-
"Invalid limit: must be a positive integer",
43-
400,
57+
return None
58+
if days <= 0:
59+
return None
60+
return min(days, _MAX_SEARCH_SINCE_DAYS)
61+
62+
63+
def _rank_search_hits(results: list[SearchHitDict]) -> list[SearchHitDict]:
64+
"""Sort hits by timestamp descending (missing timestamps last)."""
65+
return sorted(
66+
results,
67+
key=lambda hit: timestamp_to_ms(
68+
hit["timestamp"] if isinstance(hit.get("timestamp"), str) else None
69+
),
70+
reverse=True,
71+
)
72+
73+
74+
def _message_searchable_text(msg: MessageDict) -> str:
75+
text = msg.get("text", "") or msg.get("content", "")
76+
if not isinstance(text, str):
77+
text = ""
78+
tool_result = msg.get("tool_result")
79+
if isinstance(tool_result, dict):
80+
tool_text = tool_result_searchable_text(tool_result)
81+
if tool_text:
82+
text = f"{text}\n{tool_text}" if text else tool_text
83+
return text
84+
85+
86+
def _index_hit_excluded(
87+
rules: list[Any],
88+
rules_fp: str,
89+
*,
90+
project_name: str,
91+
file_path: str,
92+
mtime: float,
93+
) -> bool:
94+
if not rules:
95+
return False
96+
cached = get_summary(file_path, mtime, rules_fp)
97+
if cached is not None and cached["is_complete"]:
98+
return cached["is_excluded"]
99+
try:
100+
session = get_cached_session(file_path)
101+
except Exception:
102+
_logger.warning(
103+
"Could not load session for exclusion check during index search: %s",
104+
file_path,
105+
exc_info=True,
44106
)
107+
return False
108+
return is_session_excluded(rules, session, project_name)
45109

46-
base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir()
47-
projects = list_projects(base)
48110

49-
rules = current_app.config.get("EXCLUSION_RULES") or []
111+
def _search_via_index(
112+
projects_dir: str,
113+
rules: list[Any],
114+
query: str,
115+
query_lower: str,
116+
*,
117+
since_ms: int | None,
118+
max_results: int,
119+
) -> list[SearchHitDict] | None:
120+
if not index_search_enabled() or not index_is_usable(projects_dir, rules):
121+
return None
122+
123+
rules_fp = rules_fingerprint(rules)
50124
results: list[SearchHitDict] = []
51-
for project in projects:
52-
if len(results) >= max_results:
125+
sql_offset = 0
126+
while len(results) < max_results:
127+
need = max_results - len(results)
128+
indexed = query_index_hits(
129+
query_lower,
130+
since_ms=since_ms,
131+
max_results=need,
132+
sql_offset=sql_offset,
133+
)
134+
if not indexed["query_ok"]:
135+
return None
136+
if indexed["sql_rows_fetched"] == 0:
53137
break
54-
sessions = list_sessions(project["path"])
55-
for sess_info in sessions:
138+
139+
for hit in indexed["hits"]:
56140
if len(results) >= max_results:
57141
break
142+
if _index_hit_excluded(
143+
rules,
144+
rules_fp,
145+
project_name=hit["project_name"],
146+
file_path=hit["file_path"],
147+
mtime=hit["mtime"],
148+
):
149+
continue
150+
results.append(
151+
{
152+
"project": hit["project_name"],
153+
"session_id": hit["session_id"],
154+
"title": hit["title"],
155+
"role": hit["role"],
156+
"timestamp": hit["timestamp"],
157+
"snippet": search_snippet(hit["text"], query),
158+
}
159+
)
160+
161+
sql_offset += indexed["sql_rows_fetched"]
162+
if indexed["sql_exhausted"]:
163+
break
164+
return _rank_search_hits(results)[:max_results]
165+
166+
167+
def _search_live_scan(
168+
base: str,
169+
rules: list[Any],
170+
query: str,
171+
query_lower: str,
172+
*,
173+
since_ms: int | None,
174+
max_results: int,
175+
) -> list[SearchHitDict]:
176+
projects = list_projects(base)
177+
results: list[SearchHitDict] = []
178+
for project in projects:
179+
sessions = list_sessions(project["path"])
180+
for sess_info in sessions:
58181
try:
59182
session = get_cached_session(sess_info["path"])
60183
except Exception:
184+
_logger.warning(
185+
"Skipping session during live search: %s",
186+
sess_info["path"],
187+
exc_info=True,
188+
)
61189
continue
62190

63191
if is_session_excluded(rules, session, project["name"]):
64192
continue
65193

66194
for msg in session["messages"]:
67-
text = msg.get("text", "") or msg.get("content", "")
68-
if query in text.lower():
69-
idx = text.lower().index(query)
70-
start = max(0, idx - 80)
71-
end = min(len(text), idx + len(query) + 80)
72-
snippet = text[start:end]
73-
74-
results.append(
75-
{
76-
"project": project["name"],
77-
"session_id": session["session_id"],
78-
"title": session["title"],
79-
"role": msg["role"],
80-
"timestamp": msg.get("timestamp"),
81-
"snippet": snippet,
82-
}
83-
)
84-
if len(results) >= max_results:
85-
break
86-
87-
return json_response(results)
195+
text = _message_searchable_text(msg)
196+
if not text or query_lower not in text.lower():
197+
continue
198+
if not timestamp_in_search_window_iso(
199+
msg.get("timestamp") if isinstance(msg.get("timestamp"), str) else None,
200+
since_ms,
201+
):
202+
continue
203+
results.append(
204+
{
205+
"project": project["name"],
206+
"session_id": session["session_id"],
207+
"title": session["title"],
208+
"role": msg["role"],
209+
"timestamp": msg.get("timestamp"),
210+
"snippet": search_snippet(text, query),
211+
}
212+
)
213+
return _rank_search_hits(results)[:max_results]
214+
215+
216+
@search_bp.route("/api/search")
217+
def search() -> FlaskReturn:
218+
query = request.args.get("q", "").strip()
219+
if not query:
220+
return json_response([])
221+
222+
try:
223+
max_results = _parse_limit(request.args.get("limit"))
224+
except ValueError:
225+
return error_response(
226+
ErrorCode.SEARCH_INVALID_LIMIT,
227+
"Invalid limit: must be a positive integer",
228+
400,
229+
)
230+
231+
query_lower = query.lower()
232+
all_history = request.args.get("all_history") in ("1", "true")
233+
since_ms = resolve_search_since_ms(
234+
all_history=all_history,
235+
since_days=_parse_since_days(request.args.get("since_days")),
236+
now=datetime.now(timezone.utc),
237+
)
238+
239+
base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir()
240+
rules = current_app.config.get("EXCLUSION_RULES") or []
241+
242+
indexed = _search_via_index(
243+
base,
244+
rules,
245+
query,
246+
query_lower,
247+
since_ms=since_ms,
248+
max_results=max_results,
249+
)
250+
if indexed is not None:
251+
return json_response(indexed)
252+
253+
return json_response(
254+
_search_live_scan(
255+
base,
256+
rules,
257+
query,
258+
query_lower,
259+
since_ms=since_ms,
260+
max_results=max_results,
261+
)
262+
)

app.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
__version__ = "0.2.0.dev0"
44

55
import argparse
6+
import logging
67
import sys
78

89
from flask import Flask
@@ -13,6 +14,7 @@
1314
from api.search import search_bp
1415
from api.sessions import sessions_bp
1516
from utils.exclusion_rules import load_rules, resolve_exclusion_rules_path
17+
from utils.session_path import get_claude_projects_dir
1618

1719
# Content-Security-Policy for all Flask responses. 'unsafe-inline' in style-src is
1820
# required because highlight.js themes apply inline styles; can be tightened with
@@ -104,6 +106,15 @@ def create_app(
104106
app.register_blueprint(export_bp)
105107
app.register_blueprint(schema_report_bp)
106108

109+
if not app.config.get("TESTING"):
110+
try:
111+
from utils.search_index import start_search_index_background
112+
113+
projects_dir = base_dir or get_claude_projects_dir()
114+
start_search_index_background(projects_dir, app.config["EXCLUSION_RULES"])
115+
except Exception:
116+
logging.getLogger(__name__).exception("Failed to start search index background worker")
117+
107118
@app.after_request
108119
def set_security_headers(response):
109120
# Always set — do not use setdefault; a blueprint must not weaken CSP.

benchmarks/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,9 @@ The memory ceiling test (`test_large_parse_peak_memory_under_ceiling`) runs in t
3838

3939
The `benchmarks` job on **ubuntu-latest** runs pytest-benchmark (`--benchmark-json=benchmark-results.json`), then `scripts/check_benchmark_regression.py benchmark-results.json benchmarks/baselines.json`. CI fails when any **gated** benchmark mean exceeds its baseline by more than **20%**.
4040

41-
**Gated:** parse medium/large + large peak memory; export 10/50/100 session latency + ZIP peak memory.
41+
**Gated:** parse medium/large + large peak memory; export 10/50/100 session latency + ZIP peak memory; `test_search_full_corpus` (relaxed **2.0×** threshold vs 1.2× for other gates — sub-ms search timings are noisy on ubuntu CI).
4242

43-
**Not gated (informational only):** `test_parse_session_small`, `test_search_full_corpus` (sub-ms CI noise), and the `cache` group. These may appear in `baselines.json` for reference but are skipped by `check_benchmark_regression.py`. Benchmarks without a baseline entry print a warning and do not fail the gate.
43+
**Not gated (informational only):** `test_parse_session_small` and the `cache` group.
4444

4545
Missing gated benchmarks (renamed or removed tests still listed in `baselines.json`) fail the gate.
4646

benchmarks/baselines.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
2-
"_note": "Gated means from ubuntu-latest CI benchmark-results.json. PR #108 (schema drift): parse/export latency baselines raised for per-entry field-path fingerprinting. Excluded from gate (recorded for reference): test_parse_session_small, test_search_full_corpus (sub-ms CI noise). Memory benchmarks use extra_info.peak_bytes (bytes); latency uses stats.mean (seconds).",
3-
"updated": "2026-07-03T00:00:00Z",
2+
"_note": "Gated means from ubuntu-latest CI benchmark-results.json. PR #108 (schema drift): parse/export latency baselines raised for per-entry field-path fingerprinting. Excluded from gate (recorded for reference): test_parse_session_small (sub-ms CI noise). test_search_full_corpus baseline refreshed for FTS search path (PR #120). Memory benchmarks use extra_info.peak_bytes (bytes); latency uses stats.mean (seconds).",
3+
"updated": "2026-07-07T00:00:00Z",
44
"machine": "Linux",
55
"groups": {
66
"parse": {
@@ -18,7 +18,7 @@
1818
"test_bulk_export_zip_peak_memory[sessions-100]": 694088.0
1919
},
2020
"search": {
21-
"test_search_full_corpus": 0.0011120838654706596
21+
"test_search_full_corpus": 0.002595
2222
}
2323
}
2424
}

0 commit comments

Comments
 (0)