Skip to content

Commit f93c430

Browse files
committed
perf(core): add lightweight strict link resolution
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 1ecbfc5 commit f93c430

4 files changed

Lines changed: 200 additions & 32 deletions

File tree

src/basic_memory/repository/entity_repository.py

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,17 @@ async def get_by_id(self, entity_id: int) -> Optional[Entity]: # pragma: no cov
4545
async with db.scoped_session(self.session_maker) as session:
4646
return await self.select_by_id(session, entity_id)
4747

48-
async def get_by_external_id(self, external_id: str) -> Optional[Entity]:
48+
async def _find_one_by_query(self, query, *, load_relations: bool) -> Optional[Entity]:
49+
"""Return one entity row with optional eager loading."""
50+
if load_relations:
51+
return await self.find_one(query)
52+
53+
result = await self.execute_query(query, use_query_options=False)
54+
return result.scalars().one_or_none()
55+
56+
async def get_by_external_id(
57+
self, external_id: str, *, load_relations: bool = True
58+
) -> Optional[Entity]:
4959
"""Get entity by external UUID.
5060
5161
Args:
@@ -54,21 +64,21 @@ async def get_by_external_id(self, external_id: str) -> Optional[Entity]:
5464
Returns:
5565
Entity if found, None otherwise
5666
"""
57-
query = (
58-
self.select().where(Entity.external_id == external_id).options(*self.get_load_options())
59-
)
60-
return await self.find_one(query)
67+
query = self.select().where(Entity.external_id == external_id)
68+
return await self._find_one_by_query(query, load_relations=load_relations)
6169

62-
async def get_by_permalink(self, permalink: str) -> Optional[Entity]:
70+
async def get_by_permalink(
71+
self, permalink: str, *, load_relations: bool = True
72+
) -> Optional[Entity]:
6373
"""Get entity by permalink.
6474
6575
Args:
6676
permalink: Unique identifier for the entity
6777
"""
68-
query = self.select().where(Entity.permalink == permalink).options(*self.get_load_options())
69-
return await self.find_one(query)
78+
query = self.select().where(Entity.permalink == permalink)
79+
return await self._find_one_by_query(query, load_relations=load_relations)
7080

