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
2 changes: 2 additions & 0 deletions backend/agentpal/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
doctor,
init_cmd,
logs,
memory_cmd,
restart,
start,
status,
Expand Down Expand Up @@ -58,6 +59,7 @@ def main(
app.add_typer(logs.app, name="logs", help="View service logs")
app.add_typer(clean.app, name="clean", help="Clean generated files")
app.add_typer(doctor.app, name="doctor", help="Run environment health checks")
app.add_typer(memory_cmd.app, name="memory", help="Manage memory store (FTS reindex, etc.)")


# ── Also expose `nimo version` as a top-level command ────
Expand Down
31 changes: 31 additions & 0 deletions backend/agentpal/cli/commands/memory_cmd.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""``nimo memory`` 子命令。"""

from __future__ import annotations

import asyncio

import typer

app = typer.Typer(help="管理记忆存储(FTS 索引回填等)")


@app.command("reindex")
def reindex(
batch_size: int = typer.Option(1000, "--batch", "-b", help="每批处理条数"),
) -> None:
"""重新建立 FTS5 全文索引(幂等,已索引记录自动跳过)。"""
from agentpal.migrations.backfill_fts import backfill_fts

from agentpal.cli.console import console

async def _run() -> None:
from agentpal.database import init_db, run_migrations
await init_db()
await run_migrations()
stats = await backfill_fts(batch_size=batch_size)
console.print(
f"[green]FTS 回填完成[/green]: total={stats['total']} "
f"indexed={stats['indexed']} skipped={stats['skipped']}"
)

asyncio.run(_run())
8 changes: 8 additions & 0 deletions backend/agentpal/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,14 @@ def settings_customise_sources(
memory_reme_light_vector_weight: float = 0.7
memory_reme_light_candidate_multiplier: float = 3.0

# ── FTS5 全文检索(装饰在任意 backend 外层)──────────
# 启用后,每条 memory 额外写入 FTS5 倒排索引(jieba 分词),
# cross_session_search 会把 FTS5 与 inner backend 的结果用 RRF 融合。
# 默认关闭:升级时需用户主动开启 + 跑一次 `nimo memory reindex` 回填。
memory_fts5_enabled: bool = False
memory_fts5_rrf_k: int = 60 # RRF 平滑常数
memory_fts5_candidate_multiplier: float = 3.0 # FTS 召回候选数 = limit × 该倍数

# ── Workspace ─────────────────────────────────────────
# Agent 工作空间目录,默认 ~/.nimo(可通过 WORKSPACE_DIR 或 NIMO_HOME 环境变量覆盖)
workspace_dir: str = str(get_workspace_dir())
Expand Down
91 changes: 61 additions & 30 deletions backend/agentpal/memory/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ async def get_memory(

from typing import Any

from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker

from agentpal.config import get_settings
from agentpal.memory.base import BaseMemory
Expand Down Expand Up @@ -54,6 +54,8 @@ def create(backend: str | None = None, **kwargs: Any) -> BaseMemory:
reme_light_embedding_model_config (dict): ReMeLight Embedding 模型配置,可选
reme_light_vector_weight (float): ReMeLight 向量检索权重,可选
reme_light_candidate_multiplier (float): ReMeLight 候选倍数,可选
fts5_enabled (bool): 强制覆盖全局 memory_fts5_enabled 配置,可选
fts5_session_factory (async_sessionmaker): FTS 写入独立 session 的工厂,可选

Returns:
BaseMemory 实例
Expand All @@ -64,35 +66,64 @@ def create(backend: str | None = None, **kwargs: Any) -> BaseMemory:
sqlite_limit: int = kwargs.get("sqlite_limit", settings.memory_sqlite_limit)

if backend == "buffer":
return BufferMemory(max_size=buffer_size)

db: AsyncSession | None = kwargs.get("db")

if backend == "sqlite":
if db is None:
raise ValueError("SQLiteMemory 需要传入 db (AsyncSession)")
return SQLiteMemory(db=db, limit=sqlite_limit)

if backend == "hybrid":
if db is None:
raise ValueError("HybridMemory 需要传入 db (AsyncSession)")
buffer = BufferMemory(max_size=buffer_size)
persistent = SQLiteMemory(db=db, limit=sqlite_limit)
return HybridMemory(buffer=buffer, persistent=persistent)

if backend == "mem0":
return _create_mem0(settings, **kwargs)

if backend == "reme":
return _create_reme(settings, **kwargs)

if backend == "reme_light":
return _create_reme_light(settings, **kwargs)

raise ValueError(
f"未知的 memory_backend: '{backend}'。"
f"支持的后端:buffer, sqlite, hybrid, mem0, reme, reme_light"
)
inner: BaseMemory = BufferMemory(max_size=buffer_size)
else:
db: AsyncSession | None = kwargs.get("db")

if backend == "sqlite":
if db is None:
raise ValueError("SQLiteMemory 需要传入 db (AsyncSession)")
inner = SQLiteMemory(db=db, limit=sqlite_limit)

elif backend == "hybrid":
if db is None:
raise ValueError("HybridMemory 需要传入 db (AsyncSession)")
buffer = BufferMemory(max_size=buffer_size)
persistent = SQLiteMemory(db=db, limit=sqlite_limit)
inner = HybridMemory(buffer=buffer, persistent=persistent)

elif backend == "mem0":
inner = _create_mem0(settings, **kwargs)

elif backend == "reme":
inner = _create_reme(settings, **kwargs)

elif backend == "reme_light":
inner = _create_reme_light(settings, **kwargs)

else:
raise ValueError(
f"未知的 memory_backend: '{backend}'。"
f"支持的后端:buffer, sqlite, hybrid, mem0, reme, reme_light"
)

# 可选:外层叠加 FTS5 索引
fts_enabled = kwargs.get("fts5_enabled", settings.memory_fts5_enabled)
if fts_enabled:
return _wrap_with_fts(inner, settings, **kwargs)
return inner


def _wrap_with_fts(
inner: BaseMemory,
settings: Any,
**kwargs: Any,
) -> BaseMemory:
"""把 inner 套一层 FTSWrappedMemory。"""
from agentpal.memory.fts_wrapped import FTSWrappedMemory

session_factory = kwargs.get("fts5_session_factory")
if session_factory is None:
# 默认复用全局 AsyncSessionLocal
from agentpal.database import AsyncSessionLocal
session_factory = AsyncSessionLocal

return FTSWrappedMemory(
inner=inner,
session_factory=session_factory,
rrf_k=settings.memory_fts5_rrf_k,
fts_candidate_multiplier=settings.memory_fts5_candidate_multiplier,
)


def _create_mem0(settings: Any, **kwargs: Any) -> BaseMemory:
Expand Down
Loading