basic-memory 0.23.1.dev2__py3-none-any.whl → 0.23.1.dev20__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,77 @@
1
+ """Index complete PostgreSQL note content for full-text search.
2
+
3
+ Revision ID: 7f6a2b8c9d10
4
+ Revises: 2d26b287813b
5
+ Create Date: 2026-08-24 22:00:00.000000
6
+
7
+ """
8
+
9
+ from typing import Sequence, Union
10
+
11
+ from alembic import op
12
+
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = "7f6a2b8c9d10"
16
+ down_revision: Union[str, None] = "2d26b287813b"
17
+ branch_labels: Union[str, Sequence[str], None] = None
18
+ depends_on: Union[str, Sequence[str], None] = None
19
+
20
+
21
+ def upgrade() -> None:
22
+ """Index complete note bodies as bounded PostgreSQL FTS chunks."""
23
+ connection = op.get_bind()
24
+ if connection.dialect.name == "postgresql":
25
+ op.execute("""
26
+ CREATE TABLE search_index_fts_chunks (
27
+ project_id INTEGER NOT NULL,
28
+ search_index_id INTEGER NOT NULL,
29
+ search_index_type VARCHAR NOT NULL,
30
+ chunk_index INTEGER NOT NULL,
31
+ chunk_text TEXT NOT NULL,
32
+ textsearchable_index_col tsvector GENERATED ALWAYS AS (
33
+ to_tsvector('english', chunk_text)
34
+ ) STORED,
35
+ PRIMARY KEY (project_id, search_index_id, search_index_type, chunk_index),
36
+ FOREIGN KEY (search_index_id, search_index_type, project_id)
37
+ REFERENCES search_index(id, type, project_id)
38
+ ON UPDATE CASCADE ON DELETE CASCADE
39
+ )
40
+ """)
41
+ op.execute("""
42
+ -- PostgreSQL ignores lexemes at 2 KiB and above. Stepping 5,952
43
+ -- characters leaves a conservative 2,048-character overlap, so
44
+ -- every indexable lexeme split at one edge is complete in the next.
45
+ INSERT INTO search_index_fts_chunks (
46
+ project_id,
47
+ search_index_id,
48
+ search_index_type,
49
+ chunk_index,
50
+ chunk_text
51
+ )
52
+ SELECT
53
+ search_index.project_id,
54
+ search_index.id,
55
+ search_index.type,
56
+ (chunk_start - 1) / 5952,
57
+ substring(search_index.content_snippet FROM chunk_start FOR 8000)
58
+ FROM search_index
59
+ CROSS JOIN LATERAL generate_series(
60
+ 1,
61
+ length(search_index.content_snippet),
62
+ 5952
63
+ ) AS chunk_start
64
+ WHERE search_index.content_snippet IS NOT NULL
65
+ AND search_index.content_snippet <> ''
66
+ """)
67
+ op.execute("""
68
+ CREATE INDEX idx_search_index_fts_chunks_fts
69
+ ON search_index_fts_chunks USING gin(textsearchable_index_col)
70
+ """)
71
+
72
+
73
+ def downgrade() -> None:
74
+ """Remove the bounded full-content PostgreSQL FTS chunks."""
75
+ connection = op.get_bind()
76
+ if connection.dialect.name == "postgresql":
77
+ op.execute("DROP TABLE IF EXISTS search_index_fts_chunks")
@@ -221,6 +221,38 @@ def _preload_lazy_console_modules() -> None:
221
221
  """
222
222
  import rich._emoji_codes # noqa: F401
223
223
  import typer.rich_utils # noqa: F401
224
+ from rich.cells import cell_len
225
+
226
+ # Trigger: rich defers its Unicode cell-width table (`rich._unicode_data.
227
+ # unicode<version>`) until the first character it cannot measure with the
228
+ # ASCII fast path in `_cell_len`.
229
+ # Why: status messages echo captured `brew`/`uv` output, which carries
230
+ # non-ASCII characters (curly quotes, em dashes, warning glyphs), so the
231
+ # deferred import lands after the upgrade removed our files. Importing the
232
+ # module by name would hard-code a table version; calling `cell_len` uses
233
+ # rich's own resolution and honors UNICODE_VERSION like the print path does.
234
+ # Outcome: the table rich will reach for is resolved and cached up front.
235
+ cell_len("\u2500\u2018\u2713")
236
+
237
+
238
+ def print_update_status(console: Console, text: str, style: str) -> None:
239
+ """Print an update status line that cannot fail the command.
240
+
241
+ Trigger: the line is printed after an in-place upgrade may already have
242
+ replaced this installation on disk.
243
+ Why: `_preload_lazy_console_modules` can only preload the deferred imports
244
+ we know about today, and rich/typer are free to add more. By the time this
245
+ prints, the upgrade has already succeeded -- a status line must never be
246
+ what turns it into a traceback and a non-zero exit.
247
+ Outcome: fall back to a plain, unstyled write that needs no new imports.
248
+ """
249
+ try:
250
+ console.print(f"[{style}]{text}[/{style}]")
251
+ except Exception as exc:
252
+ logger.warning(
253
+ f"Rich console print failed after update, falling back to plain output: {exc}"
254
+ )
255
+ print(text)
224
256
 
225
257
 
226
258
  def _save_last_checked_timestamp(config_manager: ConfigManager, checked_at: datetime) -> None:
@@ -468,11 +500,11 @@ def maybe_run_periodic_auto_update(
468
500
  }:
469
501
  out = console or Console()
470
502
  if result.status == AutoUpdateStatus.UPDATED:
471
- out.print(f"[green]{result.message}[/green]")
503
+ print_update_status(out, f"{result.message}", "green")
472
504
  elif result.status == AutoUpdateStatus.FAILED:
473
505
  error_detail = f" {result.error}" if result.error else ""
474
- out.print(f"[yellow]{result.message}{error_detail}[/yellow]")
506
+ print_update_status(out, f"{result.message}{error_detail}", "yellow")
475
507
  elif result.message:
476
- out.print(f"[cyan]{result.message}[/cyan]")
508
+ print_update_status(out, f"{result.message}", "cyan")
477
509
 
478
510
  return result
@@ -81,6 +81,23 @@ async def _delete_doctor_project(
81
81
  await _delete_doctor_project_locally(project_name, project_id)
82
82
 
83
83
 
84
+ async def _read_materialized_api_note(api_file: Path, file_path: str) -> str:
85
+ """Wait for the accepted local write, then read its canonical file."""
86
+ # Deferred: importing the materialization runtime at CLI module load would slow every
87
+ # command, while doctor is the only command that needs to observe its write immediately.
88
+ from basic_memory.index.note_content_materialization import drain_pending_materializations
89
+
90
+ # Trigger: production accepts note content before its markdown file is materialized.
91
+ # Why: doctor verifies the complete DB -> file contract, not only write acceptance.
92
+ # Outcome: wait for the same deferred work drained at normal CLI shutdown before checking.
93
+ await drain_pending_materializations()
94
+
95
+ if not api_file.exists():
96
+ raise ValueError(f"API note file missing: {file_path}")
97
+
98
+ return api_file.read_text(encoding="utf-8")
99
+
100
+
84
101
  async def run_doctor() -> None:
85
102
  """Run local consistency checks for file <-> database flows."""
86
103
  # Deferred: the markdown parsing stack is only needed while the checks run,
@@ -132,10 +149,7 @@ async def run_doctor() -> None:
132
149
  api_result = await knowledge_client.create_entity(api_note.model_dump())
133
150
 
134
151
  api_file = project_path / api_result.file_path
135
- if not api_file.exists():
136
- raise ValueError(f"API note file missing: {api_result.file_path}")
137
-
138
- api_text = api_file.read_text(encoding="utf-8")
152
+ api_text = await _read_materialized_api_note(api_file, api_result.file_path)
139
153
  if api_note_title not in api_text:
140
154
  raise ValueError("API note content missing from file")
141
155
 
@@ -4,7 +4,7 @@ import typer
4
4
  from rich.console import Console
5
5
 
6
6
  from basic_memory.cli.app import app
7
- from basic_memory.cli.auto_update import AutoUpdateStatus, run_auto_update
7
+ from basic_memory.cli.auto_update import AutoUpdateStatus, print_update_status, run_auto_update
8
8
 
9
9
  console = Console()
10
10
 
@@ -22,19 +22,21 @@ def update(
22
22
 
23
23
  if result.status == AutoUpdateStatus.FAILED:
24
24
  detail = f" {result.error}" if result.error else ""
25
- console.print(f"[red]{result.message or 'Update failed.'}{detail}[/red]")
25
+ print_update_status(console, f"{result.message or 'Update failed.'}{detail}", "red")
26
26
  raise typer.Exit(1)
27
27
 
28
28
  if result.status == AutoUpdateStatus.UPDATED:
29
- console.print(f"[green]{result.message or 'Basic Memory updated successfully.'}[/green]")
29
+ print_update_status(
30
+ console, f"{result.message or 'Basic Memory updated successfully.'}", "green"
31
+ )
30
32
  return
31
33
 
32
34
  if result.status == AutoUpdateStatus.UP_TO_DATE:
33
- console.print(f"[green]{result.message or 'Basic Memory is up to date.'}[/green]")
35
+ print_update_status(console, f"{result.message or 'Basic Memory is up to date.'}", "green")
34
36
  return
35
37
 
36
38
  if result.status == AutoUpdateStatus.UPDATE_AVAILABLE:
37
- console.print(f"[cyan]{result.message or 'Update available.'}[/cyan]")
39
+ print_update_status(console, f"{result.message or 'Update available.'}", "cyan")
38
40
  return
39
41
 
40
- console.print(f"[dim]{result.message or 'No update action was performed.'}[/dim]")
42
+ print_update_status(console, f"{result.message or 'No update action was performed.'}", "dim")
@@ -13,6 +13,15 @@ from markdown_it.token import Token
13
13
  # those bracket prefixes stay ordinary content (issue #1219).
14
14
  _TIMESTAMP_VALUE = r"\d{1,3}:\d{2}(?::\d{2})?(?:[.,]\d{1,3})?"
15
15
  _TIMESTAMP_CATEGORY = re.compile(rf"^{_TIMESTAMP_VALUE}(?:\s+-\s+{_TIMESTAMP_VALUE})?$")
16
+ _LINKS_TO_DIRECTIVE = re.compile(r"\s+#bm:links_to\s*$")
17
+
18
+
19
+ def remove_links_to_directive(content: str) -> tuple[str, bool]:
20
+ """Remove an exact terminal ``#bm:links_to`` directive from content."""
21
+ match = _LINKS_TO_DIRECTIVE.search(content)
22
+ if not match:
23
+ return content, False
24
+ return content[: match.start()].rstrip(), True
16
25
 
