Skip to content

Commit 01e4e86

Browse files
committed
Change search subcommand to show results based on distance
This works better as all relevant hits are returned and no noise is offered when less than 20 pages match.
1 parent b58c816 commit 01e4e86

5 files changed

Lines changed: 117 additions & 37 deletions

File tree

‎README.md‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ uv run memoryfield-tool --help
2727
```
2828

2929
Requires Python 3.12+. Semantic search needs a local
30-
[ollama](https://ollama.dev) with `nomic-embed-text` pulled; everything else
30+
[ollama](https://ollama.com) with `nomic-embed-text` pulled; everything else
3131
works without it (search falls back to substring matching).
3232

3333
## Commands
@@ -196,8 +196,12 @@ s3 fields index to `~/.cache/memoryfield-tool/indexes/<field>.sqlite3` on the
196196
local machine. Both use the spec's `pages` schema. Indexes are derived data
197197
and can be regenerated with `index` at any time — the cache-dir index is
198198
machine-local, so it is not shared across machines (run `index` on each one) and
199-
is not included in exports. `search` returns the top 20 nearest pages (cosine
200-
distance shown in the output; no hard distance cutoff). After each `write`,
199+
is not included in exports. `search` returns every page whose cosine distance is
200+
at most `--max-distance` (default 0.45); cosine distance is shown in the output.
201+
It exits with status 1 and a stderr message when no pages match. When nothing
202+
meets the cut-off, no results are returned (the substring fallback only applies
203+
when the index or embedder is unavailable, and is capped at 20 results). After
204+
each `write`,
201205
the index is rebuilt in the background (a detached process; its stderr goes to
202206
`~/.cache/memoryfield-tool/reindex.log`).
203207

‎src/memoryfield_tool/cli.py‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -962,21 +962,36 @@ def _format_result(result: search.SearchResult) -> str:
962962
@click.argument("query")
963963
@click.option("--field", "field_name", default=None, help="Limit to a single memoryfield")
964964
@click.option("--json", "as_json", is_flag=True, help="Output as JSON array")
965-
def search_cmd(query: str, field_name: str | None, as_json: bool) -> None:
965+
@click.option(
966+
"--max-distance",
967+
type=click.FloatRange(min=0.0, max=2.0),
968+
default=search.DEFAULT_MAX_DISTANCE,
969+
show_default=True,
970+
help="Maximum cosine distance for semantic results",
971+
)
972+
def search_cmd(query: str, field_name: str | None, as_json: bool, max_distance: float) -> None:
966973
"""Search pages across connected memoryfields.
967974
968975
Examples:
969976
970977
\b
971978
memoryfield-tool search 'carbon fibre'
972979
memoryfield-tool search --json 'carbon fibre'
980+
memoryfield-tool search --max-distance 0.3 'carbon fibre'
981+
982+
Exits with status 1 when no pages match.
973983
"""
974984
cfg = config.load_config()
975985
field_list = fields.connected_fields(cfg, field_name)
976986

977-
results, errors = search.search_all(field_list, query)
987+
results, errors = search.search_all(field_list, query, max_distance)
978988
for name, msg in errors:
979989
click.echo(f"error: memoryfield {name}: {msg}", err=True)
990+
if not results:
991+
click.echo("No matching results found.", err=True)
992+
if as_json:
993+
click.echo("[]")
994+
sys.exit(1)
980995
if as_json:
981996
payload = [
982997
{
@@ -990,9 +1005,6 @@ def search_cmd(query: str, field_name: str | None, as_json: bool) -> None:
9901005
click.echo(json.dumps(payload, indent=2, default=str))
9911006
return
9921007

993-
if not results:
994-
click.echo("No matching results found.")
995-
return
9961008
if len(field_list) == 1:
9971009
for _fname, r in results:
9981010
click.echo(_format_result(r))

‎src/memoryfield_tool/search.py‎

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212

1313
RESULT_LIMIT = 20
1414

15+
# this is a good threshold for nomic-embed-text-v1
16+
DEFAULT_MAX_DISTANCE: float = 0.45
17+
1518

1619
@dataclass(frozen=True)
1720
class SearchResult:
@@ -40,7 +43,7 @@ def _summary_from_json(fm_json: str) -> str:
4043
return str(summary) if summary is not None else ""
4144

4245

43-
def _vector_search(index_loc: Path, query: str) -> list[SearchResult] | None:
46+
def _vector_search(index_loc: Path, query: str, max_distance: float) -> list[SearchResult] | None:
4447
if not index_loc.is_file():
4548
return None
4649
result = embed.embed_texts([f"search_query: {query}"])
@@ -51,9 +54,12 @@ def _vector_search(index_loc: Path, query: str) -> list[SearchResult] | None:
5154

5255
with contextlib.closing(_open_index(index_loc)) as db:
5356
cur = db.execute(
54-
"SELECT filename, vec_distance_cosine(embedding, ?) AS distance "
55-
f"FROM pages ORDER BY distance LIMIT {RESULT_LIMIT}",
56-
(qblob,),
57+
"""
58+
SELECT filename, vec_distance_cosine(embedding, ?) AS distance
59+
FROM pages
60+
WHERE vec_distance_cosine(embedding, ?) <= ?
61+
ORDER BY distance""",
62+
(qblob, qblob, max_distance),
5763
)
5864
rows = cur.fetchall()
5965

@@ -109,15 +115,22 @@ def _substring_search(t: Transport, query: str) -> list[SearchResult]:
109115
return results[:RESULT_LIMIT]
110116

111117

112-
def search_field(t: Transport, index_loc: Path, query: str) -> list[SearchResult]:
113-
vector = _vector_search(index_loc, query)
114-
if vector:
118+
def search_field(
119+
t: Transport,
120+
index_loc: Path,
121+
query: str,
122+
max_distance: float = DEFAULT_MAX_DISTANCE,
123+
) -> list[SearchResult]:
124+
vector = _vector_search(index_loc, query, max_distance)
125+
if vector is not None:
115126
return vector
116127
return _substring_search(t, query)
117128

118129

119130
def search_all(
120-
field_list: list[config.Field], query: str
131+
field_list: list[config.Field],
132+
query: str,
133+
max_distance: float = DEFAULT_MAX_DISTANCE,
121134
) -> tuple[list[tuple[str, SearchResult]], list[tuple[str, str]]]:
122135
"""Search all fields, skipping unreachable ones.
123136
@@ -130,7 +143,7 @@ def search_all(
130143
try:
131144
t = fields.get_transport(field)
132145
loc = fields.index_location(field)
133-
for result in search_field(t, loc, query):
146+
for result in search_field(t, loc, query, max_distance):
134147
results.append((field.name, result))
135148
except (TransportError, click.ClickException) as e:
136149
errors.append((field.name, str(e)))

‎tests/test_cli.py‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -826,6 +826,9 @@ def test_schema_output(cli_runner):
826826
catalog = next(c for c in data["commands"] if c["name"] == "catalog")
827827
sort_by = next(p for p in catalog["params"] if p["name"] == "sort_by")
828828
assert set(sort_by["choices"]) == {"path", "title", "created", "updated"}
829+
search_entry = next(c for c in data["commands"] if c["name"] == "search")
830+
max_distance = next(p for p in search_entry["params"] if p["name"] == "max_distance")
831+
assert max_distance["default"] == 0.45
829832

830833

831834
def test_schema_meta_covers_every_command():
@@ -926,6 +929,44 @@ def test_search_json(cli_runner, connected):
926929
assert any(r["filename"] == "gamma.md" and r["field"] == "notes" for r in data)
927930

928931

932+
def test_search_max_distance_excludes_all(cli_runner, connected, fake_embed):
933+
_cfg_path, _field_path = connected
934+
cli_runner.invoke(cli.cli, ["index", "--field", "notes"])
935+
result = cli_runner.invoke(
936+
cli.cli, ["search", "--field", "notes", "--max-distance", "0.01", "beta"]
937+
)
938+
assert result.exit_code == 1
939+
assert result.stdout == ""
940+
assert "No matching results found." in result.stderr
941+
942+
943+
def test_search_max_distance_returns_all(cli_runner, connected, fake_embed):
944+
_cfg_path, _field_path = connected
945+
cli_runner.invoke(cli.cli, ["index", "--field", "notes"])
946+
result = cli_runner.invoke(
947+
cli.cli, ["search", "--field", "notes", "--max-distance", "1.0", "beta"]
948+
)
949+
assert result.exit_code == 0
950+
for name in ("index.md", "alpha.md", "beta.md", "gamma.md"):
951+
assert name in result.output
952+
953+
954+
def test_search_no_results_exit_code(cli_runner, connected):
955+
_cfg_path, _field_path = connected
956+
result = cli_runner.invoke(cli.cli, ["search", "--field", "notes", "zzzz-not-there"])
957+
assert result.exit_code == 1
958+
assert result.stdout == ""
959+
assert "No matching results found." in result.stderr
960+
961+
962+
def test_search_json_no_results_exit_code(cli_runner, connected):
963+
_cfg_path, _field_path = connected
964+
result = cli_runner.invoke(cli.cli, ["search", "--json", "--field", "notes", "zzzz-not-there"])
965+
assert result.exit_code == 1
966+
assert json.loads(result.stdout) == []
967+
assert "No matching results found." in result.stderr
968+
969+
929970
def test_search_multi_field_prefix(cli_runner, config_env, field_dir, tmp_path):
930971
field2 = tmp_path / "field2"
931972
field2.mkdir()

‎tests/test_search.py‎

Lines changed: 30 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -20,35 +20,45 @@ def test_vector_ranking_exact_match(field_dir, fake_embed, monkeypatch):
2020
}
2121

2222

23-
def test_vector_no_threshold_returns_ranked(field_dir, monkeypatch):
24-
def controlled(texts):
25-
out = []
26-
for t in texts:
27-
if t.startswith("search_document:"):
28-
if "Alpha" in t:
29-
out.append([1.0] + [0.0] * 767)
30-
else:
31-
out.append([0.0, 1.0] + [0.0] * 766)
32-
else:
33-
out.append([1.0, 0.1] + [0.0] * 766)
34-
return out
35-
36-
monkeypatch.setattr("memoryfield_tool.embed.embed_texts", controlled)
23+
def _polarised_embed(texts: list[str]) -> list[list[float]]:
24+
"""Docs mentioning Alpha sit at similarity 0.995 to the query; all others at 0.0995."""
25+
out = []
26+
for t in texts:
27+
if t.startswith("search_document:") and "Alpha" in t:
28+
out.append([1.0] + [0.0] * 767)
29+
elif t.startswith("search_document:"):
30+
out.append([0.0, 1.0] + [0.0] * 766)
31+
else:
32+
out.append([1.0, 0.1] + [0.0] * 766)
33+
return out
34+
35+
36+
def test_vector_threshold_filters_below_default(field_dir, monkeypatch):
37+
monkeypatch.setattr("memoryfield_tool.embed.embed_texts", _polarised_embed)
3738
index.build_index(transport.local(field_dir), index.index_path(field_dir), progress=False)
38-
3939
results = search.search_field(
4040
transport.local(field_dir), index.index_path(field_dir), "some query"
4141
)
42+
assert [r.filename for r in results] == ["alpha.md"]
43+
44+
45+
def test_vector_large_max_distance_returns_ranked(field_dir, monkeypatch):
46+
monkeypatch.setattr("memoryfield_tool.embed.embed_texts", _polarised_embed)
47+
index.build_index(transport.local(field_dir), index.index_path(field_dir), progress=False)
48+
results = search.search_field(
49+
transport.local(field_dir),
50+
index.index_path(field_dir),
51+
"some query",
52+
max_distance=1.0,
53+
)
4254
filenames = [r.filename for r in results]
55+
assert set(filenames) == {"alpha.md", "beta.md", "gamma.md", "index.md"}
4356
assert filenames[0] == "alpha.md"
44-
assert "beta.md" in filenames
45-
assert "gamma.md" in filenames
46-
assert "index.md" in filenames
4757
assert all(r.distance is not None for r in results)
4858
assert results[0].distance < results[1].distance
4959

5060

51-
def test_vector_results_limited_to_20(field_dir, fake_embed):
61+
def test_vector_returns_all_above_default_threshold(field_dir, fake_embed):
5262
for i in range(25):
5363
(field_dir / f"page-{i:02d}.md").write_text(
5464
f"---\ntitle: Page {i}\n---\n\nbody {i}\n", encoding="utf-8"
@@ -57,7 +67,7 @@ def test_vector_results_limited_to_20(field_dir, fake_embed):
5767
results = search.search_field(
5868
transport.local(field_dir), index.index_path(field_dir), "anything"
5969
)
60-
assert len(results) == 20
70+
assert len(results) == 29
6171

6272

6373
def test_vector_result_without_frontmatter(field_dir, fake_embed):

0 commit comments

Comments
 (0)