Skip to content

Commit 4d64aa0

Browse files
committed
feat: enhanced search
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 8b7f1d3 commit 4d64aa0

16 files changed

Lines changed: 811 additions & 83 deletions

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,8 @@ list_directory(dir_name, depth) - Browse directory contents with filtering
396396
**Search & Discovery:**
397397
```
398398
search(query, page, page_size) - Search across your knowledge base
399+
search_notes(query, page, page_size, search_type, types, entity_types, after_date, metadata_filters, tags, status, project) - Search with filters
400+
search_by_metadata(filters, limit, offset, project) - Structured frontmatter search
399401
```
400402

401403
**Project Management:**

docs/ARCHITECTURE.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,15 +214,28 @@ Example tool using typed client:
214214

215215
```python
216216
@mcp.tool()
217-
async def search_notes(query: str, project: str | None = None) -> SearchResponse:
217+
async def search_notes(
218+
query: str,
219+
project: str | None = None,
220+
metadata_filters: dict | None = None,
221+
tags: list[str] | None = None,
222+
status: str | None = None,
223+
) -> SearchResponse:
218224
async with get_client() as client:
219225
active_project = await get_active_project(client, project)
220226

221227
# Import client inside function to avoid circular imports
222228
from basic_memory.mcp.clients import SearchClient
229+
from basic_memory.schemas.search import SearchQuery
223230

231+
search_query = SearchQuery(
232+
text=query,
233+
metadata_filters=metadata_filters,
234+
tags=tags,
235+
status=status,
236+
)
224237
search_client = SearchClient(client, active_project.external_id)
225-
return await search_client.search(query)
238+
return await search_client.search(search_query.model_dump())
226239
```
227240

228241
## Sync Coordination

docs/ai-assistant-guide-extended.md

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1038,6 +1038,35 @@ recent_decisions = await search_notes(
10381038
)
10391039
```
10401040

1041+
**Structured frontmatter filters**:
1042+
1043+
```python
1044+
# Filter by tags and status
1045+
results = await search_notes(
1046+
query="authentication",
1047+
tags=["security"],
1048+
status="in-progress",
1049+
project="main"
1050+
)
1051+
1052+
# Complex metadata filters (supports $in, $gt, $gte, $lt, $lte, $between)
1053+
results = await search_notes(
1054+
query="api design",
1055+
metadata_filters={
1056+
"type": "spec",
1057+
"priority": {"$in": ["high", "critical"]},
1058+
"tags": ["architecture"]
1059+
},
1060+
project="main"
1061+
)
1062+
1063+
# Metadata-only search
1064+
results = await search_by_metadata(
1065+
filters={"type": "spec", "status": "in-progress"},
1066+
project="main"
1067+
)
1068+
```
1069+
10411070
### Search Types
10421071

10431072
**Text search (default)**:
@@ -2861,7 +2890,7 @@ contents = await list_directory(
28612890

28622891
### Search & Discovery
28632892

2864-
**search_notes(query, page, page_size, search_type, types, entity_types, after_date, project)**
2893+
**search_notes(query, page, page_size, search_type, types, entity_types, after_date, metadata_filters, tags, status, project)**
28652894
- Search across knowledge base
28662895
- Parameters:
28672896
- `query` (required): Search query
@@ -2871,6 +2900,9 @@ contents = await list_directory(
28712900
- `types` (optional): Entity type filter
28722901
- `entity_types` (optional): Observation category filter
28732902
- `after_date` (optional): Date filter (ISO format)
2903+
- `metadata_filters` (optional): Structured frontmatter filters (dict)
2904+
- `tags` (optional): Frontmatter tags filter (list)
2905+
- `status` (optional): Frontmatter status filter (string)
28742906
- `project` (required unless default_project_mode): Target project
28752907
- Returns: Matching entities with scores
28762908
- Example:
@@ -2883,6 +2915,22 @@ results = await search_notes(
28832915
)
28842916
```
28852917

2918+
**search_by_metadata(filters, limit, offset, project)**
2919+
- Metadata-only search using structured frontmatter
2920+
- Parameters:
2921+
- `filters` (required): Dict of field -> value (supports $in, $gt/$gte/$lt/$lte, $between)
2922+
- `limit` (optional): Max results (default: 20)
2923+
- `offset` (optional): Pagination offset (default: 0)
2924+
- `project` (required unless default_project_mode): Target project
2925+
- Returns: Matching entities
2926+
- Example:
2927+
```python
2928+
results = await search_by_metadata(
2929+
filters={"type": "spec", "status": "in-progress"},
2930+
project="main"
2931+
)
2932+
```
2933+
28862934
### Project Management
28872935

