Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion src/basic_memory/cli/commands/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,23 @@
import typer
from loguru import logger
from rich.console import Console
from rich.markup import escape
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn

from basic_memory.cli.app import app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.config import ConfigManager, ProjectMode

console = Console()
REINDEX_ERROR_SUMMARY_MAX_LENGTH = 240


def _reindex_error_summary(message: str) -> str:
"""Collapse one error to a bounded single-line CLI summary."""
single_line = " ".join(message.split())
if len(single_line) <= REINDEX_ERROR_SUMMARY_MAX_LENGTH:
return single_line
return f"{single_line[: REINDEX_ERROR_SUMMARY_MAX_LENGTH - 3].rstrip()}..."


def _is_basic_memory_mcp(cmdline: list[str]) -> bool:
Expand Down Expand Up @@ -364,6 +374,8 @@ async def _reindex(
console.print(f"[red]Project '{project}' not found.[/red]")
raise typer.Exit(1)

embedding_entities_total = 0
embedding_errors_total = 0
for proj in projects:
console.print(f"\n[bold]Project: [cyan]{proj.name}[/cyan][/bold]")

Expand Down Expand Up @@ -455,11 +467,20 @@ def on_progress(entity_id, index, total):
progress.update(task, completed=stats["total_entities"])

console.print(
f" [green]done[/green] Embeddings complete: "
" [green]done[/green] Embeddings complete "
f"([cyan]index={escape(stats['vector_index'])}[/cyan], "
f"[cyan]model={escape(stats['embedding_model'])}[/cyan]): "
f"{stats['embedded']} entities embedded, "
f"{stats['skipped']} skipped, "
f"{stats['errors']} errors"
)
if stats["sample_errors"]:
console.print(
" [yellow]Representative error:[/yellow] "
f"{escape(_reindex_error_summary(stats['sample_errors'][0]))}"
)
embedding_entities_total += stats["total_entities"]
embedding_errors_total += stats["errors"]
if stats["total_entities"] == 0 and not search:
# Trigger: embeddings-only mode found no database entities.
# Why: this mode rebuilds derived vectors; it does not discover files.
Expand All @@ -471,6 +492,13 @@ def on_progress(entity_id, index, total):
"or start the MCP server and retry after its initial index completes."
)

# Trigger: every entity attempted across the selected projects failed to embed.
# Why: requested search work and other project summaries must still finish first.
# Outcome: the command preserves useful output but no longer reports false success.
if embedding_entities_total > 0 and embedding_errors_total == embedding_entities_total:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Exclude skipped entities from the all-failed denominator

When a selected project contains any opted-out or already-current entity, total_entities includes it while errors does not, so every entity that actually required embedding can fail and this comparison still exits 0. For example, one embed: false note plus one eligible note that hits the manifest-ownership guard yields total_entities=2, skipped=1, and errors=1, followed by Reindex complete!; base the failure decision on entities that required vector work rather than all database entities.

AGENTS.md reference: AGENTS.md:L132-L133

Useful? React with 👍 / 👎.

console.print("\n[red]Reindex failed: all vector embedding attempts failed.[/red]")
raise typer.Exit(code=1)

console.print("\n[green]Reindex complete![/green]")
finally:
await db.shutdown_db()
35 changes: 34 additions & 1 deletion src/basic_memory/repository/semantic_vector_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@

from basic_memory import db
from basic_memory.repository.semantic_chunking import VectorChunkRecord
from basic_memory.runtime.vector_sync import VectorSyncBatchResult
from basic_memory.runtime.vector_sync import (
VECTOR_SYNC_SAMPLE_ERROR_LIMIT,
VectorSyncBatchResult,
)
from basic_memory.schemas.search import SearchItemType

if TYPE_CHECKING: # pragma: no cover - import cycle exists only for static analysis
Expand All @@ -38,6 +41,19 @@ class _VectorSyncBatchAccumulator:
queue_wait_seconds_total: float = 0.0
embed_seconds_total: float = 0.0
write_seconds_total: float = 0.0
sample_errors: list[str] = field(default_factory=list)

def record_error(self, error: BaseException) -> None:
"""Retain a small, distinct sample without flooding callers."""
message = " ".join(str(error).split())
if not message:
message = type(error).__name__
if (
message in self.sample_errors
or len(self.sample_errors) >= VECTOR_SYNC_SAMPLE_ERROR_LIMIT
):
return
self.sample_errors.append(message)

