Keep the local index fresh from the change feed and improve ranking

- Track a feed cursor in the local index: reindex takes the head cursor
  before listing and commits it with the snapshot; reads refresh from
  /changes (bounded, throttled) and apply creates, updates, archives,
  restores and hard deletes atomically with the cursor. Existing
  cursorless indexes bootstrap tracking on first read. Failures answer
  locally with explicit freshness warnings; 400 triggers a full
  reindex, 404 falls back to timestamp sync.
- Rank by a weighted score: exact identity (including aliases and
  paths), term coverage including body, intent, bm25, authority, a
  status penalty for superseded/completed records and mild recency.
- Return query-centred FTS5 snippets (never for E2E nodes).
- FTS schema v2: porter stemming, prefix index, title column, rowid
  deletes, two-character terms, domain words no longer stopwords;
  v1 indexes upgrade in place.
- Hyphenated alias/path literals match as adjacent-token phrases and
  rank as exact identity.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
HumbleTurtle
2026-09-28 22:09:12 -03:00
co-authored by Claude Opus 5.5
parent 7eaaffb63f
commit 0938b3026e
7 changed files with 1708 additions and 93 deletions
+30 -8
View File
@@ -153,6 +153,18 @@ def indexed_node(path, *, node_type="note", tags=None, status="active", content=
}
def feed_provider(calls):
"""Synthetic server: empty change feed and no newer nodes."""
def request(method, path, payload=None, **_kwargs):
calls.append((method, path))
if "/changes?" in path:
return {"items": [], "next_cursor": "c0", "has_more": False}
return {"items": [], "next_cursor": None}
return request
def configure_local_index(mod, tmp_path):
mod.AUTH_DIR = tmp_path
mod.INDEX_PATH = tmp_path / "index.sqlite"
@@ -208,11 +220,14 @@ def test_complete_local_compact_browse_uses_keyset_cursor_without_http(tmp_path)
item["updated_at"] = f"2026-08-2{index}T00:00:00Z"
mod.index_node(item)
mod.set_local_graph_index_complete("project-1", True)
calls = []
mod.TOKEN = "disposable-test-token" # synthetic login; requests go to feed_provider only
mod.request_json = feed_provider(calls)
# The first read starts change-feed tracking for this cursorless index.
mod.discover_memory({"folder_path": "/plans", "limit": 1, "view": "paths"})
assert [path.split("?")[0].rsplit("/", 1)[-1] for _method, path in calls] == ["changes", "nodes"]
calls.clear()
def unexpected_request(*_args, **_kwargs):
raise AssertionError("complete local compact browse must not make an HTTP request")
mod.request_json = unexpected_request
first = mod.discover_memory(
{
"folder_path": "/plans",
@@ -241,6 +256,9 @@ def test_complete_local_compact_browse_uses_keyset_cursor_without_http(tmp_path)
)
assert not ({item["node_id"] for item in first["items"]} & {item["node_id"] for item in second["items"]})
assert all(item["node_id"] for item in first["items"] + second["items"])
# Steady state inside the refresh throttle window: no HTTP at all.
assert calls == []
assert first["freshness"]["feed_sync"] == "throttled"
def test_local_compact_browse_cursor_is_bound_to_filters(tmp_path):
@@ -482,11 +500,13 @@ def test_complete_filtered_local_search_stays_off_network(tmp_path):
)
)
mod.set_local_graph_index_complete("project-1", True)
calls = []
mod.TOKEN = "disposable-test-token" # synthetic login; requests go to feed_provider only
mod.request_json = feed_provider(calls)
mod.search_memory({"q": "facturación", "tag": "billing", "limit": 1, "view": "summary"})
assert len(calls) == 2 # one-time change-feed bootstrap: head + catch-up
calls.clear()
def unexpected_request(*_args, **_kwargs):
raise AssertionError("a complete filtered local result must not use the network")
mod.request_json = unexpected_request
result = mod.search_memory(
{
"q": "facturación",
@@ -500,6 +520,8 @@ def test_complete_filtered_local_search_stays_off_network(tmp_path):
assert [item["full_path"] for item in result["items"]] == [
"/documentation/facturacion-electronica"
]
assert calls == [] # a complete, tracked, recently refreshed index stays off the network
assert not result.get("warnings")
def test_discovery_schema_documents_dynamic_sort_and_intent():