17
26
 
18
27
  def _is_task_marker_category(category: str) -> bool:
@@ -47,6 +56,7 @@ def is_observation(token: Token) -> bool:
47
56
  return False
48
57
  # Use token.tag which contains the actual content for test tokens, fallback to content
49
58
  content = (token.tag or token.content).strip()
59
+ content, _ = remove_links_to_directive(content)
50
60
  if not content: # pragma: no cover
51
61
  return False
52
62
  # if it's a markdown_task, return false
@@ -74,6 +84,7 @@ def parse_observation(token: Token) -> Dict[str, Any]:
74
84
 
75
85
  # Use token.tag which contains the actual content for test tokens, fallback to content
76
86
  content = (token.tag or token.content).strip()
87
+ content, _ = remove_links_to_directive(content)
77
88
 
78
89
  # Parse [category] with regex; a timestamp-shaped prefix is not a category, so a
79
90
  # hashtag-promoted transcript line keeps its timecode inside the content instead.
@@ -325,17 +336,19 @@ def relation_plugin(md: MarkdownIt) -> None:
325
336
 
326
337
  # Only process inline tokens
327
338
  if token.type == "inline":
339
+ content = token.tag or token.content
340
+ content_without_directive, has_directive = remove_links_to_directive(content)
341
+
328
342
  # Check for explicit relations in list items