28882936
**list_memory_projects()**
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
"""Add structured metadata indexes for entity frontmatter
2+
3+
Revision ID: d7e8f9a0b1c2
4+
Revises: g9a0b3c4d5e6
5+
Create Date: 2026-01-31 12:00:00.000000
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
import sqlalchemy as sa
12+
from alembic import op
13+
from sqlalchemy import text
14+
15+
16+
def column_exists(connection, table: str, column: str) -> bool:
17+
"""Check if a column exists in a table (idempotent migration support)."""
18+
if connection.dialect.name == "postgresql":
19+
result = connection.execute(
20+
text(
21+
"SELECT 1 FROM information_schema.columns "
22+
"WHERE table_name = :table AND column_name = :column"
23+
),
24+
{"table": table, "column": column},
25+
)
26+
return result.fetchone() is not None
27+
# SQLite
28+
result = connection.execute(text(f"PRAGMA table_info({table})"))
29+
columns = [row[1] for row in result]
30+
return column in columns
31+
32+
33+
def index_exists(connection, index_name: str) -> bool:
34+
"""Check if an index exists (idempotent migration support)."""
35+
if connection.dialect.name == "postgresql":
36+
result = connection.execute(
37+
text("SELECT 1 FROM pg_indexes WHERE indexname = :index_name"),
38+
{"index_name": index_name},
39+
)
40+
return result.fetchone() is not None
41+
# SQLite
42+
result = connection.execute(
43+
text("SELECT 1 FROM sqlite_master WHERE type='index' AND name = :index_name"),
44+
{"index_name": index_name},
45+
)
46+
return result.fetchone() is not None
47+
48+
49+
# revision identifiers, used by Alembic.
50+
revision: str = "d7e8f9a0b1c2"
51+
down_revision: Union[str, None] = "g9a0b3c4d5e6"
52+
branch_labels: Union[str, Sequence[str], None] = None
53+
depends_on: Union[str, Sequence[str], None] = None
54+
55+
56+
def upgrade() -> None:
57+
"""Add JSONB/GiN indexes for Postgres and generated columns for SQLite."""
58+
connection = op.get_bind()
59+
dialect = connection.dialect.name
60+
61+
if dialect == "postgresql":
62+
# Ensure JSONB for efficient indexing
63+
result = connection.execute(
64+
text(
65+
"SELECT data_type FROM information_schema.columns "
66+
"WHERE table_name = 'entity' AND column_name = 'entity_metadata'"
67+
)
68+
).fetchone()
69+
if result and result[0] != "jsonb":
70+
op.execute(
71+
"ALTER TABLE entity ALTER COLUMN entity_metadata "
72+
"TYPE jsonb USING entity_metadata::jsonb"
73+
)
74+
75+
# General JSONB GIN index
76+
op.execute(
77+
"CREATE INDEX IF NOT EXISTS idx_entity_metadata_gin "
78+
"ON entity USING GIN (entity_metadata jsonb_path_ops)"
79+
)
80+
81+
# Common field indexes
82+
op.execute(
83+
"CREATE INDEX IF NOT EXISTS idx_entity_tags_json "
84+
"ON entity USING GIN ((entity_metadata -> 'tags'))"
85+
)
86+
op.execute(
87+
"CREATE INDEX IF NOT EXISTS idx_entity_frontmatter_type "
88+
"ON entity ((entity_metadata ->> 'type'))"
89+
)
90+
op.execute(
91+
"CREATE INDEX IF NOT EXISTS idx_entity_frontmatter_status "
92+
"ON entity ((entity_metadata ->> 'status'))"
93+
)
94+
return
95+
96+
# SQLite: add generated columns for common frontmatter fields
97+
if not column_exists(connection, "entity", "tags_json"):
98+
op.add_column(
99+
"entity",
100+
sa.Column(
101+
"tags_json",
102+
sa.Text(),
103+
sa.Computed("json_extract(entity_metadata, '$.tags')", persisted=True),
104+
),
105+
)
106+
if not column_exists(connection, "entity", "frontmatter_status"):
107+
op.add_column(
108+
"entity",
109+
sa.Column(
110+
"frontmatter_status",
111+
sa.Text(),
112+
sa.Computed("json_extract(entity_metadata, '$.status')", persisted=True),
113+
),
114+
)
115+
if not column_exists(connection, "entity", "frontmatter_type"):
116+
op.add_column(
117+
"entity",
118+
sa.Column(
119+
"frontmatter_type",
120+
sa.Text(),
121+
sa.Computed("json_extract(entity_metadata, '$.type')", persisted=True),
122+
),
123+
)
124+
125+
# Index generated columns
126+
if not index_exists(connection, "idx_entity_tags_json"):
127+
op.create_index("idx_entity_tags_json", "entity", ["tags_json"])
128+
if not index_exists(connection, "idx_entity_frontmatter_status"):
129+
op.create_index("idx_entity_frontmatter_status", "entity", ["frontmatter_status"])
130+
if not index_exists(connection, "idx_entity_frontmatter_type"):
131+
op.create_index("idx_entity_frontmatter_type", "entity", ["frontmatter_type"])
132+
133+
134+
def downgrade() -> None:
135+
"""Best-effort downgrade (drop indexes, revert JSONB on Postgres)."""
136+
connection = op.get_bind()
137+
dialect = connection.dialect.name
138+
139+
if dialect == "postgresql":
140+
op.execute("DROP INDEX IF EXISTS idx_entity_frontmatter_status")
141+
op.execute("DROP INDEX IF EXISTS idx_entity_frontmatter_type")
142+
op.execute("DROP INDEX IF EXISTS idx_entity_tags_json")
143+
op.execute("DROP INDEX IF EXISTS idx_entity_metadata_gin")
144+
op.execute(
145+
"ALTER TABLE entity ALTER COLUMN entity_metadata "
146+
"TYPE json USING entity_metadata::json"
147+
)
148+
return
149+
150+
# SQLite: drop indexes (dropping generated columns requires table rebuild)
151+
op.execute("DROP INDEX IF EXISTS idx_entity_frontmatter_status")
152+
op.execute("DROP INDEX IF EXISTS idx_entity_frontmatter_type")
153+
op.execute("DROP INDEX IF EXISTS idx_entity_tags_json")

src/basic_memory/cli/commands/tool.py

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""CLI tool commands for Basic Memory."""
22