def freeze(
self,
Expand All @@ -46,6 +62,8 @@ def freeze(
synced_entity_ids: set[int],
deferred_entity_ids: set[int],
failed_entity_ids: set[int],
vector_index: str,
embedding_model: str,
) -> VectorSyncBatchResult:
"""Return the immutable result after entity terminal states settle."""
return VectorSyncBatchResult(
Expand All @@ -55,6 +73,9 @@ def freeze(
entities_deferred=len(deferred_entity_ids),
entities_skipped=self.entities_skipped,
failed_entity_ids=tuple(sorted(failed_entity_ids)),
sample_errors=tuple(self.sample_errors),
vector_index=vector_index,
embedding_model=embedding_model,
chunks_total=self.chunks_total,
chunks_skipped=self.chunks_skipped,
embedding_jobs_total=self.embedding_jobs_total,
Expand Down Expand Up @@ -274,11 +295,15 @@ async def sync_entity_vectors_internal(
assert repository._embedding_provider is not None

total_entities = len(entity_ids)
vector_index = repository._semantic_vector_index_name
embedding_model = repository._embedding_model_key()
if total_entities == 0:
return VectorSyncBatchResult(
entities_total=0,
entities_synced=0,
entities_failed=0,
vector_index=vector_index,
embedding_model=embedding_model,
)
batch_start = time.perf_counter()
backend_name = type(repository).__name__.removesuffix("SearchRepository").lower()
Expand Down Expand Up @@ -336,6 +361,7 @@ def emit_progress(entity_id: int) -> None:
if not continue_on_error:
raise prepared
failed_entity_ids.add(entity_id)
batch_counters.record_error(prepared)
logger.warning(
"Vector batch sync entity prepare failed: project_id={project_id} "
"entity_id={entity_id} error={error}",
Expand Down Expand Up @@ -431,6 +457,7 @@ def emit_progress(entity_id: int) -> None:
except Exception as exc:
if not continue_on_error:
raise
batch_counters.record_error(exc)
affected_entity_ids = sorted({job.entity_id for job in flush_jobs})
failed_entity_ids.update(affected_entity_ids)
synced_entity_ids.difference_update(affected_entity_ids)
Expand Down Expand Up @@ -471,6 +498,7 @@ def emit_progress(entity_id: int) -> None:
except Exception as exc:
if not continue_on_error:
raise
batch_counters.record_error(exc)
affected_entity_ids = sorted({job.entity_id for job in flush_jobs})
failed_entity_ids.update(affected_entity_ids)
synced_entity_ids.difference_update(affected_entity_ids)
Expand All @@ -494,6 +522,9 @@ def emit_progress(entity_id: int) -> None:
# Outcome: fail-safe marks these entities as failed to avoid false positives.
if entity_runtime:
orphan_runtime_entities = sorted(entity_runtime.keys())
batch_counters.record_error(
RuntimeError("Vector sync left unfinished entities after flushes.")
)
failed_entity_ids.update(orphan_runtime_entities)
synced_entity_ids.difference_update(orphan_runtime_entities)
deferred_entity_ids.difference_update(orphan_runtime_entities)
Expand All @@ -514,6 +545,8 @@ def emit_progress(entity_id: int) -> None:
synced_entity_ids=synced_entity_ids,
deferred_entity_ids=deferred_entity_ids,
failed_entity_ids=failed_entity_ids,
vector_index=vector_index,
embedding_model=embedding_model,
)

logger.info(
Expand Down
5 changes: 5 additions & 0 deletions src/basic_memory/runtime/vector_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

type EntityId = int

VECTOR_SYNC_SAMPLE_ERROR_LIMIT = 3


@dataclass(frozen=True, slots=True)
class VectorSyncBatchResult:
Expand All @@ -16,6 +18,9 @@ class VectorSyncBatchResult:
entities_deferred: int = 0
entities_skipped: int = 0
failed_entity_ids: tuple[EntityId, ...] = ()
sample_errors: tuple[str, ...] = ()
vector_index: str = ""
embedding_model: str = ""
chunks_total: int = 0
chunks_skipped: int = 0
embedding_jobs_total: int = 0
Expand Down
55 changes: 32 additions & 23 deletions src/basic_memory/services/search_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@
from basic_memory.repository.search_query import relaxed_query_words
from basic_memory.schemas.base import normalize_note_type
from basic_memory.schemas.search import SearchQuery, SearchItemType, SearchRetrievalMode
from basic_memory.runtime.vector_sync import VectorSyncBatchResult
from basic_memory.runtime.vector_sync import (
VECTOR_SYNC_SAMPLE_ERROR_LIMIT,
VectorSyncBatchResult,
)
from basic_memory.services import FileService

# Maximum size for content_stems field to stay under Postgres's 8KB index row limit.
Expand Down Expand Up @@ -516,11 +519,7 @@ async def sync_entity_vectors_batch(
) -> VectorSyncBatchResult:
"""Refresh vector chunks for a batch of entities."""
if not entity_ids:
return VectorSyncBatchResult(
entities_total=0,
entities_synced=0,
entities_failed=0,
)
return await self.repository.sync_entity_vectors_batch([])
Comment on lines 521 to +522

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid initializing the vector backend for an empty batch

When reindex --embeddings is run before any entities have been indexed, this new call reaches sync_entity_vectors_internal, which invokes _ensure_vector_tables() before checking the empty list; for an external index such as unavailable Milvus, that connects to or even creates the remote collection and can fail before the CLI prints its intended No indexed entities found guidance. Preserve index/model identity without executing backend synchronization when there is no work.

Useful? React with 👍 / 👎.


async with db.scoped_session(self.session_maker) as session:
entities_by_id = {
Expand Down Expand Up @@ -550,31 +549,19 @@ async def sync_entity_vectors_batch(
cleanup_task = (
self.repository.sync_entity_vectors_batch(unknown_ids) if unknown_ids else None
)
eligible_task = (
self.repository.sync_entity_vectors_batch(
eligible_entity_ids,
progress_callback=progress_callback,
)
if eligible_entity_ids
else None
eligible_task = self.repository.sync_entity_vectors_batch(
eligible_entity_ids,
progress_callback=progress_callback,
)
repository_results = [
result
for result in await asyncio.gather(
cleanup_task if cleanup_task is not None else asyncio.sleep(0, result=None),
eligible_task if eligible_task is not None else asyncio.sleep(0, result=None),
eligible_task,
)
if result is not None
]

if not repository_results:
return VectorSyncBatchResult(
entities_total=len(entity_ids),
entities_synced=0,
entities_failed=0,
entities_skipped=len(opted_out_ids),
)

batch_result = VectorSyncBatchResult(
entities_total=len(entity_ids),
entities_synced=sum(result.entities_synced for result in repository_results),
Expand All @@ -590,6 +577,25 @@ async def sync_entity_vectors_batch(
for result in repository_results
for failed_entity_id in result.failed_entity_ids
),
sample_errors=tuple(
dict.fromkeys(
error
for result in repository_results
for error in result.sample_errors
)
)[:VECTOR_SYNC_SAMPLE_ERROR_LIMIT],
vector_index=next(
(result.vector_index for result in repository_results if result.vector_index),
"",
),
embedding_model=next(
(
result.embedding_model
for result in repository_results
if result.embedding_model
),
"",
),
chunks_total=sum(result.chunks_total for result in repository_results),
chunks_skipped=sum(result.chunks_skipped for result in repository_results),
embedding_jobs_total=sum(result.embedding_jobs_total for result in repository_results),
Expand All @@ -616,7 +622,7 @@ async def reindex_vectors(
eligible entity re-embeds from scratch.

Returns:
dict with stats: total_entities, embedded, skipped, errors
dict with counts, sampled errors, and the active vector index/model identity
"""
async with db.scoped_session(self.session_maker) as session:
entities = await self.entity_repository.find_all(session)
Expand All @@ -638,6 +644,9 @@ async def reindex_vectors(
"embedded": batch_result.entities_synced,
"skipped": batch_result.entities_skipped,
"errors": batch_result.entities_failed,
"sample_errors": batch_result.sample_errors,
"vector_index": batch_result.vector_index,
"embedding_model": batch_result.embedding_model,
}

for failed_entity_id in batch_result.failed_entity_ids:
Expand Down
Loading
Loading