329
- if in_list_item and is_explicit_relation(token):
343
+ if in_list_item and not has_directive and is_explicit_relation(token):
330
344
  rel = parse_relation(token)
331
345
  if rel:
332
346
  token.meta["relations"] = [rel]
333
347
 
334
348
  # Always check for inline links in any text
335
349
  else:
336
- content = token.tag or token.content
337
350
  if "[[" in content:
338
- rels = parse_inline_relations(content)
351
+ rels = parse_inline_relations(content_without_directive)
339
352
  if rels:
340
353
  token.meta["relations"] = token.meta.get("relations", []) + rels
341
354
 
@@ -109,12 +109,15 @@ async def write_note(
109
109
  - Explicit: `- relation_type [[Entity]] (optional context)`
110
110
  - Quoted: `- "multi word relation type" [[Entity]] (optional context)`
111
111
  - Quoted: `- 'multi word relation type' [[Entity]] (optional context)`
112
+ - Disambiguation: Add `#bm:links_to` when prose before `[[Entity]]`
113
+ must not be treated as a single-token relation type
112
114
  - Inline: Any other `[[Entity]]` reference creates a `links_to` relation
113
115
 
114
116
  Examples:
115
117
  `- depends_on [[Content Parser]] (Need for semantic extraction)`
116
118
  `- "based on" [[Design Notes]]`
117
119
  `- 'in response to' [[Incident Review]]`
120
+ `- Mother [[Alice]] #bm:links_to`
118
121
  `- implements [[Search Spec]] (Initial implementation)`
119
122
  `- This feature extends [[Base Design]] and uses [[Core Utils]]`
120
123
 
@@ -33,7 +33,11 @@ CREATE TABLE IF NOT EXISTS search_index (
33
33
  created_at TIMESTAMP WITH TIME ZONE,
34
34
  updated_at TIMESTAMP WITH TIME ZONE,
35
35
  textsearchable_index_col tsvector GENERATED ALWAYS AS (
36
- to_tsvector('english', coalesce(title, '') || ' ' || coalesce(content_stems, ''))
36
+ to_tsvector(
37
+ 'english',
38
+ coalesce(title, '') || ' ' ||
39
+ coalesce(content_stems, '')
40
+ )
37
41
  ) STORED,
38
42
  PRIMARY KEY (id, type, project_id),
39
43
  FOREIGN KEY (project_id) REFERENCES project(id) ON DELETE CASCADE
@@ -44,6 +48,30 @@ CREATE_POSTGRES_SEARCH_INDEX_FTS = DDL("""
44
48
  CREATE INDEX IF NOT EXISTS idx_search_index_fts ON search_index USING gin(textsearchable_index_col)
45
49
  """)
46
50
 
51
+ # Full note bodies are stored in bounded child rows so one unusually large note
52
+ # cannot exceed PostgreSQL's per-tsvector size limit.
53
+ CREATE_POSTGRES_SEARCH_INDEX_FTS_CHUNKS_TABLE = DDL("""
54
+ CREATE TABLE IF NOT EXISTS search_index_fts_chunks (
55
+ project_id INTEGER NOT NULL,
56
+ search_index_id INTEGER NOT NULL,
57
+ search_index_type VARCHAR NOT NULL,
58
+ chunk_index INTEGER NOT NULL,
59
+ chunk_text TEXT NOT NULL,
60
+ textsearchable_index_col tsvector GENERATED ALWAYS AS (
61
+ to_tsvector('english', chunk_text)
62
+ ) STORED,
63
+ PRIMARY KEY (project_id, search_index_id, search_index_type, chunk_index),
64
+ FOREIGN KEY (search_index_id, search_index_type, project_id)
65
+ REFERENCES search_index(id, type, project_id)
66
+ ON UPDATE CASCADE ON DELETE CASCADE
67
+ )
68
+ """)
69
+
70
+ CREATE_POSTGRES_SEARCH_INDEX_FTS_CHUNKS_INDEX = DDL("""
71
+ CREATE INDEX IF NOT EXISTS idx_search_index_fts_chunks_fts
72
+ ON search_index_fts_chunks USING gin(textsearchable_index_col)
73
+ """)
74
+
47
75
  CREATE_POSTGRES_SEARCH_INDEX_METADATA = DDL("""
48
76
  CREATE INDEX IF NOT EXISTS idx_search_index_metadata_gin ON search_index USING gin(metadata jsonb_path_ops)
49
77
  """)
@@ -45,6 +45,66 @@ from basic_memory.repository.pgvector_index import PgVectorIndex
45
45
  from basic_memory.schemas.search import SearchItemType, SearchRetrievalMode
46
46
 
47
47
 
48
+ POSTGRES_FTS_CHUNK_SIZE = 8_000
49
+ # PostgreSQL ignores lexemes at 2 KiB and above. A 2,048-character overlap is
50
+ # therefore conservative for every indexable lexeme, including multi-byte text:
51
+ # any token split at one 8,000-character edge is complete in the next chunk.
52
+ POSTGRES_FTS_CHUNK_OVERLAP = 2_048
53
+ _TSQUERY_OPERAND_PATTERN = re.compile(r"'(?:''|[^'])*'(?::\*)?|[^\s&|!()]+")
54
+ _TSQUERY_WORD_PATTERN = re.compile(r"[^\W_]+(?:'[^\W_]+)?", re.UNICODE)
55
+
56
+
57
+ def _iter_fts_chunks(content: str | None) -> list[tuple[int, str]]:
58
+ """Split full note text without losing an indexable lexeme at a chunk edge."""
59
+ if not content:
60
+ return []
61
+
62
+ step = POSTGRES_FTS_CHUNK_SIZE - POSTGRES_FTS_CHUNK_OVERLAP
63
+ return [
64
+ (chunk_index, content[start : start + POSTGRES_FTS_CHUNK_SIZE])
65
+ for chunk_index, start in enumerate(range(0, len(content), step))
66
+ ]
67
+
68
+
69
+ def _tsquery_operands(processed_text: str) -> list[tuple[str, str]]:
70
+ """Return unique (query operand, representative text) pairs in source order."""
71
+ operands: dict[str, str] = {}
72
+ for operand in _TSQUERY_OPERAND_PATTERN.findall(processed_text):
73
+ representative = operand.removesuffix(":*")
74
+ if representative.startswith("'") and representative.endswith("'"):
75
+ representative = representative[1:-1].replace("''", "'")
76
+ operands.setdefault(operand, representative)
77
+ continue
78
+
79
+ # An unquoted apostrophe is invalid tsquery syntax. Keep the literal
80
+ # word for the synthetic document, but quote and escape its probe so a
81
+ # strict syntax failure can proceed to the relaxed retry.
82
+ if "'" in representative:
83
+ escaped = "'{}'".format(representative.replace("'", "''"))
84
+ safe_operand = f"{escaped}:*" if operand.endswith(":*") else escaped
85
+ operands.setdefault(safe_operand, representative)
86
+ continue
87
+
88
+ # PostgreSQL legitimately parses punctuation inside operands such as
89
+ # ``v0.13.0b2:*`` and ``auth-service:*``. Preserve those bytes so the
90
+ # synthetic document is tokenized the same way as the original note.
91
+ if "<" not in representative and ">" not in representative:
92
+ operands.setdefault(operand, representative)
93
+ continue
94
+
95
+ # A malformed strict operand (for example ``foo<bar:*``) must not poison
96
+ # the later relaxed retry. Split punctuation into the same safe word pieces
97
+ # that PostgreSQL will lex, while the original full tsquery still determines
98
+ # whether the strict attempt raises and falls back.
99
+ is_prefix = operand.endswith(":*")
100
+ words = _TSQUERY_WORD_PATTERN.findall(representative)
101
+ for word in words or [representative]:
102
+ escaped_word = "'{}'".format(word.replace("'", "''")) if "'" in word else word
103
+ safe_operand = f"{escaped_word}:*" if is_prefix else escaped_word
104
+ operands.setdefault(safe_operand, word)
105
+ return list(operands.items())
106
+
107
+
48
108
  def _strip_nul_from_row(row_data: dict[str, Any]) -> dict[str, Any]:
49
109
  """Strip NUL bytes from all string values in a row dict.
50
110
 
@@ -199,9 +259,76 @@ class PostgresSearchRepository(SearchRepositoryBase):
199
259
  """),
200
260
  insert_data,
201
261
  )