3+
import json
34
import sys
45
from typing import Annotated, List, Optional
56

@@ -288,7 +289,10 @@ def recent_activity(
288289

289290
@tool_app.command("search-notes")
290291
def search_notes(
291-
query: str,
292+
query: Annotated[
293+
Optional[str],
294+
typer.Argument(help="Search query string (optional when using metadata filters)"),
295+
] = "",
292296
permalink: Annotated[bool, typer.Option("--permalink", help="Search permalink values")] = False,
293297
title: Annotated[bool, typer.Option("--title", help="Search title values")] = False,
294298
project: Annotated[
@@ -301,6 +305,26 @@ def search_notes(
301305
Optional[str],
302306
typer.Option("--after_date", help="Search results after date, eg. '2d', '1 week'"),
303307
] = None,
308+
tags: Annotated[
309+
Optional[List[str]],
310+
typer.Option("--tag", help="Filter by frontmatter tag (repeatable)"),
311+
] = None,
312+
status: Annotated[
313+
Optional[str],
314+
typer.Option("--status", help="Filter by frontmatter status"),
315+
] = None,
316+
note_types: Annotated[
317+
Optional[List[str]],
318+
typer.Option("--type", help="Filter by frontmatter type (repeatable)"),
319+
] = None,
320+
meta: Annotated[
321+
Optional[List[str]],
322+
typer.Option("--meta", help="Filter by frontmatter key=value (repeatable)"),
323+
] = None,
324+
filter_json: Annotated[
325+
Optional[str],
326+
typer.Option("--filter", help="JSON metadata filter (advanced)"),
327+
] = None,
304328
page: int = 1,
305329
page_size: int = 10,
306330
local: bool = typer.Option(
@@ -335,21 +359,57 @@ def search_notes(
335359
)
336360
raise typer.Exit(1)
337361

362+
# Build metadata filters from --filter and --meta
363+
metadata_filters = {}
364+
if filter_json:
365+
try:
366+
metadata_filters = json.loads(filter_json)
367+
if not isinstance(metadata_filters, dict):
368+
raise ValueError("Metadata filter JSON must be an object")
369+
except json.JSONDecodeError as e:
370+
typer.echo(f"Invalid JSON for --filter: {e}", err=True)
371+
raise typer.Exit(1)
372+
373+
if meta:
374+
for item in meta:
375+
if "=" not in item:
376+
typer.echo(
377+
f"Invalid --meta entry '{item}'. Use key=value format.",
378+
err=True,
379+
)
380+
raise typer.Exit(1)
381+
key, value = item.split("=", 1)
382+
key = key.strip()
383+
if not key:
384+
typer.echo(f"Invalid --meta entry '{item}'.", err=True)
385+
raise typer.Exit(1)
386+
metadata_filters[key] = value
387+
388+
if not metadata_filters:
389+
metadata_filters = None
390+
338391
# set search type
339-
search_type = ("permalink" if permalink else None,)
340-
search_type = ("permalink_match" if permalink and "*" in query else None,)
341-
search_type = ("title" if title else None,)
342-
search_type = "text" if search_type is None else search_type
392+
search_type = "text"
393+
if permalink:
394+
search_type = "permalink"
395+
if query and "*" in query:
396+
search_type = "permalink"
397+
if title:
398+
search_type = "title"
343399

344400
with force_routing(local=local, cloud=cloud):
345401
results = run_with_cleanup(
346402
mcp_search.fn(
347-
query,
403+
query or "",
348404
project_name,
349405
search_type=search_type,
350406
page=page,
351407
after_date=after_date,
352408
page_size=page_size,
409+
types=note_types,
410+
metadata_filters=metadata_filters,
411+
tags=tags,
412+
status=status,
353413
)
354414
)
355415
# Use json module for more controlled serialization

0 commit comments

Comments
 (0)