Files
Auto-Trader/agent/src/memory/search_index.py
T
shadowinlifeandQoder b9507ee12b feat(memory): implement Tier 2 structural organization (#815)
* feat(memory): implement Tier 2 structural organization

Add four independent modules for memory structural organization:
- H-MEM hierarchical directory routing (hierarchy.py)
- A-MEM semantic linking via BM25 (semantic_links.py)
- Auto-compression pipeline via TF-IDF (compression.py)
- FTS5 full-text search index (search_index.py)

Integration:
- 4 feature flags in MemoryConfig (default: disabled)
- MemoryEntry extended with category + compression_level fields
- persistent.py wired with hierarchy routing, FTS5 search, link expansion
- lifecycle.py triggers compression in GC cycle
- remember_tool.py attaches related links in recall results
- FTS5 CJK bigram+unigram indexing for better Chinese recall
- Lazy auto-rebuild on first FTS5 search

Testing:
- 163 unit tests + 9 integration tests + 23 benchmarks (all passing)
- Benchmark evaluated on 200 real production memories + 50 labeled queries
- Compression retention: daily 91.4%, digest 97.1%
- Hierarchy routing: 93.3% P@5 parity with full scan

Closes #732 (Tier 2 scope)

Signed-off-by: shadowinlife <shadowinlife@gmail.com>

Co-Authored-By: Qoder <noreply@qoder.com>
AI-Contributed/Feature: 1582/1871
AI-Contributed/UT: 2123/2159

* feat(memory): add VT_MEMORY=off|on|full business-friendly preset

Simplify memory configuration from 7 individual boolean flags to a
single business-friendly environment variable:
  - off:  all features disabled (default, backward compatible)
  - on:   Tier 1 quality + decay + garbage collection
  - full: Tier 1 + Tier 2 hierarchy + links + compression + FTS

Individual VT_MEMORY_* flags still work as explicit overrides with
higher priority than the preset baseline.

Also adds a dependency-conflict warning in lifecycle.py when
compression is enabled without GC.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>

AI-Contributed/Feature: 0/138
AI-Contributed/UT: 0/0

* docs: add Memory Tier 2 and VT_MEMORY preset to CHANGELOG

Document the new structural organization features (hierarchy, semantic
links, compression, FTS5) and the business-friendly VT_MEMORY=off|on|full
configuration preset.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>

AI-Contributed/Feature: 0/15
AI-Contributed/UT: 0/0

---------

Co-authored-by: Qoder <noreply@qoder.com>
2026-07-24 22:13:16 +08:00

481 lines
17 KiB
Python

"""SQLite FTS5 full-text search index for persistent memory (Tier 2).
Provides O(log n) search via inverted index, replacing the O(n) sequential
token-scan in PersistentMemory.find_relevant(). Falls back gracefully if
FTS5 is unavailable.
Feature flag: VT_MEMORY_FTS_INDEX (default off).
Database: ~/.vibe-trading/memory_index.db (separate from sessions.db).
"""
from __future__ import annotations
import logging
import re
import sqlite3
import threading
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional
logger = logging.getLogger(__name__)
_DEFAULT_DB_PATH = Path.home() / ".vibe-trading" / "memory_index.db"
# Body text truncation limit (chars)
_MAX_BODY_LEN = 50_000
# CJK Unicode range boundaries for ideograph detection
_CJK_RANGE_MAIN = (0x4E00, 0x9FFF)
_CJK_RANGE_EXT_A = (0x3400, 0x4DBF)
def _is_cjk_char(char: str) -> bool:
"""Check if character is a CJK ideograph."""
cp = ord(char)
return (
_CJK_RANGE_MAIN[0] <= cp <= _CJK_RANGE_MAIN[1]
or _CJK_RANGE_EXT_A[0] <= cp <= _CJK_RANGE_EXT_A[1]
)
def _expand_cjk_buffer(chars: list[str]) -> str:
"""Expand a sequence of CJK chars into unigrams + bigrams.
Example: ['\u8bb0', '\u5fc6', '\u7cfb', '\u7edf'] -> '\u8bb0 \u5fc6 \u7cfb \u7edf \u8bb0\u5fc6 \u5fc6\u7cfb \u7cfb\u7edf'
"""
parts: list[str] = []
# Unigrams
parts.extend(chars)
# Bigrams (overlapping pairs)
for i in range(len(chars) - 1):
parts.append(chars[i] + chars[i + 1])
return " ".join(parts)
def _cjk_query_tokens(chars: list[str]) -> list[str]:
"""Generate unigram + bigram tokens from consecutive CJK chars for queries."""
tokens: list[str] = list(chars)
for i in range(len(chars) - 1):
tokens.append(chars[i] + chars[i + 1])
return tokens
def _dedupe_cjk_runs(text: str) -> str:
"""Remove duplicate CJK substrings produced by bigram expansion during clean.
After collapsing spaces, bigram tokens merge into the original run,
creating duplicates like '\u8bb0\u5fc6\u7cfb\u7edf\u8bb0\u5fc6\u5fc6\u7cfb\u7cfb\u7edf'. This finds CJK runs and
keeps only the longest non-repeating form.
"""
_cjk_run_re = re.compile(r"[\u4e00-\u9fff\u3400-\u4dbf]{2,}")
def _shrink(match: re.Match) -> str: # type: ignore[type-arg]
run = match.group(0)
# Try to find the minimal prefix whose repetition covers the run
# But simpler: the original text was unigrams + bigrams concatenated.
# The original run length N produces N unigrams + (N-1) bigrams
# = N + 2*(N-1) = 3N-2 chars when concatenated. Solve for N.
total = len(run)
# 3N - 2 = total => N = (total + 2) / 3
n = (total + 2) // 3
if 3 * n - 2 == total and n >= 2:
# Verify the prefix matches
candidate = run[:n]
# Rebuild what bigram expansion would produce concatenated
expected = candidate + "".join(
candidate[i] + candidate[i + 1] for i in range(n - 1)
)
if expected == run:
return candidate
return run
return _cjk_run_re.sub(_shrink, text)
@dataclass(frozen=True)
class MemoryMatch:
"""A single FTS5 search result.
Attributes:
entry_id: 6-char hex identifier of the memory entry.
title: Memory title.
snippet: FTS5 snippet with match highlights (>>> <<<).
rank: FTS5 relevance rank (lower is better).
"""
entry_id: str
title: str
snippet: str
rank: float
class MemorySearchIndex:
"""SQLite FTS5 index for memory full-text search.
Supports:
- Indexing individual memory entries as they are created/updated
- Full-text search with relevance ranking
- Bulk reindex from in-memory entry data
- Graceful degradation when FTS5 is unavailable
"""
def __init__(self, db_path: Optional[Path] = None) -> None:
"""Initialize the search index.
Args:
db_path: Path to SQLite DB (default: ~/.vibe-trading/memory_index.db).
"""
self.db_path = db_path or _DEFAULT_DB_PATH
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._conn: Optional[sqlite3.Connection] = None
self._fts_available: bool = True
self._auto_rebuilt: bool = False
self._op_lock = threading.Lock()
self._init_db()
def _get_conn(self) -> sqlite3.Connection:
"""Get or create SQLite connection with WAL mode."""
if self._conn is None:
self._conn = sqlite3.connect(
str(self.db_path), check_same_thread=False
)
self._conn.execute("PRAGMA journal_mode=WAL")
self._conn.execute("PRAGMA synchronous=NORMAL")
return self._conn
def _init_db(self) -> None:
"""Create tables + FTS5 virtual table + auto-sync triggers."""
conn = self._get_conn()
conn.executescript("""
CREATE TABLE IF NOT EXISTS memories (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
keywords TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT ''
);
""")
# FTS5 virtual table — separate try/except for graceful degradation
try:
conn.execute("""
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts
USING fts5(
title, description, keywords, body,
content=memories, content_rowid=rowid
)
""")
except sqlite3.OperationalError as exc:
logger.warning("FTS5 unavailable, search disabled: %s", exc)
self._fts_available = False
conn.commit()
return
# Auto-sync triggers
for trigger_sql in [
"""CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
INSERT INTO memories_fts(rowid, title, description, keywords, body)
VALUES (new.rowid, new.title, new.description, new.keywords, new.body);
END""",
"""CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid, title, description, keywords, body)
VALUES ('delete', old.rowid, old.title, old.description, old.keywords, old.body);
END""",
"""CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid, title, description, keywords, body)
VALUES ('delete', old.rowid, old.title, old.description, old.keywords, old.body);
INSERT INTO memories_fts(rowid, title, description, keywords, body)
VALUES (new.rowid, new.title, new.description, new.keywords, new.body);
END""",
]:
try:
conn.execute(trigger_sql)
except sqlite3.OperationalError:
pass
conn.commit()
def _is_index_populated(self) -> bool:
"""Check if the FTS5 index has any entries."""
conn = self._get_conn()
try:
row = conn.execute("SELECT COUNT(*) FROM memories").fetchone()
return row[0] > 0 if row else False
except Exception:
return False
def index_entry(
self,
entry_id: str,
title: str,
description: str,
keywords: str,
body: str,
) -> None:
"""Upsert a memory entry into the index.
Args:
entry_id: 6-char hex identifier.
title: Memory title.
description: One-line description.
keywords: Space-joined keywords.
body: Full body text (truncated to 50k chars).
"""
with self._op_lock:
conn = self._get_conn()
try:
conn.execute(
"INSERT OR REPLACE INTO memories (id, title, description, keywords, body) "
"VALUES (?, ?, ?, ?, ?)",
(
entry_id,
self._prepare_cjk(title),
self._prepare_cjk(description),
self._prepare_cjk(keywords),
self._prepare_cjk(body[:_MAX_BODY_LEN]),
),
)
conn.commit()
except sqlite3.OperationalError as exc:
logger.debug("index_entry failed for %s: %s", entry_id, exc)
def remove_entry(self, entry_id: str) -> None:
"""Remove an entry from the index by ID."""
with self._op_lock:
conn = self._get_conn()
try:
conn.execute("DELETE FROM memories WHERE id = ?", (entry_id,))
conn.commit()
except sqlite3.OperationalError as exc:
logger.debug("remove_entry failed for %s: %s", entry_id, exc)
def search(self, query: str, max_results: int = 5) -> List[MemoryMatch]:
"""Full-text search with FTS5 MATCH.
Args:
query: User search query.
max_results: Maximum results to return.
Returns:
List of MemoryMatch sorted by relevance. Empty list if FTS5
unavailable or query produces no results.
"""
if not self._fts_available:
return []
fts_query = self._sanitize_fts_query(query)
if not fts_query or fts_query == '""':
return []
with self._op_lock:
conn = self._get_conn()
try:
cursor = conn.execute(
"""
SELECT
m.id,
m.title,
snippet(memories_fts, 3, '>>>', '<<<', '...', 64) AS snippet,
rank
FROM memories_fts
JOIN memories m ON m.rowid = memories_fts.rowid
WHERE memories_fts MATCH ?
ORDER BY rank
LIMIT ?
""",
(fts_query, max_results),
)
except sqlite3.OperationalError as exc:
logger.warning("FTS5 search failed: %s", exc)
return []
results: List[MemoryMatch] = []
for row in cursor.fetchall():
results.append(
MemoryMatch(
entry_id=row[0],
title=self._clean_cjk(row[1] or ""),
snippet=self._clean_cjk(row[2] or ""),
rank=row[3],
)
)
return results
def rebuild_all(self, entries_data: List[tuple]) -> int:
"""Full reindex from a list of (id, title, description, keywords, body) tuples.
Clears existing data and re-inserts all entries. Use for bulk
synchronization from the canonical PersistentMemory store.
Args:
entries_data: List of tuples (id, title, description, keywords, body).
Returns:
Number of entries indexed.
"""
with self._op_lock:
conn = self._get_conn()
try:
conn.execute("DELETE FROM memories")
if self._fts_available:
conn.execute(
"INSERT INTO memories_fts(memories_fts) VALUES ('rebuild')"
)
except sqlite3.OperationalError as exc:
logger.debug("rebuild_all clear failed: %s", exc)
conn.commit()
count = 0
for entry in entries_data:
if len(entry) < 5:
continue
entry_id, title, description, keywords, body = (
entry[0],
entry[1],
entry[2],
entry[3],
entry[4],
)
try:
conn.execute(
"INSERT OR REPLACE INTO memories "
"(id, title, description, keywords, body) VALUES (?, ?, ?, ?, ?)",
(
entry_id,
self._prepare_cjk(title or ""),
self._prepare_cjk(description or ""),
self._prepare_cjk(keywords or ""),
self._prepare_cjk((body or "")[:_MAX_BODY_LEN]),
),
)
count += 1
except sqlite3.OperationalError as exc:
logger.debug("rebuild_all entry %s failed: %s", entry_id, exc)
conn.commit()
return count
@staticmethod
def _prepare_cjk(text: str) -> str:
"""Insert spaces for CJK chars and generate bigrams for better matching.
Example: "记忆系统" → "记 忆 系 统 记忆 忆系 系统"
This allows both single-char and two-char phrase matching in FTS5.
Non-CJK text is preserved as-is.
"""
result: list[str] = []
cjk_buffer: list[str] = []
text_buffer: list[str] = []
for char in text:
if _is_cjk_char(char):
if text_buffer:
result.append("".join(text_buffer))
text_buffer = []
cjk_buffer.append(char)
else:
if cjk_buffer:
result.append(_expand_cjk_buffer(cjk_buffer))
cjk_buffer = []
text_buffer.append(char)
if cjk_buffer:
result.append(_expand_cjk_buffer(cjk_buffer))
if text_buffer:
result.append("".join(text_buffer))
return " ".join(result)
@staticmethod
def _clean_cjk(text: str) -> str:
"""Collapse extra whitespace and remove bigram duplicates from display text.
Removes spaces between CJK characters and strips bigram tokens that
are substrings of already-present CJK runs, normalizing whitespace.
"""
# First remove bigram-only tokens (two adjacent CJK chars surrounded by spaces)
# by collapsing all CJK-adjacent spacing.
_cjk_space = re.compile(
r"([\u4e00-\u9fff\u3400-\u4dbf])\s+([\u4e00-\u9fff\u3400-\u4dbf])"
)
prev = None
while prev != text:
prev = text
text = _cjk_space.sub(r"\1\2", text)
# Remove duplicate CJK runs that appear due to bigram expansion
# e.g. "记忆系统记忆忆系系统" → keep longest contiguous run only
text = _dedupe_cjk_runs(text)
return re.sub(r"\s+", " ", text).strip()
@staticmethod
def _sanitize_fts_query(query: str) -> str:
"""Sanitize user query for FTS5 MATCH syntax.
Extracts alphanumeric tokens (2+ chars) and CJK characters,
generates bigrams for consecutive CJK chars, quotes each token
and joins with OR to prevent FTS5 operator injection.
Args:
query: Raw user query string.
Returns:
FTS5-safe MATCH expression, or empty-quoted string if no tokens.
"""
tokens: list[str] = []
cjk_buffer: list[str] = []
# Walk through pre-extracted raw tokens
raw_tokens = re.findall(
r"[a-zA-Z0-9_]{2,}|[\u4e00-\u9fff\u3400-\u4dbf]", query
)
for tok in raw_tokens:
if len(tok) == 1 and _is_cjk_char(tok):
cjk_buffer.append(tok)
else:
if cjk_buffer:
tokens.extend(_cjk_query_tokens(cjk_buffer))
cjk_buffer = []
tokens.append(tok)
if cjk_buffer:
tokens.extend(_cjk_query_tokens(cjk_buffer))
if not tokens:
return '""'
# Quote each token and join with OR for broader matching
return " OR ".join(f'"{t}"' for t in tokens)
def close(self) -> None:
"""Close the database connection."""
if self._conn:
self._conn.close()
self._conn = None
# ---------------------------------------------------------------------------
# Singleton access
# ---------------------------------------------------------------------------
_shared_index: Optional[MemorySearchIndex] = None
_shared_lock = threading.Lock()
def get_shared_index(db_path: Optional[Path] = None) -> MemorySearchIndex:
"""Return a process-wide singleton MemorySearchIndex.
Thread-safe via double-checked locking. Shared by memory indexing
and search callers so they use one SQLite connection.
Args:
db_path: Optional override for the database path (only used on
first call when creating the singleton).
Returns:
The shared MemorySearchIndex instance.
"""
global _shared_index
if _shared_index is None:
with _shared_lock:
if _shared_index is None:
_shared_index = MemorySearchIndex(db_path=db_path)
return _shared_index