fix(aibox): FTS5 中文检索 + Token 压缩阈值生效——Compressor 抽离解耦

- db.py: FTS5 改用 trigram 分词(中文可检索),旧 unicode61 表自动迁移回灌;search_memories 增加 LIKE 兜底(%/_ 转义、MATCH 异常防护)
- conversation_compressor.py: should_compress 补齐 Token 阈值判断(原为死配置),Token 触发时按比例压缩早期消息
- ConversationCompressor 抽离至 subsystems,注入 llm_client 打破与 DeepSeekChat 循环引用;aibox.py 3517→3018 行
- 新增 21 项测试(中文检索/旧表迁移/LIKE 转义/token 估算/压缩流程/失败回退),全量 232 项通过
This commit is contained in:
qing committed 2026-08-15 09:51:59 +08:00
1 parent 26de527a09
commit 7f21b65428
7 files changed
+940 -533

No files matched your search

+101 -15
View File
@@ -51,13 +51,57 @@ class MemoryDatabase:
)
''')
cursor.execute('''
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts
USING fts5(topic, summary, content, tags, content=memories)
''')
self._init_fts_table(conn)
conn.commit()
def _fts_trigram_supported(self, conn) -> bool:
"""检测当前 SQLite 是否支持 trigram 分词器(需 SQLite >= 3.34)"""
try:
conn.execute("CREATE VIRTUAL TABLE temp.__trigram_probe USING fts5(x, tokenize='trigram')")
conn.execute("DROP TABLE temp.__trigram_probe")
return True
except sqlite3.OperationalError:
return False
def _init_fts_table(self, conn):
"""初始化 FTS5 全文索引表。
优先使用 trigram 分词器(中文可检索,unicode61 对中文不分词);
检测到旧 unicode61 表时自动迁移:DROP 重建后从 memories 表回灌全量数据。
环境不支持 trigram 时退回 unicode61 建表,中文搜索由 LIKE 兜底。
"""
cursor = conn.cursor()
trigram_ok = self._fts_trigram_supported(conn)
tokenize_opt = ", tokenize='trigram'" if trigram_ok else ""
create_sql = (
"CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts "
f"USING fts5(topic, summary, content, tags, content=memories{tokenize_opt})"
)
row = cursor.execute(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='memories_fts'"
).fetchone()
need_backfill = False
if row is None:
need_backfill = True # 新库(memories 可能已有数据,回灌无害)
elif trigram_ok and "trigram" not in (row[0] or ""):
# 旧 unicode61 表 → 迁移为 trigram
cursor.execute("DROP TABLE memories_fts")
need_backfill = True
cursor.execute(create_sql)
if need_backfill:
count = cursor.execute('''
INSERT INTO memories_fts (rowid, topic, summary, content, tags)
SELECT id, topic, summary, COALESCE(content, ''), COALESCE(tags, '')
FROM memories
''').rowcount
if count:
self.logger.info(f"FTS5 索引已使用 trigram 分词重建并回灌 {count} 条记忆")
def add_memory(self, topic: str, summary: str, content: str = None,
start_time: str = None, end_time: str = None,
tags: List[str] = None, importance: int = None) -> int:
@@ -106,7 +150,11 @@ class MemoryDatabase:
return deleted
def search_memories(self, keywords: List[str], match_all: bool = False, limit: int = None) -> List[Dict]:
"""多关键词模糊搜索记忆"""
"""多关键词模糊搜索记忆。
优先走 FTS5 全文检索;FTS 无结果时(trigram 对 <3 字符的中文短词
无法命中、旧表 unicode61 对中文不分词等场景)用 LIKE 兜底。
"""
if not keywords:
return []
@@ -117,11 +165,18 @@ class MemoryDatabase:
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
if match_all:
query = " AND ".join([f'"{kw}"' for kw in keywords])
else:
query = " OR ".join([f'"{kw}"' for kw in keywords])
results = self._search_via_fts(cursor, keywords, match_all, limit)
if not results:
results = self._search_via_like(cursor, keywords, match_all, limit)
return results
def _search_via_fts(self, cursor, keywords: List[str], match_all: bool, limit: int) -> List[Dict]:
"""FTS5 全文检索(关键词含特殊字符导致 MATCH 语法错误时返回空,走 LIKE 兜底)"""
joiner = " AND " if match_all else " OR "
query = joiner.join([f'"{kw}"' for kw in keywords])
try:
cursor.execute('''
SELECT m.*, rank
FROM memories m
@@ -130,14 +185,45 @@ class MemoryDatabase:
ORDER BY m.end_time DESC, rank, m.importance DESC
LIMIT ?
''', (query, limit))
except sqlite3.OperationalError as e:
self.logger.warning(f"FTS5 检索失败(改用 LIKE 兜底): {e}")
return []
results = []
for row in cursor.fetchall():
memory = dict(row)
if memory.get('tags'):
memory['tags'] = memory['tags'].split(',')
results.append(memory)
return self._rows_to_memories(cursor.fetchall())
def _search_via_like(self, cursor, keywords: List[str], match_all: bool, limit: int) -> List[Dict]:
"""LIKE 模糊检索兜底(转义 %/_/\\ 防止关键词被当作通配符)"""
fields = ["topic", "summary", "content", "tags"]
def escape(kw: str) -> str:
return kw.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
conditions = []
params = []
for kw in keywords:
pattern = f"%{escape(kw)}%"
per_field = " OR ".join([f"{f} LIKE ? ESCAPE '\\'" for f in fields])
conditions.append(f"({per_field})")
params.extend([pattern] * len(fields))
joiner = " AND " if match_all else " OR "
cursor.execute(
f"SELECT * FROM memories WHERE {joiner.join(conditions)} "
f"ORDER BY end_time DESC, importance DESC LIMIT ?",
(*params, limit),
)
return self._rows_to_memories(cursor.fetchall())
@staticmethod
def _rows_to_memories(rows) -> List[Dict]:
"""sqlite3.Row 列表转记忆字典列表(tags 逗号串拆分为列表)"""
results = []
for row in rows:
memory = dict(row)
if memory.get('tags'):
memory['tags'] = memory['tags'].split(',')
results.append(memory)
return results
def get_recent_memories(self, limit: int = 10) -> List[Dict]: