#!/usr/bin/env python3 """ FTS5 记忆索引系统 用于全文搜索 memory/*.md 文件 """ import sqlite3 import os import re from pathlib import Path from datetime import datetime # 配置 MEMORY_DIR = Path.home() / ".openclaw" / "workspace" / "memory" DB_PATH = MEMORY_DIR / ".fts5" / "memory.db" def init_db(): """初始化 FTS5 数据库""" DB_PATH.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(DB_PATH) cursor = conn.cursor() # 创建文件表 cursor.execute(""" CREATE TABLE IF NOT EXISTS files ( id INTEGER PRIMARY KEY, path TEXT UNIQUE NOT NULL, modified_at TIMESTAMP, indexed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) # 创建 FTS5 虚拟表用于全文搜索 cursor.execute(""" CREATE VIRTUAL TABLE IF NOT EXISTS memory_index USING fts5( content, file_id, chunk_index, tokenize='porter unicode61' ) """) # 创建 chunks 表存储分块信息 cursor.execute(""" CREATE TABLE IF NOT EXISTS chunks ( id INTEGER PRIMARY KEY, file_id INTEGER, chunk_index INTEGER, content TEXT, start_line INTEGER, end_line INTEGER, FOREIGN KEY (file_id) REFERENCES files(id) ) """) conn.commit() conn.close() print(f"✓ 数据库初始化完成: {DB_PATH}") def chunk_content(content, chunk_size=500, overlap=100): """ 将内容分块,带重叠以保持上下文 Args: content: 文件内容 chunk_size: 每块字符数 overlap: 重叠字符数 Returns: list of (chunk_text, start_line, end_line) """ lines = content.split('\n') chunks = [] current_chunk = [] current_size = 0 start_line = 0 for i, line in enumerate(lines): line_with_newline = line + '\n' if current_size + len(line_with_newline) > chunk_size and current_chunk: # 保存当前块 chunk_text = ''.join(current_chunk) chunks.append((chunk_text, start_line + 1, i)) # 重叠部分 overlap_text = chunk_text[-overlap:] if len(chunk_text) > overlap else chunk_text current_chunk = [overlap_text] current_size = len(overlap_text) start_line = i - len(overlap_text.split('\n')) + 1 current_chunk.append(line_with_newline) current_size += len(line_with_newline) # 最后一块 if current_chunk: chunk_text = ''.join(current_chunk) chunks.append((chunk_text, start_line + 1, len(lines))) return chunks def index_file(file_path, conn=None): """索引单个文件""" close_conn = False if conn is None: conn = sqlite3.connect(DB_PATH) close_conn = True cursor = conn.cursor() # 读取文件 try: with open(file_path, 'r', encoding='utf-8') as f: content = f.read() except Exception as e: print(f"✗ 读取失败 {file_path}: {e}") return # 获取文件信息 stat = os.stat(file_path) modified_at = datetime.fromtimestamp(stat.st_mtime) rel_path = str(Path(file_path).relative_to(MEMORY_DIR.parent)) # 插入或更新文件记录 cursor.execute(""" INSERT OR REPLACE INTO files (path, modified_at, indexed_at) VALUES (?, ?, CURRENT_TIMESTAMP) """, (rel_path, modified_at)) file_id = cursor.lastrowid # 删除旧索引 cursor.execute("DELETE FROM memory_index WHERE file_id = ?", (file_id,)) cursor.execute("DELETE FROM chunks WHERE file_id = ?", (file_id,)) # 分块并索引 chunks = chunk_content(content) for chunk_idx, (chunk_text, start_line, end_line) in enumerate(chunks): # 插入 chunks 表 cursor.execute(""" INSERT INTO chunks (file_id, chunk_index, content, start_line, end_line) VALUES (?, ?, ?, ?, ?) """, (file_id, chunk_idx, chunk_text, start_line, end_line)) # 插入 FTS5 索引 cursor.execute(""" INSERT INTO memory_index (content, file_id, chunk_index) VALUES (?, ?, ?) """, (chunk_text, file_id, chunk_idx)) conn.commit() if close_conn: conn.close() print(f"✓ 已索引: {rel_path} ({len(chunks)} 块)") def index_all(): """索引所有 memory 文件""" init_db() conn = sqlite3.connect(DB_PATH) # 查找所有 .md 文件 md_files = list(MEMORY_DIR.glob("*.md")) print(f"\n开始索引 {len(md_files)} 个文件...") for file_path in md_files: if file_path.name.startswith('.'): continue index_file(file_path, conn) conn.close() print(f"\n✓ 索引完成") def search(query, limit=10): """ 全文搜索 Args: query: 搜索关键词 limit: 返回结果数量 Returns: list of dict with path, snippet, rank """ conn = sqlite3.connect(DB_PATH) cursor = conn.cursor() # 使用 FTS5 的 snippet 功能生成摘要 cursor.execute(""" SELECT f.path, snippet(memory_index, 0, '[', ']', '...', 32) as snippet, c.start_line, c.end_line, rank FROM memory_index JOIN files f ON memory_index.file_id = f.id JOIN chunks c ON memory_index.file_id = c.file_id AND memory_index.chunk_index = c.chunk_index WHERE memory_index MATCH ? ORDER BY rank LIMIT ? """, (query, limit)) results = [] for row in cursor.fetchall(): results.append({ 'path': row[0], 'snippet': row[1], 'start_line': row[2], 'end_line': row[3], 'rank': row[4] }) conn.close() return results def print_search_results(query, results): """打印搜索结果""" print(f"\n🔍 搜索: '{query}'") print(f"找到 {len(results)} 条结果:\n") for i, r in enumerate(results, 1): print(f"{i}. {r['path']} (行 {r['start_line']}-{r['end_line']})") print(f" {r['snippet']}") print() if __name__ == "__main__": import sys if len(sys.argv) < 2: print("用法:") print(f" {sys.argv[0]} index # 索引所有文件") print(f" {sys.argv[0]} search # 搜索") sys.exit(1) command = sys.argv[1] if command == "index": index_all() elif command == "search": if len(sys.argv) < 3: print("错误: 需要提供搜索关键词") sys.exit(1) query = sys.argv[2] results = search(query) print_search_results(query, results) else: print(f"未知命令: {command}") sys.exit(1)