71-
async def get_by_title(self, title: str) -> Sequence[Entity]:
81+
async def get_by_title(self, title: str, *, load_relations: bool = True) -> Sequence[Entity]:
7282
"""Get entities by title, ordered by shortest path first.
7383
7484
When multiple entities share the same title (in different folders),
@@ -82,23 +92,20 @@ async def get_by_title(self, title: str) -> Sequence[Entity]:
8292
self.select()
8393
.where(Entity.title == title)
8494
.order_by(func.length(Entity.file_path), Entity.file_path)
85-
.options(*self.get_load_options())
8695
)
87-
result = await self.execute_query(query)
96+
result = await self.execute_query(query, use_query_options=load_relations)
8897
return list(result.scalars().all())
8998

90-
async def get_by_file_path(self, file_path: Union[Path, str]) -> Optional[Entity]:
99+
async def get_by_file_path(
100+
self, file_path: Union[Path, str], *, load_relations: bool = True
101+
) -> Optional[Entity]:
91102
"""Get entity by file_path.
92103
93104
Args:
94105
file_path: Path to the entity file (will be converted to string internally)
95106
"""
96-
query = (
97-
self.select()
98-
.where(Entity.file_path == Path(file_path).as_posix())
99-
.options(*self.get_load_options())
100-
)
101-
return await self.find_one(query)
107+
query = self.select().where(Entity.file_path == Path(file_path).as_posix())
108+
return await self._find_one_by_query(query, load_relations=load_relations)
102109

103110
# -------------------------------------------------------------------------
104111
# Lightweight methods for permalink resolution (no eager loading)

src/basic_memory/services/entity_service.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -242,9 +242,17 @@ async def create_or_update_entity(self, schema: EntitySchema) -> Tuple[EntityMod
242242

243243
# Try to find existing entity using strict resolution (no fuzzy search)
244244
# This prevents incorrectly matching similar file paths like "Node A.md" and "Node C.md"
245-
existing = await self.link_resolver.resolve_link(schema.file_path, strict=True)
245+
existing = await self.link_resolver.resolve_link(
246+
schema.file_path,
247+
strict=True,
248+
load_relations=False,
249+
)
246250
if not existing and schema.permalink:
247-
existing = await self.link_resolver.resolve_link(schema.permalink, strict=True)
251+
existing = await self.link_resolver.resolve_link(
252+
schema.permalink,
253+
strict=True,
254+
load_relations=False,
255+
)
248256

249257
if existing:
250258
logger.debug(f"Found existing entity: {existing.file_path}")
@@ -946,7 +954,11 @@ async def update_entity_relations(
946954
# Use strict=True to disable fuzzy search - only exact matches should create resolved relations
947955
# This ensures forward references (links to non-existent entities) remain unresolved (to_id=NULL)
948956
lookup_tasks = [
949-
self.link_resolver.resolve_link(rel.target, strict=True)
957+
self.link_resolver.resolve_link(
958+
rel.target,
959+
strict=True,
960+
load_relations=False,
961+
)
950962
for rel in markdown.relations
951963
]
952964

@@ -1053,7 +1065,11 @@ async def edit_entity(
10531065
action="edit",
10541066
phase="resolve_entity",
10551067
):
1056-
entity = await self.link_resolver.resolve_link(identifier, strict=True)
1068+
entity = await self.link_resolver.resolve_link(
1069+
identifier,
1070+
strict=True,
1071+
load_relations=False,
1072+
)
10571073
if not entity:
10581074
raise EntityNotFoundError(f"Entity not found: {identifier}")
10591075

src/basic_memory/services/link_resolver.py

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ async def resolve_link(
4747
use_search: bool = True,
4848
strict: bool = False,
4949
source_path: Optional[str] = None,
50+
load_relations: bool = True,
5051
) -> Optional[Entity]:
5152
"""Resolve a markdown link to a permalink.
5253
@@ -56,6 +57,7 @@ async def resolve_link(
5657
strict: If True, only exact matches are allowed (no fuzzy search fallback)
5758
source_path: Optional path of the source file containing the link.
5859
Used to prefer notes closer to the source (context-aware resolution).
60+
load_relations: When False, skip eager loading and return a lightweight entity row.
5961
"""
6062
logger.trace(f"Resolving link: {link_text} (source: {source_path})")
6163

@@ -70,7 +72,10 @@ async def resolve_link(
7072
# UUIDs also match the stored external_id values.
7173
try:
7274
canonical_id = str(uuid_mod.UUID(clean_text))
73-
entity = await self.entity_repository.get_by_external_id(canonical_id)
75+
entity = await self.entity_repository.get_by_external_id(
76+
canonical_id,
77+
load_relations=load_relations,
78+
)
7479
if entity:
7580
logger.debug(f"Found entity by external_id: {entity.permalink}")
7681
return entity
@@ -98,6 +103,7 @@ async def resolve_link(
98103
strict=strict,
99104
source_path=None,
100105
project_permalink=project.permalink,
106+
load_relations=load_relations,
101107
)
102108

103109
current_project_permalink = await self._get_current_project_permalink()
@@ -109,6 +115,7 @@ async def resolve_link(
109115
strict=strict,
110116
source_path=source_path,
111117
project_permalink=current_project_permalink,
118+
load_relations=load_relations,
112119
)
113120
if resolved:
114121
return resolved
@@ -136,6 +143,7 @@ async def resolve_link(
136143
strict=strict,
137144
source_path=None,
138145
project_permalink=project.permalink,
146+
load_relations=load_relations,
139147
)
140148

141149
def _normalize_link_text(self, link_text: str) -> Tuple[str, Optional[str]]:
@@ -176,6 +184,7 @@ async def _resolve_in_project(
176184
strict: bool,
177185
source_path: Optional[str],
178186
project_permalink: Optional[str],
187+
load_relations: bool,
179188
) -> Optional[Entity]:
180189
"""Resolve a link within a specific project scope."""
181190
clean_text = link_text
@@ -223,12 +232,18 @@ async def _resolve_in_project(
223232
# Try with .md extension
224233
if not relative_path.endswith(".md"):
225234
relative_path_md = f"{relative_path}.md"
226-
entity = await entity_repository.get_by_file_path(relative_path_md)
235+
entity = await entity_repository.get_by_file_path(
236+
relative_path_md,
237+
load_relations=load_relations,
238+
)
227239
if entity:
228240
return entity
229241

230242
# Try as-is (already has extension or is a permalink)
231-
entity = await entity_repository.get_by_file_path(relative_path)
243+
entity = await entity_repository.get_by_file_path(
244+
relative_path,
245+
load_relations=load_relations,
246+
)
232247
if entity:
233248
return entity
234249

@@ -242,12 +257,18 @@ async def _resolve_in_project(
242257

243258
# Check permalink match
244259
for candidate_permalink in permalink_candidates:
245-
permalink_entity = await entity_repository.get_by_permalink(candidate_permalink)
260+
permalink_entity = await entity_repository.get_by_permalink(
261+
candidate_permalink,
262+
load_relations=load_relations,
263+
)
246264
if permalink_entity and permalink_entity.id not in [c.id for c in candidates]:
247265
candidates.append(permalink_entity)
248266

249267
# Check title matches
250-
title_entities = await entity_repository.get_by_title(clean_text)
268+
title_entities = await entity_repository.get_by_title(
269+
clean_text,
270+
load_relations=load_relations,
271+
)
251272
for entity in title_entities:
252273
# Avoid duplicates (permalink match might also be in title matches)
253274
if entity.id not in [c.id for c in candidates]:
@@ -263,29 +284,41 @@ async def _resolve_in_project(
263284
# Standard resolution (no source context): permalink first, then title
264285
# 1. Try exact permalink match first (most efficient)
265286
for candidate_permalink in permalink_candidates:
266-
entity = await entity_repository.get_by_permalink(candidate_permalink)
287+
entity = await entity_repository.get_by_permalink(
288+
candidate_permalink,
289+
load_relations=load_relations,
290+
)
267291
if entity:
268292
logger.debug(f"Found exact permalink match: {entity.permalink}")
269293
return entity
270294

271295
# 2. Try exact title match
272-
found = await entity_repository.get_by_title(clean_text)
296+
found = await entity_repository.get_by_title(
297+
clean_text,
298+
load_relations=load_relations,
299+
)
273300
if found:
274301
# Return first match (shortest path) if no source context
275302
entity = found[0]
276303
logger.debug(f"Found title match: {entity.title}")
277304
return entity
278305

279306
# 3. Try file path
280-
found_path = await entity_repository.get_by_file_path(clean_text)
307+
found_path = await entity_repository.get_by_file_path(
308+
clean_text,
309+
load_relations=load_relations,
310+
)
281311
if found_path:
282312
logger.debug(f"Found entity with path: {found_path.file_path}")
283313
return found_path
284314

285315
# 4. Try file path with .md extension if not already present
286316
if not clean_text.endswith(".md") and "/" in clean_text:
287317
file_path_with_md = f"{clean_text}.md"
288-
found_path_md = await entity_repository.get_by_file_path(file_path_with_md)
318+
found_path_md = await entity_repository.get_by_file_path(
319+
file_path_with_md,
320+
load_relations=load_relations,
321+
)
289322
if found_path_md:
290323
logger.debug(f"Found entity with path (with .md): {found_path_md.file_path}")
291324
return found_path_md
@@ -309,7 +342,10 @@ async def _resolve_in_project(
309342
f"Selected best match from {len(results)} results: {best_match.permalink}"
310343
)
311344
if best_match.permalink:
312-
return await entity_repository.get_by_permalink(best_match.permalink)
345+
return await entity_repository.get_by_permalink(
346+
best_match.permalink,
347+
load_relations=load_relations,
348+
)
313349

314350
# if we couldn't find anything then return None
315351
return None

0 commit comments

Comments
 (0)