262
+ await self._replace_fts_chunks(session, [search_index_row])
202
263
  logger.debug(f"indexed row {search_index_row}")
203
264
  await session.commit()
204
265
 
266
+ async def _replace_fts_chunks(
267
+ self,
268
+ session: AsyncSession,
269
+ search_index_rows: Sequence[SearchIndexRow],
270
+ ) -> None:
271
+ """Replace bounded full-content FTS rows for one indexing batch."""
272
+ if not search_index_rows:
273
+ return
274
+
275
+ await session.execute(
276
+ text("""
277
+ DELETE FROM search_index_fts_chunks
278
+ WHERE project_id = :project_id
279
+ AND (search_index_id, search_index_type) IN (
280
+ SELECT search_index_id, search_index_type
281
+ FROM unnest(
282
+ CAST(:search_index_ids AS INTEGER[]),
283
+ CAST(:search_index_types AS VARCHAR[])
284
+ ) AS indexed_rows(search_index_id, search_index_type)
285
+ )
286
+ """),
287
+ {
288
+ "project_id": self.project_id,
289
+ "search_index_ids": [row.id for row in search_index_rows],
290
+ "search_index_types": [row.type for row in search_index_rows],
291
+ },
292
+ )
293
+
294
+ chunks = [
295
+ {
296
+ "search_index_id": row.id,
297
+ "search_index_type": row.type,
298
+ "chunk_index": chunk_index,
299
+ "chunk_text": chunk_text.replace("\x00", ""),
300
+ }
301
+ for row in search_index_rows
302
+ for chunk_index, chunk_text in _iter_fts_chunks(row.content_snippet)
303
+ ]
304
+ if not chunks:
305
+ return
306
+
307
+ await session.execute(
308
+ text("""
309
+ INSERT INTO search_index_fts_chunks (
310
+ project_id,
311
+ search_index_id,
312
+ search_index_type,
313
+ chunk_index,
314
+ chunk_text
315
+ )
316
+ SELECT
317
+ :project_id,
318
+ chunk.search_index_id,
319
+ chunk.search_index_type,
320
+ chunk.chunk_index,
321
+ chunk.chunk_text
322
+ FROM jsonb_to_recordset(CAST(:chunks AS JSONB)) AS chunk(
323
+ search_index_id INTEGER,
324
+ search_index_type VARCHAR,
325
+ chunk_index INTEGER,
326
+ chunk_text TEXT
327
+ )
328
+ """),
329
+ {"project_id": self.project_id, "chunks": json.dumps(chunks)},
330
+ )
331
+
205
332
  # ------------------------------------------------------------------
206
333
  # tsquery preparation (backend-specific)
207
334
  # ------------------------------------------------------------------
