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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,20 @@ python -m app.mneme.memoria.server.cli.backfill \
--dry-run
```

迁移后补齐历史 memory revision 的语义向量:

```bash
python -m app.mneme.memoria.server.cli.embedding_backfill --memory --dry-run
python -m app.mneme.memoria.server.cli.embedding_backfill --memory --batch-size 100
```

上下文化向量格式升级后重建当前文档 chunk 向量:

```bash
python -m app.mneme.memoria.server.cli.embedding_backfill --documents --dry-run
python -m app.mneme.memoria.server.cli.embedding_backfill --documents --batch-size 100
```

完整删除、重建和恢复流程见[部署指南中的 Memoria operations](deploy/DEPLOY.md#memoria-rebuild-and-deletion-operations)。

## 项目结构
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""add semantic embeddings to memory revisions

Revision ID: 20260831_01
Revises: 20260830_01
Create Date: 2026-08-31
"""

from collections.abc import Sequence

import sqlalchemy as sa
from pgvector.sqlalchemy import Vector

from alembic import op
from app.mneme.memoria.server.config import settings

revision: str = "20260831_01"
down_revision: str | Sequence[str] | None = "20260830_01"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
op.add_column(
"memory_revisions",
sa.Column("embedding", Vector(settings.EMBEDDING_DIMENSION), nullable=True),
)
op.create_index(
"ix_memory_revisions_embedding_hnsw",
"memory_revisions",
["embedding"],
postgresql_using="hnsw",
postgresql_ops={"embedding": "vector_cosine_ops"},
)


def downgrade() -> None:
op.drop_index("ix_memory_revisions_embedding_hnsw", table_name="memory_revisions")
op.drop_column("memory_revisions", "embedding")
174 changes: 174 additions & 0 deletions app/mneme/memoria/server/cli/embedding_backfill.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
"""Backfill Memoria memory and active-document embeddings."""

import argparse
import asyncio
import json

from sqlalchemy import func, select, update

from app.mneme.memoria.server.database import engine, open_read_session, open_write_session
from app.mneme.memoria.server.models.document_chunk import DocumentChunk
from app.mneme.memoria.server.models.document_projection import DocumentProjection
from app.mneme.memoria.server.models.memory_revision import MemoryRevision
from app.mneme.memoria.server.services.embeddings import (
document_embedding_text,
embed_texts,
memory_embedding_text,
)


def _memory_filters(args: argparse.Namespace) -> list:
filters = [MemoryRevision.embedding.is_(None)]
if args.owner_id is not None:
filters.append(MemoryRevision.owner_id == args.owner_id)
if args.knowledge_base_id is not None:
filters.append(MemoryRevision.knowledge_base_id == args.knowledge_base_id)
return filters


async def memory_embedding_backfill(args: argparse.Namespace) -> dict:
filters = _memory_filters(args)
if args.resume_from:
filters.append(MemoryRevision.revision_id > args.resume_from)
if args.dry_run:
async with open_read_session() as db:
pending = await db.scalar(select(func.count()).select_from(MemoryRevision).where(*filters))
return {"pending": int(pending or 0), "updated": 0, "dry_run": True}

updated = 0
resume_from = args.resume_from
while True:
statement = select(
MemoryRevision.revision_id,
MemoryRevision.subject,
MemoryRevision.predicate,
MemoryRevision.value,
).where(*filters)
if resume_from:
statement = statement.where(MemoryRevision.revision_id > resume_from)
statement = statement.order_by(MemoryRevision.revision_id).limit(args.batch_size)
async with open_read_session() as db:
rows = (await db.execute(statement)).all()
if not rows:
break

vectors = await embed_texts(
[memory_embedding_text(subject=row.subject, predicate=row.predicate, value=row.value) for row in rows]
)
async with open_write_session() as db:
for row, vector in zip(rows, vectors, strict=True):
result = await db.execute(
update(MemoryRevision)
.where(
MemoryRevision.revision_id == row.revision_id,
MemoryRevision.embedding.is_(None),
)
.values(embedding=vector)
)
updated += result.rowcount or 0
resume_from = rows[-1].revision_id

return {"pending": 0, "updated": updated, "resume_from": resume_from, "dry_run": False}


def _document_filters(args: argparse.Namespace) -> list:
filters = [DocumentChunk.is_active.is_(True), DocumentProjection.status == "active"]
if args.owner_id is not None:
filters.append(DocumentChunk.owner_id == args.owner_id)
if args.knowledge_base_id is not None:
filters.append(DocumentChunk.knowledge_base_id == args.knowledge_base_id)
return filters


async def document_embedding_backfill(args: argparse.Namespace) -> dict:
cursor = int(args.resume_from or 0)
filters = _document_filters(args)
if args.dry_run:
async with open_read_session() as db:
eligible = await db.scalar(
select(func.count())
.select_from(DocumentChunk)
.join(DocumentProjection)
.where(*filters, DocumentChunk.id > cursor)
)
return {"eligible": int(eligible or 0), "updated": 0, "dry_run": True}

updated = 0
# ponytail: embedding version is not persisted; reruns rewrite active vectors.
# Add a version column only if representation upgrades become frequent.
while True:
statement = (
select(
DocumentChunk.id,
DocumentChunk.content,
DocumentChunk.section_path,
DocumentProjection.file_name,
)
.join(DocumentProjection)
.where(*filters, DocumentChunk.id > cursor)
.order_by(DocumentChunk.id)
.limit(args.batch_size)
)
async with open_read_session() as db:
rows = (await db.execute(statement)).all()
if not rows:
break

vectors = await embed_texts(
[
document_embedding_text(
file_name=row.file_name,
section_path=row.section_path,
content=row.content,
)
for row in rows
]
)
async with open_write_session() as db:
for row, vector in zip(rows, vectors, strict=True):
result = await db.execute(
update(DocumentChunk)
.where(DocumentChunk.id == row.id, DocumentChunk.is_active.is_(True))
.values(embedding=vector)
)
updated += result.rowcount or 0
cursor = rows[-1].id

return {"updated": updated, "resume_from": cursor, "dry_run": False}


def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
mode = parser.add_mutually_exclusive_group(required=True)
mode.add_argument("--memory", action="store_true")
mode.add_argument("--documents", action="store_true")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--owner-id", type=int)
parser.add_argument("--knowledge-base-id")
parser.add_argument("--resume-from")
parser.add_argument("--batch-size", type=int, default=100)
return parser


async def _main() -> None:
args = _parser().parse_args()
if args.batch_size <= 0:
raise SystemExit("--batch-size must be positive")
if args.owner_id is not None and args.owner_id <= 0:
raise SystemExit("--owner-id must be positive")
if args.documents and args.resume_from:
try:
resume_from = int(args.resume_from)
except ValueError as exc:
raise SystemExit("--resume-from must be a numeric chunk row ID") from exc
if resume_from < 0:
raise SystemExit("--resume-from must be non-negative")
try:
report = await document_embedding_backfill(args) if args.documents else await memory_embedding_backfill(args)
print(json.dumps(report, sort_keys=True))
finally:
await engine.dispose()


if __name__ == "__main__":
asyncio.run(_main())
43 changes: 43 additions & 0 deletions app/mneme/memoria/server/eval/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,46 @@ that defaults to off. Operators can force the single-agent path immediately by
setting `memory_agent.multi_agent.enabled` to `false` in `memoria.json`.
Operators may stage the rollout with `rollout_percent` and `allowed_modes` in
the same section.

## Public retrieval benchmark

`beir.py` runs a real offline retrieval benchmark on BEIR SciFact. It downloads
the official archive linked by the Hugging Face dataset card, verifies the
published MD5, deterministically samples test queries, and compares the
configured dense model with a TF-IDF lexical baseline, RRF hybrid retrieval,
and an optional configured CrossEncoder. Generated data and reports stay under
the git-ignored `storage/eval/` directory.

The application image already contains the model dependencies, so no host
Python environment is required:

```zsh
DOCKER=/Applications/Docker.app/Contents/Resources/bin/docker
"$DOCKER" run --rm \
--user 0:0 \
-e HOME=/tmp \
-e HF_ENDPOINT=https://huggingface.co \
-e RERANKER_ENABLED=true \
-e BEIR_EVAL_GIT_COMMIT="$(git rev-parse HEAD)" \
-e BEIR_EVAL_GIT_DIRTY="$(test -z "$(git status --porcelain)" && echo false || echo true)" \
-v "$PWD:/app" \
-w /app \
reminder-app:local \
python -m app.mneme.memoria.server.eval.beir \
--sample-size 100 \
--corpus-size 1000 \
--top-k 10 \
--candidate-k 50 \
--rerank-k 20 \
--reranker \
--output storage/eval/beir/scifact-report.json
```

The report records the repository commit and dirty state, dataset checksum and
revision, sampled query IDs, resolved model source, configuration, timings,
per-pipeline Recall/MRR/NDCG, and failure samples. Embedding caches are bound to
the resolved model source and exact sampled document text. TF-IDF is only an offline lexical proxy;
use live evaluation against PostgreSQL when exact backend parity is required.
The default 1000-document corpus keeps every qrels-positive document for the
sampled queries and fills the remainder with fixed-seed distractors. This is a
repeatable development benchmark, not the official full-corpus BEIR score.
Loading
Loading