@@ -657,6 +784,7 @@ class PostgresSearchRepository(SearchRepositoryBase):
657
784
  """),
658
785
  insert_data_list,
659
786
  )
787
+ await self._replace_fts_chunks(session, search_index_rows)
660
788
  logger.debug(f"Bulk indexed {len(search_index_rows)} rows")
661
789
  await session.commit()
662
790
 
@@ -685,12 +813,14 @@ class PostgresSearchRepository(SearchRepositoryBase):
685
813
  search_item_types: Optional[List[SearchItemType]] = None,
686
814
  categories: Optional[List[str]] = None,
687
815
  metadata_filters: Optional[dict[str, Any]] = None,
816
+ allow_relaxed: bool = False,
688
817
  ) -> tuple[str, str, dict[str, Any], str, str]:
689
818
  """Build Postgres FTS FROM/WHERE params shared by search and count."""
690
819
  conditions = []
691
820
  params = {}
692
821
  order_by_clause = ""
693
822
  from_clause = "search_index"
823
+ document_vector_sql: str | None = None
694
824
 
695
825
  # Handle text search for title and content using tsvector
696
826
  if search_text:
@@ -701,10 +831,61 @@ class PostgresSearchRepository(SearchRepositoryBase):
701
831
  # Prepare search term for tsquery
702
832
  processed_text = self._prepare_search_term(search_text.strip())
703
833
  params["text"] = processed_text
704
- # Use @@ operator for tsvector matching
705
- conditions.append(
706
- "search_index.textsearchable_index_col @@ to_tsquery('english', :text)"
707
- )
834
+ probe_texts = [processed_text]
835
+ if allow_relaxed:
836
+ relaxed_text = self._relaxed_tsquery_text(search_text)
837
+ if relaxed_text:
838
+ probe_texts.append(relaxed_text)
839
+
840
+ candidate_operands: dict[str, None] = {}
841
+ for probe_text in probe_texts:
842
+ for operand, _representative in _tsquery_operands(probe_text):
843
+ candidate_operands.setdefault(operand, None)
844
+ if candidate_operands:
845
+ params["text_candidate"] = " | ".join(candidate_operands)
846
+
847
+ # Trigger: PostgreSQL can extract a required-positive query tree.
848
+ # Why: OR-ing its operands is a safe indexed superset even when
849
+ # terms live in different chunks. Pure/optional negation returns
850
+ # ``T`` and must retain all project rows for correct semantics.
851
+ # Outcome: ordinary and required-positive NOT queries use both
852
+ # GIN indexes; only genuinely unindexable negation scans the project.
853
+ from_clause = """
854
+ search_index JOIN (
855
+ SELECT
856
+ candidate_parent.project_id,
857
+ candidate_parent.id,
858
+ candidate_parent.type
859
+ FROM search_index AS candidate_parent
860
+ WHERE candidate_parent.project_id = :project_id
861
+ AND querytree(to_tsquery('english', :text)) <> 'T'
862
+ AND candidate_parent.textsearchable_index_col
863
+ @@ to_tsquery('english', :text_candidate)
864
+ UNION
865
+ SELECT
866
+ candidate_chunk.project_id,
867
+ candidate_chunk.search_index_id AS id,
868
+ candidate_chunk.search_index_type AS type
869
+ FROM search_index_fts_chunks AS candidate_chunk
870
+ WHERE candidate_chunk.project_id = :project_id
871
+ AND querytree(to_tsquery('english', :text)) <> 'T'
872
+ AND candidate_chunk.textsearchable_index_col
873
+ @@ to_tsquery('english', :text_candidate)
874
+ UNION
875
+ SELECT
876
+ candidate_all.project_id,
877
+ candidate_all.id,
878
+ candidate_all.type
879
+ FROM search_index AS candidate_all
880
+ WHERE candidate_all.project_id = :project_id
881
+ AND querytree(to_tsquery('english', :text)) = 'T'
882
+ ) AS fts_candidate
883
+ ON fts_candidate.project_id = search_index.project_id
884
+ AND fts_candidate.id = search_index.id
885
+ AND fts_candidate.type = search_index.type
886
+ """
887
+ document_vector_sql = self._document_fts_vector_sql(probe_texts, params)
888
+ conditions.append(f"{document_vector_sql} @@ to_tsquery('english', :text)")
708
889
 
709
890
  # Handle title search
710
891
  if title:
@@ -785,7 +966,7 @@ class PostgresSearchRepository(SearchRepositoryBase):
785
966
  # path parts instead of #>> / #> with interpolated paths.
786
967
  if metadata_filters:
787
968
  parsed_filters = parse_metadata_filters(metadata_filters)
788
- from_clause = "search_index JOIN entity ON search_index.entity_id = entity.id"
969
+ from_clause = f"{from_clause} JOIN entity ON search_index.entity_id = entity.id"
789
970
  metadata_expr = "entity.entity_metadata::jsonb"
790
971
 
791
972
  for idx, filt in enumerate(parsed_filters):
@@ -863,14 +1044,59 @@ class PostgresSearchRepository(SearchRepositoryBase):
863
1044
  # Build SQL with ts_rank() for scoring
864
1045
  # Note: If no text search, score will be NULL, so we use COALESCE to default to 0
865
1046
  if search_text and search_text.strip() and search_text.strip() != "*":
1047
+ assert document_vector_sql is not None
866
1048
  score_expr = (
867
- "ts_rank(search_index.textsearchable_index_col, to_tsquery('english', :text))"
1049
+ "GREATEST("
1050
+ f"ts_rank({document_vector_sql}, to_tsquery('english', :text)), "
1051
+ "ts_rank(search_index.textsearchable_index_col, to_tsquery('english', :text)), "
1052
+ "COALESCE((SELECT MAX(ts_rank("
1053
+ "fts_chunk.textsearchable_index_col, to_tsquery('english', :text))) "
1054
+ "FROM search_index_fts_chunks AS fts_chunk "
1055
+ "WHERE fts_chunk.project_id = search_index.project_id "
1056
+ "AND fts_chunk.search_index_id = search_index.id "
1057
+ "AND fts_chunk.search_index_type = search_index.type "
1058
+ "AND fts_chunk.textsearchable_index_col "
1059
+ "@@ to_tsquery('english', :text)), 0))"
868
1060
  )
869
1061
  else:
870
1062
  score_expr = "0"
871
1063
 
872
1064
  return from_clause, where_clause, params, order_by_clause, score_expr
873
1065
 
1066
+ @staticmethod
1067
+ def _document_fts_vector_sql(processed_texts: Sequence[str], params: dict[str, Any]) -> str:
1068
+ """Build a query-sized vector representing lexemes found anywhere in one item."""
1069
+ operands: dict[str, str] = {}
1070
+ for processed_text in processed_texts:
1071
+ for operand, representative in _tsquery_operands(processed_text):
1072
+ operands.setdefault(operand, representative)
1073
+
1074
+ present_lexemes: list[str] = []
1075
+ for index, (operand, representative) in enumerate(operands.items()):
1076
+ operand_param = f"text_operand_{index}"
1077
+ representative_param = f"text_representative_{index}"
1078
+ params[operand_param] = operand
1079
+ params[representative_param] = representative
1080
+ present_lexemes.append(
1081
+ "CASE WHEN (search_index.textsearchable_index_col "
1082
+ f"@@ to_tsquery('english', :{operand_param}) OR EXISTS ("
1083
+ "SELECT 1 FROM search_index_fts_chunks AS operand_chunk "
1084
+ "WHERE operand_chunk.project_id = search_index.project_id "
1085
+ "AND operand_chunk.search_index_id = search_index.id "
1086
+ "AND operand_chunk.search_index_type = search_index.type "
1087
+ "AND operand_chunk.textsearchable_index_col "
1088
+ f"@@ to_tsquery('english', :{operand_param}))) "
1089
+ f"THEN :{representative_param} ELSE '' END"
1090
+ )
1091
+
1092
+ if not present_lexemes:
1093
+ return "search_index.textsearchable_index_col"
1094
+
1095
+ # The synthesized text contains at most the query operands, never the note body.
1096
+ # This preserves document-wide Boolean semantics without recreating an unbounded vector.
1097
+ lexeme_array = f"ARRAY[{', '.join(present_lexemes)}]"
1098
+ return f"to_tsvector('english', array_to_string({lexeme_array}, ' '))"
1099
+
874
1100
  @override
875
1101
  async def search(
876
1102
  self,
@@ -930,6 +1156,7 @@ class PostgresSearchRepository(SearchRepositoryBase):
930
1156
  search_item_types=search_item_types,
931
1157
  categories=categories,
932
1158
  metadata_filters=metadata_filters,
1159
+ allow_relaxed=allow_relaxed,
933
1160
  )
934
1161
 
935
1162
  # set limit and offset
@@ -1111,6 +1338,7 @@ class PostgresSearchRepository(SearchRepositoryBase):
1111
1338
  search_item_types=search_item_types,
1112
1339
  categories=categories,
1113
1340
  metadata_filters=metadata_filters,
1341
+ allow_relaxed=allow_relaxed,
1114
1342
  )
1115
1343
  sql = f"SELECT COUNT(*) FROM {from_clause} WHERE {where_clause}"
1116
1344
  logger.trace(f"Count {sql} params: {params}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: basic-memory
3
- Version: 0.23.1.dev2
3
+ Version: 0.23.1.dev20
4
4
  Summary: Local-first knowledge management combining Zettelkasten with knowledge graphs
5
5
  Project-URL: Homepage, https://github.com/basicmachines-co/basic-memory
6
6
  Project-URL: Repository, https://github.com/basicmachines-co/basic-memory
@@ -23,6 +23,7 @@ basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink
23
23
  basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py,sha256=xTSVvB-caYl_CdVfzhj-eqdQEOxQ12NIHOc8dBSqCMQ,4779
24
24
  basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py,sha256=oFtjzJd37HUMezuCUonK-yRc867_9L5zxd4CnLhFIi4,4655
25
25
  basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py,sha256=8bO4yg_v6klCJz0E15sDI2pDtsmAL2nNO5ACpKGLYQo,499
26
+ basic_memory/alembic/versions/7f6a2b8c9d10_index_full_postgres_note_content.py,sha256=Kf4-_uBKe28MbpbKPsxZEKQzCYpIxv79iWxbKUZI91w,2857
26
27
  basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py,sha256=DYlXlRYO3Ms6x9GbrFGWgj1QEzsdKHx-4AA4lSVM7y0,1587
27
28
  basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py,sha256=i42eQyO08RWf4exnH1q1PKw_EJFsHAG-T4pdLTMSpiA,1818
28
29
  basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py,sha256=Oh_J5Jw85J3LvknfizFP7_1SoelZM_A7IjBau8lRpfk,1671
@@ -67,7 +68,7 @@ basic_memory/cli/__init__.py,sha256=arcKLAWRDhPD7x5t80MlviZeYzwHZ0GZigyy3NKVoGk,
67
68
  basic_memory/cli/analytics.py,sha256=GlyEFzvTRh2A8E1iFzRVLYVMn1_torhcbptfKNYU59w,3858
68
69
  basic_memory/cli/app.py,sha256=Tsz6bcWDWJWDBqdPnvEOQrUWG2UcdWriradzWj04r50,6318
69
70
  basic_memory/cli/auth.py,sha256=tQSE3uHhGPTfxkCcJ1FR2HDkmyu_E9uoaxHUENFUhAA,11523
70
- basic_memory/cli/auto_update.py,sha256=S48DmIcVSpaSCO70KriJ6jHQql5wLZewqame7RiBbgs,18060
71
+ basic_memory/cli/auto_update.py,sha256=tYgJ8wjIuEwHGg4lh3UwMjFTija6_58GHAjJl1YeHr4,19690
71
72
  basic_memory/cli/container.py,sha256=5oIdLTEBsVNl_3q1blh50TSThCO_tEpDaO-cXITeQnk,2299
72
73
  basic_memory/cli/main.py,sha256=LF5u9iyvGDnn8DsfORzQOWVXFR0-TL2HHqntB2b8MFI,1160
73
74
  basic_memory/cli/promo.py,sha256=c_CxzCifl_1ZHPT7-Kd84TLG1Gt7sfXXmCmRYSTCerM,4592
@@ -76,7 +77,7 @@ basic_memory/cli/commands/ci.py,sha256=MW4pepm9472iMzbryA4p-r-SG-OPnFftshB-0S8LB
76
77
  basic_memory/cli/commands/command_utils.py,sha256=9_xUeeg1-nsnUw3MdNXOc_rhVROqhXqEDcTD3E_ga3A,5008
77
78
  basic_memory/cli/commands/config.py,sha256=8IBqPrcgklhvHkVFG8v9laO0PqZkvfLsZ_jgbPkFCb4,8700
78
79
  basic_memory/cli/commands/db.py,sha256=aQcjHpff15pfpDTKKpV-zz38TIJNfkOFQsCWggqbbU8,22596
79
- basic_memory/cli/commands/doctor.py,sha256=83KDPyNpMC4uuVR--5RPNtDl0H3tW6M-wciYCCAvMDQ,9963
80
+ basic_memory/cli/commands/doctor.py,sha256=84AifKWTxcCibUOkZvJQ8P7PKuPK_P36KLy5d6aNvsQ,10755
80
81
  basic_memory/cli/commands/format.py,sha256=pgXkk0ptU2quN0XzjURuapA5AqrIBu1y4j6J1t52ovI,6753
81
82
  basic_memory/cli/commands/hook.py,sha256=tNtGzf4E40bhZQL6-SJwQpq8ehQmxm7EIae7O7-3jJg,60641
82
83
  basic_memory/cli/commands/import_chatgpt.py,sha256=7SUGG_mmme0BuL4ov0lhb98VkTUldunCv-ehQirtwZE,3870
@@ -92,7 +93,7 @@ basic_memory/cli/commands/routing.py,sha256=jGI7Sx0_E_M1sLqMjY3lFSE_uNEzKDhj-DkI
92
93
  basic_memory/cli/commands/schema.py,sha256=zGCrCM-PwWdZSVxD2_BF3dV_gdjtqp-qKFWVoitLAys,14260
93
94
  basic_memory/cli/commands/status.py,sha256=u0nLbQPJAhGAtJzkQY6iVw6qDzGJL2Sv9V5osmliYzk,7345
94
95
  basic_memory/cli/commands/tool.py,sha256=4q-WmCU6SftT3J_DkFH--dOLYmiCXd8k0_n-OhrRBNw,63870
95
- basic_memory/cli/commands/update.py,sha256=ah_Igpz4MiP53HKp7J1AEkjTcM_fjmJ6TIKrps4XNTk,1324
96
+ basic_memory/cli/commands/update.py,sha256=ncOWOkk4AXvpLdJP-SQslIIPaybDoF02DMDnqkfcteE,1417
96
97
  basic_memory/cli/commands/workspace.py,sha256=ZpP-AicC4RJNCsDl5gSxJBDZVmcn0Q9TBtFsGOdeVNk,1043
97
98
  basic_memory/cli/commands/cloud/__init__.py,sha256=tHu2fkrQ0EHN3uFz4lSxpt0Msa7aJgAXQnZ3m-nRC74,1005
98
99
  basic_memory/cli/commands/cloud/api_client.py,sha256=CeL0n1hwISh6eNvMsfwc5GJ02TVlpysSYbqcDfIEU2k,5092
@@ -206,7 +207,7 @@ basic_memory/man/bm.1,sha256=iQhnHzJHSN3WRdMIphv22AnWTYR2SErzeKwCqQJ3LzQ,3406
206
207
  basic_memory/markdown/__init__.py,sha256=DdzioCWtDnKaq05BHYLgL_78FawEHLpLXnp-kPSVfIc,501
207
208
  basic_memory/markdown/entity_parser.py,sha256=7RUL7QXOQc00ez7OFOJB6kvsD8yY6K_yVD4VGSHOPxc,12629
208
209
  basic_memory/markdown/markdown_processor.py,sha256=YK8KF8NY0N9nQM3I_8Pcxybf5SPOPnIL5kzmFBFBlx0,7429
209
- basic_memory/markdown/plugins.py,sha256=PaeSERjUtxpAcMfV0QH63IUKeaPaFszTNbJYydYyils,12249
210
+ basic_memory/markdown/plugins.py,sha256=y_gfzywOC4r-WNTYPPnn-Px0eiCTwdm2MJnFXJEfIwg,12831
210
211
  basic_memory/markdown/schemas.py,sha256=IHi4sm40ew4zNjb359imwiPJXGO6cIni_1f3YNVyZPs,2798
211
212
  basic_memory/markdown/utils.py,sha256=EP8P8uDNrhcnvpgc_iaczIQK3r9Z6k1MHMKCcZvTzm0,4122
212
213
  basic_memory/mcp/__init__.py,sha256=dsDOhKqjYeIbCULbHIxfcItTbqudEuEg1Np86eq0GEQ,35
@@ -255,7 +256,7 @@ basic_memory/mcp/tools/ui_sdk.py,sha256=bVkNJUk0hH6j3q47wVZYbGMe4Xxe0UwrGvbCMC0p
255
256
  basic_memory/mcp/tools/utils.py,sha256=qMjO7UpCfGf5r3XsCCpelB-MPwSn8eGegLrh5SgEP08,31924
256
257
  basic_memory/mcp/tools/view_note.py,sha256=PtW_qIKYa1cu0W7Y-DlQYHcIx6M1FqWxMSH9pSpZHKU,2846
257
258
  basic_memory/mcp/tools/workspaces.py,sha256=m2G0vdYt2l084JtXq_t9a9V3_n6HnoT8MNSOvdclrXU,2826
258
- basic_memory/mcp/tools/write_note.py,sha256=emT91s9zMXRqM51y3j5laEYb1xyKoILjHBkzD-exyWw,19349
259
+ basic_memory/mcp/tools/write_note.py,sha256=hbshPb59bKw1_JoTJtOgJl13wIbhXa80xwNn0Luyq2o,19529
259
260
  basic_memory/mcp/ui/__init__.py,sha256=shSaivFGUaKaK7rUt3NNja8tGPbi1Bqie1uTC7FzVCU,193
260
261
  basic_memory/mcp/ui/sdk.py,sha256=Vk23Cf4C_lA6BA2p21plE-Ia0r2-vpT6vw65D7swm-w,1764
261
262
  basic_memory/mcp/ui/templates.py,sha256=eUrtb3p90dhZusu6b5EjasxXpcn0NsQDbRNiufQcegQ,822
@@ -270,7 +271,7 @@ basic_memory/models/base.py,sha256=2F45uI2-_xyEaHDeqifEHSTsiwIbDcwE50QjQcuggMI,2
270
271
  basic_memory/models/knowledge.py,sha256=2D0aPlsgnjn5phWdAq-I4gMvn8chewIC1VzYLRgKQTM,16102
271
272
  basic_memory/models/project.py,sha256=8IMKWA_Z7mecAzRByRGi7V1XQ1XaYL8r_K9k91pdS6Q,3418
272
273
  basic_memory/models/relation_search_refresh.py,sha256=DA1v-N6G9nKJ7a-DvfSi13pGRdckNUfh_d9tiEvLqNY,1606
273
- basic_memory/models/search.py,sha256=82VYMaXPIm0muatYOXsBNVUGzveNNsdRUbWyifVbTIg,5818
274
+ basic_memory/models/search.py,sha256=beMfq6vtdSXLuYTsjZUEBP6x-e7QU2qN-fHTUFxEmzM,6833
274
275
  basic_memory/picoschema/__init__.py,sha256=OsFNpVzzm38Y_0ELoTM_EcyGlnA1-_1DVx0ZKl45xNw,1270
275
276
  basic_memory/picoschema/diff.py,sha256=xjCzbS4XzrW6lXKjsGPSitMWDd3fbFrsA2JW9CslBtk,4212
276
277
  basic_memory/picoschema/inference.py,sha256=Cc02myST0zqmDKw0IUYjaPom8WrQFVvqrBbyurN4O5k,10575
@@ -306,7 +307,7 @@ basic_memory/repository/note_file_vacate_repository.py,sha256=3dyYc2ySkPJFEDMdH-
306
307
  basic_memory/repository/observation_repository.py,sha256=mLvIhjM3pbORBNUF-YT9NiPleUHgPCHNAjdNoFXEJaE,5710
307
308
  basic_memory/repository/openai_provider.py,sha256=kfGEqQYhi0dtOP91Ud9M9ARITz8ZKjpVJoPeRvQdLBc,4838
308
309
  basic_memory/repository/pgvector_index.py,sha256=Vh16pPMAo4SeLpd_g4FIfdO4VdR5FLnYYBlMcg0B3Xg,16119
309
- basic_memory/repository/postgres_search_repository.py,sha256=CUj8LqzzSwue-6Je-DjowJzJXMKAa1BM5KptfK3tetQ,50819
310
+ basic_memory/repository/postgres_search_repository.py,sha256=6ySkknt5hOSGdNvblEGLqnEgxehQHliombcvhrvDEAg,62331
310
311
  basic_memory/repository/prefixing_provider.py,sha256=8qGzW3_9Y40O794vk0QkR6MUxN07ZHVj0awf0WEObAI,3312
311
312
  basic_memory/repository/project_info_repository.py,sha256=jr32DLlVWq8tmXOi7xALv1CxHlg9n6y5xlrz74G_CyM,317
312
313
  basic_memory/repository/project_repository.py,sha256=ZzpAynhh9vgaJdZjTsWGjBPvPjyfzPOX1AWiGlsdh-c,12592
@@ -390,8 +391,8 @@ basic_memory/services/search_service.py,sha256=OcT2wMn7rN1GQ_0l8OH8KKcoTG4D7-Evf
390
391
  basic_memory/services/service.py,sha256=V-d_8gOV07zGIQDpL-Ksqs3ZN9l3qf3HZOK1f_YNTag,336
391
392
  basic_memory/templates/prompts/continue_conversation.hbs,sha256=trrDHSXA5S0JCbInMoUJL04xvCGRB_ku1RHNQHtl6ZI,3076
392
393
  basic_memory/templates/prompts/search.hbs,sha256=H1cCIsHKp4VC1GrH2KeUB8pGe5vXFPqb2VPotypmeCA,3098
393
- basic_memory-0.23.1.dev2.dist-info/METADATA,sha256=NOQMQs1G7r47EKTCAEXUwwiwd-1il_7SJJFDi_P910k,26847
394
- basic_memory-0.23.1.dev2.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
395
- basic_memory-0.23.1.dev2.dist-info/entry_points.txt,sha256=wvE2mRF6-Pg4weIYcfQ-86NOLZD4WJg7F7TIsRVFLb8,90
396
- basic_memory-0.23.1.dev2.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
397
- basic_memory-0.23.1.dev2.dist-info/RECORD,,
394
+ basic_memory-0.23.1.dev20.dist-info/METADATA,sha256=kS21fqv6k0zvFoXQ3KaKUgv6UWpoDs_APp8C7FjTpqs,26848
395
+ basic_memory-0.23.1.dev20.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
396
+ basic_memory-0.23.1.dev20.dist-info/entry_points.txt,sha256=wvE2mRF6-Pg4weIYcfQ-86NOLZD4WJg7F7TIsRVFLb8,90
397
+ basic_memory-0.23.1.dev20.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
398
+ basic_memory-0.23.1.dev20.dist-info/RECORD,,