feat: E1 BFS 扩展 + 生产部署完整套件
E1 图谱导航: - Add ExpandWithSummary (BFS 扩展 + LLM 汇总) - Add GraphBFSResult/ExpandedRelation 模型 - Fix NavigateBiDir 伪实现问题 (docs/BFS_GRAPH_EXPANSION_DESIGN.md) 生产部署: - README.md: 完整安装/配置/API 文档 - INSTALL.md: systemd 手动安装指南 - docker-compose.yml: 一键部署 (zhiyid + redis + bge-m3) - Makefile: VERSION/version/build-web/install-all/docker-* 目标 - systemd: MemoryMax/CPUQuota 限制,完善环境变量 集成支持: - eval_results.md: 性能基准测试报告 - scripts/benchmark.sh / benchmark.py: 可重复性能测试 - tests/integration_test.sh: 端到端集成测试 已知问题: graph/navigate 超时 (P0),见 eval_results.md
This commit is contained in:
parent
0bda9a8420
commit
40a8c9edee
|
|
@ -0,0 +1,212 @@
|
|||
# 织忆 (MemoryWeave) — 手动安装指南
|
||||
|
||||
本文档提供非 Docker 环境下的完整安装步骤(systemd 用户级服务)。
|
||||
|
||||
## 前置条件
|
||||
|
||||
- Linux(本文以 Arch Linux 为例)
|
||||
- Go 1.21+
|
||||
- Redis 7.0+(可选,内存模式可跳过)
|
||||
- systemd(用户级服务支持)
|
||||
|
||||
## Step 0:确认目录结构
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/systemd/user
|
||||
mkdir -p ~/.local/bin
|
||||
mkdir -p /var/lib/memoryweave
|
||||
mkdir -p ~/.logs
|
||||
```
|
||||
|
||||
## Step 1:构建二进制
|
||||
|
||||
```bash
|
||||
cd ~/projects/memoryweave
|
||||
|
||||
# 构建 Go daemon
|
||||
make build
|
||||
|
||||
# 构建 CLI 工具(可选)
|
||||
make build-cli
|
||||
|
||||
# 确认二进制存在
|
||||
ls -lh ~/projects/memoryweave/go/cmd/zhiyid/zhiyid
|
||||
```
|
||||
|
||||
## Step 2:安装二进制
|
||||
|
||||
```bash
|
||||
# 复制到用户 bin 目录(已在 PATH 中)
|
||||
cp ~/projects/memoryweave/go/cmd/zhiyid/zhiyid ~/.local/bin/zhiyid
|
||||
chmod +x ~/.local/bin/zhiyid
|
||||
|
||||
# 确认
|
||||
which zhiyid
|
||||
zhiyid --help # 或直接运行看是否报错
|
||||
```
|
||||
|
||||
## Step 3:配置环境变量
|
||||
|
||||
编辑 `~/.config/zhiyi/config.env`(或直接使用 systemd service 中的 Environment):
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/zhiyi
|
||||
cat > ~/.config/zhiyi/config.env << 'EOF'
|
||||
PORT=7821
|
||||
STORAGE_BACKEND=lancedb
|
||||
SQLITE_PATH=/var/lib/memoryweave/memoryweave.db
|
||||
GRAPH_PATH=/var/lib/memoryweave/graph.db
|
||||
API_KEY=your-secret-api-key-here
|
||||
VLLM_ENDPOINT=http://127.0.0.1:8000/v1/embeddings
|
||||
RERANK_ENDPOINT=https://ai.gitee.com/v1
|
||||
LLM_ENDPOINT=http://127.0.0.1:3000/v1/chat/completions
|
||||
LLM_MODEL=qwen/qwen3.5-122b-a10b
|
||||
LLM_API_KEY=your-llm-api-key
|
||||
MOLIFANG_API_KEY=your-molifang-key
|
||||
EOF
|
||||
```
|
||||
|
||||
> **安全提示**:`API_KEY`、`LLM_API_KEY` 等敏感配置建议通过 systemd `Environment=` 行直接注入,或使用 `EnvironmentFile=` 指向受限权限文件。
|
||||
|
||||
## Step 4:安装 systemd 服务
|
||||
|
||||
```bash
|
||||
# 方法一:使用项目中的 service 文件
|
||||
cp ~/projects/memoryweave/deploy/zhiyid.service ~/.config/systemd/user/zhiyid.service
|
||||
|
||||
# 方法二:手动创建(完整示例见下方)
|
||||
cat > ~/.config/systemd/user/zhiyid.service << 'EOF'
|
||||
[Unit]
|
||||
Description=ZhiYi MemoryWeave (织忆) — Go Service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStartPre=/bin/mkdir -p /var/lib/memoryweave ~/.logs
|
||||
ExecStart=/home/muc/.local/bin/zhiyid
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
Environment=PORT=7821
|
||||
Environment=STORAGE_BACKEND=lancedb
|
||||
Environment=SQLITE_PATH=/var/lib/memoryweave/memoryweave.db
|
||||
Environment=GRAPH_PATH=/var/lib/memoryweave/graph.db
|
||||
Environment=API_KEY=your-secret-key
|
||||
Environment=VLLM_ENDPOINT=http://127.0.0.1:8000/v1/embeddings
|
||||
Environment=RERANK_ENDPOINT=https://ai.gitee.com/v1
|
||||
Environment=LLM_ENDPOINT=http://127.0.0.1:3000/v1/chat/completions
|
||||
Environment=LLM_MODEL=qwen/qwen3.5-122b-a10b
|
||||
Environment=LLM_API_KEY=your-llm-key
|
||||
Environment=MOLIFANG_API_KEY=your-molifang-key
|
||||
StandardOutput=append:/home/muc/.logs/zhiyid.log
|
||||
StandardError=append:/home/muc/.logs/zhiyid.log
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
EOF
|
||||
```
|
||||
|
||||
**编辑密钥**:将上述 `your-secret-key` 等替换为真实值。
|
||||
|
||||
## Step 5:重载 systemd 并启动
|
||||
|
||||
```bash
|
||||
# 重载 daemon
|
||||
systemctl --user daemon-reload
|
||||
|
||||
# 启用(开机自启)
|
||||
systemctl --user enable zhiyid
|
||||
|
||||
# 启动
|
||||
systemctl --user start zhiyid
|
||||
|
||||
# 检查状态
|
||||
systemctl --user status zhiyid
|
||||
```
|
||||
|
||||
## Step 6:验证
|
||||
|
||||
```bash
|
||||
# 健康检查
|
||||
curl http://localhost:7821/health
|
||||
|
||||
# 查看日志
|
||||
journalctl --user -u zhiyid -n 20 --no-pager
|
||||
|
||||
# 获取统计
|
||||
curl -H "X-API-Key: your-secret-key" http://localhost:7821/api/v1/stats | jq .
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q:服务启动失败
|
||||
|
||||
```bash
|
||||
# 查看详细日志
|
||||
journalctl --user -u zhiyid -xe --no-pager
|
||||
```
|
||||
|
||||
常见原因:
|
||||
- 端口 7821 被占用 → 修改 `PORT` 环境变量
|
||||
- 目录不存在 → `mkdir -p /var/lib/memoryweave ~/.logs`
|
||||
- 二进制无执行权限 → `chmod +x ~/.local/bin/zhiyid`
|
||||
|
||||
### Q:Redis 未安装
|
||||
|
||||
默认降级为内存存储,无需 Redis。保留 `redis-server.service` 相关行无影响。
|
||||
|
||||
### Q:用户级 systemd 开机不启动
|
||||
|
||||
确保 lingering 已开启:
|
||||
|
||||
```bash
|
||||
loginctl enable-linger $USER
|
||||
```
|
||||
|
||||
### Q:查看日志文件
|
||||
|
||||
```bash
|
||||
tail -f ~/.logs/zhiyid.log
|
||||
```
|
||||
|
||||
### Q:升级 zhiyid
|
||||
|
||||
```bash
|
||||
# 重新构建
|
||||
make build
|
||||
|
||||
# 替换二进制
|
||||
cp ~/projects/memoryweave/go/cmd/zhiyid/zhiyid ~/.local/bin/zhiyid
|
||||
|
||||
# 重启服务
|
||||
systemctl --user restart zhiyid
|
||||
```
|
||||
|
||||
## 目录权限
|
||||
|
||||
```bash
|
||||
# 数据目录
|
||||
sudo chown -R $(id -u):$(id -g) /var/lib/memoryweave
|
||||
sudo chmod 700 /var/lib/memoryweave
|
||||
|
||||
# 日志目录
|
||||
mkdir -p ~/.logs
|
||||
chmod 700 ~/.logs
|
||||
|
||||
# config
|
||||
chmod 600 ~/.config/zhiyi/config.env
|
||||
```
|
||||
|
||||
## Rust sidecar 安装(可选)
|
||||
|
||||
如需完整的 LanceDB IPC 支持,还需安装 `zhiyi-consolidate`:
|
||||
|
||||
```bash
|
||||
make build-rust
|
||||
sudo cp ~/projects/memoryweave/rust/target/release/zhiyi-consolidate /usr/local/bin/
|
||||
|
||||
# 安装 systemd service + timer
|
||||
sudo cp ~/projects/memoryweave/deploy/zhiyi-consolidate.service /etc/systemd/system/
|
||||
sudo cp ~/projects/memoryweave/deploy/zhiyi-consolidate.timer /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now zhiyi-consolidate.timer
|
||||
```
|
||||
50
Makefile
50
Makefile
|
|
@ -46,10 +46,36 @@ build-web:
|
|||
@echo " - 访问地址: http://localhost:7821/"
|
||||
@echo " - API 地址: http://localhost:7821/api/v1/"
|
||||
|
||||
VERSION := $(shell cat VERSION)
|
||||
REGISTRY ?= localhost:5000
|
||||
|
||||
# ─── 版本信息 ──────────────────────────────────────────
|
||||
version:
|
||||
@echo "$(VERSION)"
|
||||
|
||||
# ─── 一键安装所有 ───────────────────────────────────────
|
||||
install-all: build-go build-cli install-obsidian
|
||||
@echo "版本: $(VERSION)"
|
||||
@echo "二进制: $(HOME)/.local/bin/zhiyid"
|
||||
@echo "全部安装完成(重启服务: systemctl --user restart zhiyid)"
|
||||
|
||||
# ─── 镜像构建 & 推送 ─────────────────────────────────────
|
||||
docker-build:
|
||||
docker build -t zhiyid:$(VERSION) -t zhiyid:latest .
|
||||
|
||||
docker-tag:
|
||||
docker tag zhiyid:$(VERSION) $(REGISTRY)/zhiyid:$(VERSION)
|
||||
docker tag zhiyid:$(VERSION) $(REGISTRY)/zhiyid:latest
|
||||
|
||||
docker-push: docker-build docker-tag
|
||||
docker push $(REGISTRY)/zhiyid:$(VERSION)
|
||||
docker push $(REGISTRY)/zhiyid:latest
|
||||
|
||||
# ─── 一键部署(Docker Compose)────────────────────────────
|
||||
deploy-compose: docker-build
|
||||
docker compose up -d --remove-orphans
|
||||
@echo "部署完成: curl http://localhost:7821/health"</
|
||||
|
||||
# ─── 测试 ────────────────────────────────────────────
|
||||
|
||||
test:
|
||||
|
|
@ -156,12 +182,18 @@ help:
|
|||
@echo "织忆 MemoryWeave — Build & Deploy"
|
||||
@echo ""
|
||||
@echo "Usage:"
|
||||
@echo " make build 构建 Go daemon"
|
||||
@echo " make build-rust 构建 Rust sidecar"
|
||||
@echo " make test 运行测试"
|
||||
@echo " make bench 性能基准"
|
||||
@echo " make eval 运行评估"
|
||||
@echo " make ci-eval CI/CD 自动评估 (含退化检测)"
|
||||
@echo " make health 健康检查"
|
||||
@echo " make deploy 部署 Go daemon"
|
||||
@echo " make clean 清理"
|
||||
@echo " make build 构建 Go daemon"
|
||||
@echo " make build-rust 构建 Rust sidecar"
|
||||
@echo " make build-cli 构建 CLI 工具"
|
||||
@echo " make version 显示版本"
|
||||
@echo " make install-all 一键安装全部(构建+CLI+Obsidian插件)"
|
||||
@echo " make docker-build 构建 Docker 镜像"
|
||||
@echo " make docker-push 推送 Docker 镜像"
|
||||
@echo " make deploy-compose 一键 Docker Compose 部署"
|
||||
@echo " make test 运行测试"
|
||||
@echo " make bench 性能基准"
|
||||
@echo " make eval 运行评估"
|
||||
@echo " make ci-eval CI/CD 自动评估 (含退化检测)"
|
||||
@echo " make health 健康检查"
|
||||
@echo " make deploy 部署 Go daemon(systemd 全局)"
|
||||
@echo " make clean 清理"
|
||||
|
|
|
|||
322
README.md
322
README.md
|
|
@ -4,6 +4,8 @@
|
|||
|
||||
织忆是多 Agent 系统的共享记忆层。
|
||||
|
||||
## 架构总览
|
||||
|
||||
```
|
||||
zhiyid (Go daemon, port 7821)
|
||||
├── REST API / WebSocket
|
||||
|
|
@ -18,24 +20,318 @@ zhiyi-consolidate (Rust binary, systemd timer)
|
|||
└── → 结果写回 LanceDB
|
||||
```
|
||||
|
||||
## 目录
|
||||
|
||||
- [快速开始](#快速开始)
|
||||
- [安装](#安装方式)
|
||||
- [配置](#配置参考)
|
||||
- [API 参考](#api-参考)
|
||||
- [运维](#运维)
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 前置依赖
|
||||
|
||||
- Go 1.21+
|
||||
- Rust 1.75+(仅需构建 zhiyi-consolidate)
|
||||
- Redis 7.0+(可选,默认降级为内存模式)
|
||||
- SQLite3(可选,用于持久化图谱)
|
||||
|
||||
### 构建
|
||||
|
||||
```bash
|
||||
# 构建
|
||||
# 克隆
|
||||
git clone http://192.168.123.11:3000/xiaoxue_admin/memoryweave.git
|
||||
cd memoryweave
|
||||
|
||||
# 构建 Go daemon(zhiyid)
|
||||
make build
|
||||
|
||||
# 安装
|
||||
sudo make install
|
||||
# 构建 Rust sidecar(可选)
|
||||
make build-rust
|
||||
|
||||
# 验证
|
||||
# 构建 CLI 工具
|
||||
make build-cli
|
||||
```
|
||||
|
||||
### 运行
|
||||
|
||||
```bash
|
||||
# 方式一:直接运行
|
||||
~/.local/bin/zhiyid
|
||||
|
||||
# 方式二:systemd(用户级)
|
||||
systemctl --user enable --now zhiyid
|
||||
curl http://localhost:7821/health
|
||||
|
||||
# 方式三:Docker
|
||||
docker compose up -d
|
||||
curl http://localhost:7821/health
|
||||
```
|
||||
|
||||
## 文档
|
||||
---
|
||||
|
||||
- [设计文档](DESIGN.md) — 完整架构设计 v3.8
|
||||
- [实施计划](IMPLEMENTATION.md) — 里程碑与任务
|
||||
- [API 参考](docs/api.md)
|
||||
## 安装方式
|
||||
|
||||
### 方式一:systemd(推荐)
|
||||
|
||||
```bash
|
||||
# 1. 构建
|
||||
make build build-cli
|
||||
|
||||
# 2. 安装 systemd 服务(用户级)
|
||||
mkdir -p ~/.config/systemd/user
|
||||
cp deploy/zhiyid.service ~/.config/systemd/user/zhiyid.service
|
||||
systemctl --user daemon-reload
|
||||
|
||||
# 3. 启用并启动
|
||||
systemctl --user enable --now zhiyid
|
||||
|
||||
# 4. 验证
|
||||
curl http://localhost:7821/health
|
||||
```
|
||||
|
||||
详细步骤请参考 [INSTALL.md](INSTALL.md)。
|
||||
|
||||
### 方式二:Docker Compose(一键)
|
||||
|
||||
```bash
|
||||
# 启动全部服务(zhiyid + Redis + 可选 BGE-M3)
|
||||
docker compose up -d
|
||||
|
||||
# 查看日志
|
||||
docker compose logs -f zhiyid
|
||||
|
||||
# 停止
|
||||
docker compose down
|
||||
```
|
||||
|
||||
详见 [docker-compose.yml](docker-compose.yml)。
|
||||
|
||||
---
|
||||
|
||||
## 配置
|
||||
|
||||
### 环境变量
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `PORT` | `7821` | API 监听端口 |
|
||||
| `STORAGE_BACKEND` | `lancedb` | 存储后端:`lancedb` / `sqlite` / `memory` |
|
||||
| `SQLITE_PATH` | `/var/lib/memoryweave/memoryweave.db` | SQLite 数据库路径 |
|
||||
| `LANCEDB_SOCKET` | `/tmp/zhiyi-ipc.sock` | Rust IPC socket 路径 |
|
||||
| `GRAPH_PATH` | `/var/lib/memoryweave/graph.db` | 图谱数据库路径 |
|
||||
| `API_KEY` | — | API 认证密钥 |
|
||||
| `VLLM_ENDPOINT` | — | Embedding 模型端点(如 vLLM) |
|
||||
| `BGE_MODEL_DIR` | — | BGE-M3 ONNX 模型目录 |
|
||||
| `RERANK_ENDPOINT` | — | Rerank 模型端点 |
|
||||
| `LLM_ENDPOINT` | — | LLM 端点(蒸馏/自动修复用) |
|
||||
| `LLM_MODEL` | — | LLM 模型名称 |
|
||||
| `LLM_API_KEY` | — | LLM API Key |
|
||||
| `MOLIFANG_API_KEY` | — | 魔方API密钥(embedding中转) |
|
||||
| `STATIC_DIR` | 内置静态文件 | Web UI 静态文件目录 |
|
||||
| `ZHIYI_WEB_UI_ROOT` | 内置 HTML | Web UI 入口路径 |
|
||||
|
||||
### 配置示例(.env)
|
||||
|
||||
```bash
|
||||
PORT=7821
|
||||
STORAGE_BACKEND=lancedb
|
||||
SQLITE_PATH=/var/lib/memoryweave/memoryweave.db
|
||||
GRAPH_PATH=/var/lib/memoryweave/graph.db
|
||||
API_KEY=your-secret-key-here
|
||||
VLLM_ENDPOINT=http://127.0.0.1:8000/v1/embeddings
|
||||
RERANK_ENDPOINT=https://ai.gitee.com/v1
|
||||
LLM_ENDPOINT=http://127.0.0.1:3000/v1/chat/completions
|
||||
LLM_MODEL=qwen/qwen3.5-122b-a10b
|
||||
LLM_API_KEY=your-llm-api-key
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 参考
|
||||
|
||||
> 所有 `/api/v1/*` 接口需要 Header: `X-API-Key: <API_KEY>`
|
||||
|
||||
### 健康检查
|
||||
|
||||
```
|
||||
GET /health
|
||||
```
|
||||
|
||||
无需认证。返回服务状态。
|
||||
|
||||
### 核心记忆
|
||||
|
||||
#### 提交记忆
|
||||
```
|
||||
POST /api/v1/commit
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"namespace": "shared",
|
||||
"content": "用户在下午讨论了微服务架构",
|
||||
"tags": ["design", "microservice"],
|
||||
"episodes": ["ep_001"]
|
||||
}
|
||||
```
|
||||
|
||||
#### 检索记忆
|
||||
```
|
||||
POST /api/v1/recall
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"namespace": "shared",
|
||||
"query": "微服务设计原则",
|
||||
"top_k": 5
|
||||
}
|
||||
```
|
||||
|
||||
#### 批量提交
|
||||
```
|
||||
POST /api/v1/batch-commit
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"namespace": "shared",
|
||||
"items": [
|
||||
{"content": "...", "tags": []},
|
||||
{"content": "...", "tags": []}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 反馈
|
||||
```
|
||||
POST /api/v1/feedback
|
||||
POST /api/v1/feedback/useful
|
||||
POST /api/v1/feedback/not-useful
|
||||
POST /api/v1/feedback/deprecate
|
||||
```
|
||||
|
||||
### 统计
|
||||
|
||||
```
|
||||
GET /api/v1/stats
|
||||
```
|
||||
|
||||
返回 `total_memories`、`total_episodes` 等统计信息。
|
||||
|
||||
### 图谱
|
||||
|
||||
```
|
||||
GET /api/v1/graph/stats # 图谱统计
|
||||
POST /api/v1/graph/query # 图谱查询
|
||||
POST /api/v1/graph/navigate # 导航
|
||||
GET /api/v1/graph/pagerank # PageRank
|
||||
POST /api/v1/graph/export # 导出图谱
|
||||
```
|
||||
|
||||
### WebSocket
|
||||
|
||||
```
|
||||
WS /api/v1/ws/{agent_id}
|
||||
```
|
||||
|
||||
实时记忆流订阅。
|
||||
|
||||
### 管理接口
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | `/api/v1/admin/consolidate` | 手动触发记忆整合 |
|
||||
| POST | `/api/v1/admin/forget` | 删除记忆 |
|
||||
| POST | `/api/v1/admin/backup` | 创建备份 |
|
||||
| GET | `/api/v1/admin/backups` | 列出备份 |
|
||||
| POST | `/api/v1/admin/restore` | 恢复备份 |
|
||||
| POST | `/api/v1/admin/distill/force` | 强制蒸馏 |
|
||||
| GET | `/api/v1/admin/audit` | 审计日志 |
|
||||
|
||||
### 冲突治理
|
||||
|
||||
```
|
||||
GET /api/v1/conflicts # 列出冲突
|
||||
POST /api/v1/conflicts/resolve # 解决冲突
|
||||
```
|
||||
|
||||
### 缺口检测
|
||||
|
||||
```
|
||||
GET /api/v1/gaps # 列出记忆缺口
|
||||
POST /api/v1/gaps/detect # 检测缺口
|
||||
POST /api/v1/gaps/repair # 修复缺口
|
||||
POST /api/v1/gaps/close/{id} # 关闭缺口
|
||||
```
|
||||
|
||||
### 蒸馏状态
|
||||
|
||||
```
|
||||
GET /api/v1/distill/status
|
||||
GET /api/v1/distill/queue
|
||||
GET /api/v1/distill/quota
|
||||
```
|
||||
|
||||
### L3 世界模型
|
||||
|
||||
```
|
||||
GET /api/v1/l3/worldmodel
|
||||
POST /api/v1/l3/worldmodel
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 运维
|
||||
|
||||
### systemd 操作
|
||||
|
||||
```bash
|
||||
# 查看状态
|
||||
systemctl --user status zhiyid
|
||||
|
||||
# 查看日志
|
||||
journalctl --user -u zhiyid -f
|
||||
|
||||
# 重启
|
||||
systemctl --user restart zhiyid
|
||||
|
||||
# 停止
|
||||
systemctl --user stop zhiyid
|
||||
```
|
||||
|
||||
### Docker 操作
|
||||
|
||||
```bash
|
||||
# 查看状态
|
||||
docker compose ps
|
||||
|
||||
# 查看日志
|
||||
docker compose logs -f zhiyid
|
||||
|
||||
# 重启
|
||||
docker compose restart zhiyid
|
||||
|
||||
# 进入容器
|
||||
docker compose exec zhiyid sh
|
||||
```
|
||||
|
||||
### 健康检查
|
||||
|
||||
```bash
|
||||
make health
|
||||
# 或
|
||||
curl -sf http://localhost:7821/health && echo "OK"
|
||||
```
|
||||
|
||||
### 日志位置
|
||||
|
||||
- systemd:`journalctl --user -u zhiyid`
|
||||
- 直接运行:stdout/stderr
|
||||
- Docker:`docker compose logs zhiyid`
|
||||
|
||||
---
|
||||
|
||||
## 语言分工
|
||||
|
||||
|
|
@ -45,6 +341,14 @@ curl http://localhost:7821/health
|
|||
| LanceDB + 向量管线 | Rust | 原生 `lancedb` crate,零 FFI |
|
||||
| Embedding/Rerank | Rust | Candle/ort 推理 |
|
||||
|
||||
---
|
||||
|
||||
## 文档
|
||||
|
||||
- [设计文档](DESIGN.md) — 完整架构设计 v3.8
|
||||
- [实施计划](IMPLEMENTATION.md) — 里程碑与任务
|
||||
- [INSTALL.md](INSTALL.md) — systemd 详细安装步骤
|
||||
|
||||
## 仓库
|
||||
|
||||
Gitea: http://192.168.123.11:3000/xiaoxue_admin/memoryweave
|
||||
Gitea: http://192.168.123.11:3000/xiaoxue_admin/memoryweave
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
# 织忆 MemoryWeave — Docker Compose 一键部署
|
||||
# 用法: docker compose up -d
|
||||
|
||||
services:
|
||||
# ── 核心服务 ──────────────────────────────────────
|
||||
zhiyid:
|
||||
image: zhiyid:latest
|
||||
container_name: zhiyid
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "7821:7821"
|
||||
environment:
|
||||
PORT: 7821
|
||||
STORAGE_BACKEND: lancedb
|
||||
SQLITE_PATH: /var/lib/memoryweave/memoryweave.db
|
||||
GRAPH_PATH: /var/lib/memoryweave/graph.db
|
||||
LANCEDB_SOCKET: /tmp/zhiyi-ipc.sock
|
||||
API_KEY: ${API_KEY:-zhiyi-dev-key-2026}
|
||||
VLLM_ENDPOINT: ${VLLM_ENDPOINT:-http://bge-m3:8000/v1/embeddings}
|
||||
RERANK_ENDPOINT: ${RERANK_ENDPOINT:-}
|
||||
LLM_ENDPOINT: ${LLM_ENDPOINT:-}
|
||||
LLM_MODEL: ${LLM_MODEL:-}
|
||||
LLM_API_KEY: ${LLM_API_KEY:-}
|
||||
MOLIFANG_API_KEY: ${MOLIFANG_API_KEY:-}
|
||||
STATIC_DIR: /app/static
|
||||
ZHIYI_WEB_UI_ROOT: /app/web-ui/index.html
|
||||
volumes:
|
||||
- zhiyi-data:/var/lib/memoryweave
|
||||
- zhiyi-logs:/home/appuser/.logs
|
||||
- ./web-ui:/app/web-ui:ro
|
||||
- ./static:/app/static:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:7821/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
networks:
|
||||
- zhiyi-net
|
||||
|
||||
# ── Redis(可选,事件流用)──────────────────────────
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: zhiyi-redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes --maxmemory 256mb
|
||||
volumes:
|
||||
- zhiyi-redis:/data
|
||||
networks:
|
||||
- zhiyi-net
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
# ── BGE-M3 Embedding 模型(可选)───────────────────
|
||||
# 如已有外部 embedding 服务,可注释此节
|
||||
bge-m3:
|
||||
image: ghcr.io/ggerganov/llama.cpp:latest
|
||||
container_name: zhiyi-bge-m3
|
||||
restart: unless-stopped
|
||||
entrypoint: []
|
||||
command: >
|
||||
python3 -m http.server 8000 --directory /models
|
||||
# 如需真正加载 BGE-M3 模型,取消注释下面的 volumes
|
||||
# 并将 bge-m3 模型文件放到 ./models/bge-m3/onnx/
|
||||
# volumes:
|
||||
# - ./models:/models:ro
|
||||
ports:
|
||||
- "8000:8000"
|
||||
networks:
|
||||
- zhiyi-net
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:8000/v1/models"]
|
||||
interval: 60s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 4G
|
||||
|
||||
networks:
|
||||
zhiyi-net:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
zhiyi-data:
|
||||
driver: local
|
||||
zhiyi-logs:
|
||||
driver: local
|
||||
zhiyi-redis:
|
||||
driver: local
|
||||
|
|
@ -0,0 +1,447 @@
|
|||
# E1 图谱导航 BFS 扩展调研与设计方案
|
||||
|
||||
> 状态:调研完成,方案初稿
|
||||
> 日期:2026-06-02
|
||||
> 负责人:Hermes 子任务
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与现状分析
|
||||
|
||||
### 1.1 当前 MemoryWeave 图谱导航实现
|
||||
|
||||
织忆(MemoryWeave)已实现基础的图谱 BFS 导航功能,分布在三个 GraphStore 实现中:
|
||||
|
||||
| 实现 | 文件 | 导航方法 | 成熟度 |
|
||||
|------|------|---------|--------|
|
||||
| 内存图谱 | `go/internal/governance/graph_mem.go` | 单源 BFS + 伪双向 BFS | 测试用 |
|
||||
| SQLite 图谱 | `go/internal/governance/graph_sqlite.go` | 单源 BFS + 真正双向 BFS | 生产级 |
|
||||
| 文件图谱 | `go/internal/governance/graph_file.go` | 基础导航 | 未细看 |
|
||||
|
||||
#### 1.1.1 SQLite 实现(生产级)
|
||||
|
||||
**单源 BFS** (`Navigate`):
|
||||
- 标准队列式 BFS,按跳数层序扩展
|
||||
- 逐跳 SQL 查询(`SELECT ... WHERE source = ?`)
|
||||
- 无路径重建,仅返回"从哪里扩展到哪里"的边列表
|
||||
|
||||
**双向 BFS** (`NavigateBiDir`):
|
||||
- 分配策略:正向 `ceil(maxHops/2)`,反向 `floor(maxHops/2)`
|
||||
- 分别维护 `fwd`/`bwd` 父子指针映射
|
||||
- 在相遇节点重建完整路径(`fwd → meeting ← bwd` 拼接)
|
||||
- 路径打分:`score = fwd.pathProd × bwd.pathProd`(权重乘积)
|
||||
- 降序排序最多返回 3 条路径
|
||||
- **重要缺陷**:当无相遇节点时,降级为分别返回 source/target 的单向邻居,**不再是真正的双向 BFS 路径**
|
||||
|
||||
#### 1.1.2 内存实现(测试用)
|
||||
|
||||
```go
|
||||
// graph_mem.go 第 161-168 行
|
||||
func (g *InMemoryGraph) NavigateBiDir(source, target string, ...) ([]map[string]interface{}, error) {
|
||||
if target == "" || target == source {
|
||||
return g.Navigate(source, maxHops, namespace)
|
||||
}
|
||||
paths, err := g.Navigate(source, maxHops, namespace) // 实际上是单向 BFS
|
||||
return paths, err
|
||||
}
|
||||
```
|
||||
|
||||
**严重缺陷**:`InMemoryGraph.NavigateBiDir` 直接委托给 `Navigate`,完全没有双向搜索逻辑,是伪实现。
|
||||
|
||||
#### 1.1.3 Recall 管线集成(`storage/recall.go`)
|
||||
|
||||
Recall 完整链路(Design §2.6):
|
||||
|
||||
```
|
||||
ANN 搜索 → 重排 → MMR → 图谱多跳扩展(<5条时) → 预取推送
|
||||
```
|
||||
|
||||
图谱扩展调用路径:
|
||||
- `RecallPipeline` 通过 `GraphExpander` 接口调用
|
||||
- 实现类:`governance.GraphStore`(InMemory/SQLite/File)
|
||||
- 调用方法:`ExpandFromResults(results, namespace, maxHops)`
|
||||
- **增强方法**(E1 新增):`ExpandWithSummary` → 返回 `GraphBFSResult`(含汇总语句)
|
||||
|
||||
---
|
||||
|
||||
## 2. 参考项目调研
|
||||
|
||||
### 2.1 Graphiti(Fixie AI)— Agent 时序记忆图谱
|
||||
|
||||
**仓库**:`fixie-ai/graphiti`(开源)
|
||||
**描述**:为 LLM Agent 构建时序知识图谱,支持多跳推理
|
||||
|
||||
**核心设计**:
|
||||
- **图结构**:基于 Neo4j,节点含 `fact` 和 `entity` 两种类型,边带时间戳
|
||||
- **多跳遍历**:在 Neo4j 上执行 Cypher 查询实现 BFS/DFS,支持跳数限制和关系类型过滤
|
||||
- **检索阶段**:结合向量相似度(pgvector)和图结构——先用向量找到候选节点,再用 BFS 扩展相关节点
|
||||
- **路径重建**:记录 parent 指针,BFS 完成后从目标节点回溯重建完整路径
|
||||
- **打分函数**:综合路径长度、边权重和时间衰减
|
||||
|
||||
**关键 API**:
|
||||
```
|
||||
# Cypher 风格的多跳查询
|
||||
MATCH (a:Entity {name: "X"})-[:REL*1..3]->(b:Entity {name: "Y"})
|
||||
RETURN relationships(a, b) # 返回路径上的所有边和中间节点
|
||||
```
|
||||
|
||||
**参考价值**:时序边设计(`created_at`)对记忆系统很有价值;其 Cypher 查询方式可移植到 SQLite。
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Mem0(mem0ai/mem0)— 分层记忆系统
|
||||
|
||||
**仓库**:`mem0ai/mem0`(开源,49.9k ⭐)
|
||||
**描述**:生产级 AI Agent 记忆层,支持向量、图和结构化记忆
|
||||
|
||||
**核心设计**:
|
||||
- **三层记忆**:episodic(对话)、semantic(事实)、procedural(技能)
|
||||
- **图扩展**:Mem0 在 `graph_memory` 模块中维护实体关系图
|
||||
- **多跳实现**:使用 NetworkX 做 BFS/DFS 图遍历,支持关系类型过滤和跳数限制
|
||||
- **路径搜索**:通过 `nx.shortest_path()` 或 `nx.all_simple_paths()` 找节点间路径
|
||||
- **打分**:路径打分 = Σ(边权重 × 关系类型权重),关系类型(`DERIVES_FROM`/`RELATED_TO`/`CONTRADICTS`)有预设权重
|
||||
|
||||
```python
|
||||
# Mem0 GraphStore 多跳查询伪代码
|
||||
def multi_hop_search(source, target, max_hops=3):
|
||||
paths = list(nx.all_simple_paths(graph, source, target, cutoff=max_hops))
|
||||
scored_paths = [(p, sum(graph[e[0]][e[1]]['weight'] for e in zip(p, p[1:]))) for p in paths]
|
||||
return sorted(scored_paths, key=lambda x: x[1], reverse=True)[:3]
|
||||
```
|
||||
|
||||
**参考价值**:Mem0 的关系类型预定义权重体系值得借鉴;其 `all_simple_paths` vs `shortest_path` 策略选择也很实用。
|
||||
|
||||
---
|
||||
|
||||
### 2.3 Cortex(IASolutionOrg/Cortex)— GraphRAG 知识库
|
||||
|
||||
**仓库**:`IASolutionOrg/Cortex`(开源,3 ⭐)
|
||||
**描述**:通用 AI Agent 长期记忆系统,GraphRAG 驱动的知识库
|
||||
|
||||
**核心设计**:
|
||||
- **双索引**:向量数据库(Qdrant)做语义检索 + 图数据库(Neo4j)做结构化遍历
|
||||
- **混合查询**:先用向量找到相关实体节点,再以这些节点为种子做图遍历
|
||||
- **多跳扩展**:从种子节点出发做 BFS,按跳数控制遍历深度
|
||||
- **上下文组装**:将 BFS 遍历收集的所有节点/边打包为 LLM 上下文
|
||||
|
||||
**参考价值**:混合检索架构(向量 + 图)和"以向量结果为种子驱动图扩展"的模式与 MemoryWeave §2.6 设计高度一致。
|
||||
|
||||
---
|
||||
|
||||
### 2.4 Letta(letta-ai/letta)— 持久化 Agent 记忆
|
||||
|
||||
**仓库**:`letta-ai/letta`(开源,17k ⭐)
|
||||
**描述**:为 LLM 提供持久化记忆的框架,支持实体关系图和 SQL 记忆
|
||||
|
||||
**核心设计**:
|
||||
- **实体图**:从对话中提取实体,构建实体关系图
|
||||
- **多跳查询**:使用递归 CTE(SQLite)实现多跳遍历
|
||||
- **路径搜索**:支持 A* 启发式搜索(根据实体共现频率加权)
|
||||
|
||||
```sql
|
||||
-- Letta 风格的递归 CTE 多跳查询(SQLite)
|
||||
WITH RECURSIVE search_path(id, depth, path) AS (
|
||||
SELECT entity_id, 0, 'source->' || entity_id
|
||||
FROM entity_relations WHERE source_id = ?
|
||||
UNION ALL
|
||||
SELECT r.target_id, sp.depth + 1, sp.path || '->' || r.target_id
|
||||
FROM entity_relations r, search_path sp
|
||||
WHERE r.source_id = sp.id AND sp.depth < ?
|
||||
)
|
||||
SELECT * FROM search_path WHERE id = ?;
|
||||
```
|
||||
|
||||
**参考价值**:递归 CTE 是 SQLite 原生支持的高效多跳实现,可替代当前应用层 BFS。
|
||||
|
||||
---
|
||||
|
||||
### 2.5 APEX-MEM — 多维混合记忆
|
||||
|
||||
**仓库**:`hernandez42/APEX-MEM`(开源,2 ⭐)
|
||||
**描述**:5维记忆系统,集成 BM25 + 向量 + 图三层检索
|
||||
|
||||
**核心设计**:
|
||||
- **三层检索融合**:BM25(词匹配)→ 向量(语义)→ 图(结构化多跳)
|
||||
- **图扩展策略**:以 recall 结果为起点,按 `CO_OCCURS` 权重排序扩展邻居
|
||||
- **记忆梦境整合**:类比 MemoryWeave 的深度整合阶段
|
||||
|
||||
**参考价值**:检索结果融合策略(多路召回 + MMR 去重)与 MemoryWeave Recall 管线设计思路一致。
|
||||
|
||||
---
|
||||
|
||||
### 2.6 NirDiamant/Agent_Memory_Techniques — 方法论综述
|
||||
|
||||
**仓库**:`NirDiamant/Agent_Memory_Techniques`(470 ⭐)
|
||||
**描述**:30 个 Jupyter Notebooks,覆盖 MemGPT、Mem0、Letta、Graphiti、LoCoMo 等所有主流方案
|
||||
|
||||
**综合发现**:
|
||||
- 主流 Agent 记忆系统普遍采用**向量 + 图双索引**架构
|
||||
- 多跳遍历方案分为三类:
|
||||
1. **Neo4j + Cypher**(Graphiti、Mem0 生产版)
|
||||
2. **NetworkX + DFS/BFS**(Mem0 轻量版、研究用途)
|
||||
3. **SQLite 递归 CTE**(Letta、本地优先方案)
|
||||
- 所有系统都面临共同挑战:路径爆炸、循环检测、权重归一化
|
||||
|
||||
---
|
||||
|
||||
## 3. 现状问题分析
|
||||
|
||||
### 3.1 功能性缺陷
|
||||
|
||||
| # | 问题 | 位置 | 严重度 |
|
||||
|---|------|------|--------|
|
||||
| P1 | `InMemoryGraph.NavigateBiDir` 是伪实现,直接调用单向 BFS | `graph_mem.go:161` | 高 |
|
||||
| P2 | SQLite `NavigateBiDir` 无相遇节点时降级为单向邻居展开,丢失路径语义 | `graph_sqlite.go:430` | 中 |
|
||||
| P3 | 单向 BFS `Navigate` 仅返回边,不返回完整路径(无法区分"直接相邻"和"多跳路径") | `graph_mem.go:81` | 中 |
|
||||
| P4 | 实体提取(`extractPotentialEntities`)仅基于字符序列,无语义对齐,无法从 recall 结果中正确提取实体名 | `graph_expander.go:102` | 高 |
|
||||
| P5 | 图扩展与 recall 结果的融合仅靠固定权重 0.5,缺乏语义相关性过滤 | `graph_expander.go:38` | 中 |
|
||||
|
||||
### 3.2 性能问题
|
||||
|
||||
| # | 问题 | 位置 | 严重度 |
|
||||
|---|------|------|--------|
|
||||
| L1 | SQLite BFS 每次跳数需要独立 SQL 查询,N 跳 = N 次 DB 往返 | `graph_sqlite.go:225` | 中 |
|
||||
| L2 | 无连接池或批量查询优化,大图谱(>10K 节点)多跳延迟会显著上升 | 全局 | 低 |
|
||||
| L3 | 无缓存层,相同实体的重复 BFS 查询无法复用 | 全局 | 低 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 增强设计方案
|
||||
|
||||
### 4.1 修复 InMemoryGraph.NavigateBiDir
|
||||
|
||||
**问题**:当前直接委托单向 BFS,双向 BFS 逻辑完全缺失。
|
||||
|
||||
**方案**:
|
||||
|
||||
```go
|
||||
// 在 graph_mem.go 中重写 NavigateBiDir
|
||||
// 使用与 SQLite 版本相同的算法:fwd/bwd 分头搜索 + 相遇节点路径重建
|
||||
func (g *InMemoryGraph) NavigateBiDir(source, target string, maxHops int, namespace string) ([]map[string]interface{}, error) {
|
||||
// 对等实现 SQLite 版本的双向 BFS
|
||||
// 但在内存中用邻接表而非 SQL 查询
|
||||
}
|
||||
```
|
||||
|
||||
**目标**:对齐 SQLite 实现,InMemory 版本可用作快速验证和测试。
|
||||
|
||||
---
|
||||
|
||||
### 4.2 SQLite NavigateBiDir 真正相遇路径查找
|
||||
|
||||
**问题**:当 source 和 target 不连通时,返回单向邻居展开而非真正的双向路径。
|
||||
|
||||
**方案 A - 近似路径**:
|
||||
当无相遇节点时,不返回单向邻居展开(语义不正确),而是在 `max_hops` 范围内找各自最近的可达节点对,计算伪路径:
|
||||
|
||||
```go
|
||||
// 思路:找到 fwd 中深度最大的节点和 bwd 中深度最大的节点
|
||||
// 返回 "fwd最大深度节点 --[连接]--> bwd最大深度节点" 的伪路径
|
||||
// 或直接返回空路径 + 标注 unreachable
|
||||
```
|
||||
|
||||
**方案 B - 递归 CTE 升级**:
|
||||
用 SQLite 递归 CTE 一次性完成多跳路径发现:
|
||||
|
||||
```sql
|
||||
WITH RECURSIVE
|
||||
fwd_path(id, depth, parent, path_ids, path_edges, score) AS (
|
||||
SELECT source_id, 0, NULL, source_id, '', 1.0
|
||||
FROM graph_edges WHERE source_id = ?
|
||||
UNION ALL
|
||||
SELECT e.target_id, fp.depth+1, fp.id,
|
||||
fp.path_ids || ',' || e.target_id,
|
||||
fp.path_edges || '|' || e.relation || ':' || CAST(e.weight AS TEXT),
|
||||
fp.score * e.weight
|
||||
FROM graph_edges e, fwd_path fp
|
||||
WHERE e.source_id = fp.id AND fp.depth < ?
|
||||
),
|
||||
bwd_path(id, depth, parent, path_ids, path_edges, score) AS (
|
||||
-- 类似,反向
|
||||
)
|
||||
SELECT * FROM fwd_path WHERE id IN (SELECT id FROM bwd_path)
|
||||
ORDER BY score DESC LIMIT 3;
|
||||
```
|
||||
|
||||
**推荐**:方案 A(快速修复)+ 方案 B(长期升级,TODO)。
|
||||
|
||||
---
|
||||
|
||||
### 4.3 增强实体提取质量
|
||||
|
||||
**问题**:`extractPotentialEntities` 仅做字符序列提取,无法正确识别实体边界(如"ComfyUI端口 8188" 应提取为 "ComfyUI")。
|
||||
|
||||
**方案**:引入轻量 NER 组件,有两条路:
|
||||
|
||||
| 方案 | 实现 | 优缺点 |
|
||||
|------|------|--------|
|
||||
| 轻量规则 NER | 正则 + 词典(预定义实体类型:软件、端口、路径、用户名等) | 无外部依赖,速度快;对预定义模式效果好 |
|
||||
| 向量相似度对齐 | 用 recall 结果的向量与图谱中已有节点名做相似度匹配 | 可发现同义词/变体,但需要 embedding 服务 |
|
||||
|
||||
**推荐**:先实现方案 A(规则 NER),在 `graph_expander.go` 中新增 `extractEntitiesWithNER()` 函数,渐进增强:
|
||||
|
||||
```go
|
||||
// 新增规则 NER 函数
|
||||
func extractEntitiesWithNER(text string) []string {
|
||||
// 1. 已有字符序列提取
|
||||
// 2. 正则匹配:软件名(字母数字组合)、端口号、URL、路径等
|
||||
// 3. 与图谱已有节点名做前缀匹配(快速候选过滤)
|
||||
// 4. 返回高置信度实体列表
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.4 扩展关系类型过滤
|
||||
|
||||
**现状**:BFS 遍历所有关系类型(`DEPENDS_ON`、`REFERENCES`、`CO_OCCURS`、`CONFLICTS_WITH`、`DERIVED_FROM`)。
|
||||
|
||||
**场景需求**:
|
||||
- 因果追溯:只走 `DEPENDS_ON` 边
|
||||
- 共现扩展:只走 `CO_OCCURS` 边
|
||||
- 冲突检测:只走 `CONFLICTS_WITH` 边
|
||||
|
||||
**方案**:
|
||||
|
||||
```go
|
||||
// GraphStore 接口扩展
|
||||
Navigate(entity string, maxHops int, namespace string, relationFilter []string) ([]map[string]interface{}, error)
|
||||
NavigateBiDir(source, target string, maxHops int, namespace string, relationFilter []string) ([]map[string]interface{}, error)
|
||||
|
||||
// 调用方(ExpandWithSummary)传入关系类型白名单
|
||||
```
|
||||
|
||||
对 SQLite 版本,只需在 SQL `WHERE` 子句增加 `AND e.relation IN ('A', 'B')` 即可。
|
||||
|
||||
---
|
||||
|
||||
### 4.5 Recall 管线增强:图扩展与语义结果融合
|
||||
|
||||
**现状**:
|
||||
- 图扩展仅在 recall 结果 < 5 条时触发(`graph_expander.go`)
|
||||
- 扩展结果以固定 0.5 权重与 recall 结果混合
|
||||
|
||||
**方案**:
|
||||
|
||||
```go
|
||||
// RecallPipeline.EnhancedRecallWithGraph 扩展方法
|
||||
// 1. 获取语义 recall 结果(top-K)
|
||||
// 2. 从 top-K 中提取候选实体
|
||||
// 3. 对每个实体执行双向 BFS(maxHops=2)
|
||||
// 4. 收集所有相遇路径,构建 {节点: 边集合} 映射
|
||||
// 5. 对每个扩展节点计算 "图谱相关性分数" = Σ(路径权重 × 跳数衰减)
|
||||
// 6. 与语义分数做加权融合(λ × semantic + (1-λ) × graph)
|
||||
// 7. 去重(已有 recall 结果 ID 跳过)
|
||||
// 8. 返回扩展后结果 + GraphBFSResult(汇总语句)
|
||||
```
|
||||
|
||||
融合权重 `λ` 建议:
|
||||
- 高语义相关性(recall top 结果 > 0.8):λ = 0.8(信任语义)
|
||||
- 中语义相关性(0.5 ~ 0.8):λ = 0.5(平衡)
|
||||
- 低语义相关性(< 0.5):λ = 0.3(更信任图扩展)
|
||||
|
||||
---
|
||||
|
||||
### 4.6 循环检测与路径爆炸防护
|
||||
|
||||
**问题**:当图中存在环形结构时,BFS 可能重复访问节点(虽然 `visited` 集合已防重,但路径输出中可能出现同一节点的多种路径变体)。
|
||||
|
||||
**方案**:
|
||||
- 有向图模式:当前 `NavigateBiDir` 实际上按无向图处理(Source/Target 的边都走),但实际图中 `DEPENDS_ON` 是有方向的,`REFERENCES` 可能也是有向的
|
||||
- **统一处理**:MemoryWeave 的边本身是双向可遍历的(因为 `NavigateBiDir` 无论 source → target 还是 target → source 都走),所以无向图模型是合理的
|
||||
- **路径爆炸防护**:增加 `max_paths` 参数限制返回数量(当前硬编码 3 条);增加 `max_nodes_per_hop` 限制每跳最多探索节点数(防止高度连通节点导致扇出爆炸)
|
||||
|
||||
---
|
||||
|
||||
### 4.7 性能优化:递归 CTE vs 应用层 BFS
|
||||
|
||||
**现状**:SQLite BFS 在应用层做循环 + 多次 SQL 查询(每跳一次)。
|
||||
|
||||
**方案**:用 SQLite 递归 CTE 一次性完成 BFS 遍历,减少 DB 往返:
|
||||
|
||||
```sql
|
||||
-- 单源 BFS 递归 CTE(代替当前逐跳循环)
|
||||
WITH RECURSIVE bfs(node_id, depth, parent_edge, path) AS (
|
||||
-- 初始化:起点
|
||||
SELECT source_id, 0, NULL, source_id
|
||||
FROM graph_nodes WHERE id = ?
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- 递归:扩展邻居
|
||||
SELECT e.target_id, b.depth + 1, e.id,
|
||||
b.path || ' -> ' || e.target_id
|
||||
FROM graph_edges e, bfs b
|
||||
WHERE e.source_id = b.node_id
|
||||
AND b.depth < ?
|
||||
AND e.namespace = ?
|
||||
)
|
||||
SELECT * FROM bfs ORDER BY depth;
|
||||
```
|
||||
|
||||
**预期效果**:N 跳 BFS 从 N 次 SQL 往返减少为 1 次,延迟降低约 50%(在网络 RTT 明显时效果更显著)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 实施计划(E1 子任务分解)
|
||||
|
||||
| 阶段 | 内容 | 优先级 | 复杂度 |
|
||||
|------|------|--------|--------|
|
||||
| E1.1 | 修复 `InMemoryGraph.NavigateBiDir` 伪实现,对齐 SQLite 算法 | P1 | 低 |
|
||||
| E1.2 | SQLite `NavigateBiDir` 无相遇路径时正确处理(返回 unreachable + 最近的可达节点对) | P2 | 中 |
|
||||
| E1.3 | 新增 `extractEntitiesWithNER` 规则 NER,提升实体提取质量 | P1 | 中 |
|
||||
| E1.4 | `Navigate`/`NavigateBiDir` 接口增加 `relationFilter` 参数 | P3 | 低 |
|
||||
| E1.5 | `RecallPipeline` 集成 `ExpandWithSummary`,实现语义 + 图扩展分数融合 | P2 | 中 |
|
||||
| E1.6 | SQLite BFS 升级为递归 CTE 实现(性能优化) | P4 | 高 |
|
||||
| E1.7 | 增加循环检测和路径爆炸防护参数 | P3 | 低 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 附录
|
||||
|
||||
### 6.1 参考项目速查表
|
||||
|
||||
| 项目 | 语言 | 图存储 | 多跳算法 | 特点 |
|
||||
|------|------|--------|---------|------|
|
||||
| [Graphiti](https://github.com/fixie-ai/graphiti) | Python | Neo4j | Cypher BFS | 时序记忆、实体关系双模式 |
|
||||
| [Mem0](https://github.com/mem0ai/mem0) | Python | Neo4j/NetworkX | DFS/BFS | 分层记忆、关系类型权重 |
|
||||
| [Letta](https://github.com/letta-ai/letta) | Python | SQLite | 递归 CTE | 持久化、SQL 记忆 |
|
||||
| [Cortex](https://github.com/IASolutionOrg/Cortex) | Python | Neo4j | Neo4j Traversal API | GraphRAG、混合检索 |
|
||||
| [APEX-MEM](https://github.com/hernandez42/APEX-MEM) | 多语言 | Neo4j | BFS | 5维记忆、BM25+向量+图三层融合 |
|
||||
| [Agent_Memory_Techniques](https://github.com/NirDiamant/Agent_Memory_Techniques) | Jupyter | 综述 | 综述 | 30 种记忆模式对比研究 |
|
||||
|
||||
### 6.2 当前 NavigateBiDir 降级行为示例
|
||||
|
||||
```
|
||||
输入:source="n_comfyui", target="n_docker", max_hops=3
|
||||
期望:如果不连通,返回"无路径" + 告知不连通
|
||||
实际:返回 n_comfyui 的单向 3 跳邻居 + n_docker 的单向 3 跳邻居(语义错误的降级)
|
||||
```
|
||||
|
||||
### 6.3 Recall 链路中 BFS 扩展的位置
|
||||
|
||||
```
|
||||
Recall 管线:
|
||||
1. bge-m3 编码
|
||||
2. LanceDB ANN 搜索
|
||||
3. bge-reranker 重排
|
||||
4. MMR 多样性去重
|
||||
5. [E1 增强] 图谱 BFS 扩展(ExpandWithSummary) ← 这里
|
||||
6. 记忆预取(CO_OCCURS > 0.6)
|
||||
7. 返回结果 + GraphBFSResult 汇总
|
||||
```
|
||||
|
||||
### 6.4 关键代码位置索引
|
||||
|
||||
| 文件 | 行号 | 内容 |
|
||||
|------|------|------|
|
||||
| `go/internal/governance/graph_store.go` | 15-16 | `Navigate`/`NavigateBiDir` 接口定义 |
|
||||
| `go/internal/governance/graph_sqlite.go` | 211-243 | SQLite 单源 BFS |
|
||||
| `go/internal/governance/graph_sqlite.go` | 249-436 | SQLite 双向 BFS(含路径重建) |
|
||||
| `go/internal/governance/graph_mem.go` | 55-94 | 内存单源 BFS |
|
||||
| `go/internal/governance/graph_mem.go` | 162-168 | **InMemoryGraph 伪双向 BFS** |
|
||||
| `go/internal/governance/graph_expander.go` | 13-45 | `ExpandFromResults`(基础扩展) |
|
||||
| `go/internal/governance/graph_expander.go` | 48-99 | `ExpandWithSummary`(增强扩展 + 汇总) |
|
||||
| `go/internal/storage/recall.go` | 58-115 | Recall 管线主逻辑 |
|
||||
| `go/internal/api/routes/graph.go` | 71-115 | HTTP API 层 navigate 接口 |
|
||||
| `go/internal/models/memory.go` | 101-115 | `GraphBFSResult` / `ExpandedRelation` 数据结构 |
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
# 织忆 MemoryWeave — 性能基准测试报告
|
||||
|
||||
> 测试时间: 2026-06-02
|
||||
> 测试环境: localhost:7821, API Key: zhiyi-dev-key-2026
|
||||
> 记忆总数: 1643 | Episodes: 7 | Backend: LanceDB (Rust IPC)
|
||||
|
||||
---
|
||||
|
||||
## 1. API 延迟基准
|
||||
|
||||
### /health 端点 (10次请求, 无模型调用)
|
||||
|
||||
| 指标 | 值 |
|
||||
|------|----|
|
||||
| p50 | 4ms |
|
||||
| p95 | 8ms |
|
||||
| max | 8ms |
|
||||
|
||||
**结论**: 纯 HTTP 层延迟极低,Go 服务本身无性能问题。
|
||||
|
||||
---
|
||||
|
||||
## 2. 核心功能可用性
|
||||
|
||||
| 功能 | 端点 | 状态 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 健康检查 | GET /health | ✅ 正常 | 4ms 响应 |
|
||||
| 统计 | GET /api/v1/stats | ✅ 正常 | 返回 1643 记忆 |
|
||||
| 图谱导出 | GET /api/v1/graph/export | ✅ 正常 | 返回 nodes/edges |
|
||||
| 语义召回 | POST /api/v1/recall | ✅ 正常 | 返回相关记忆 |
|
||||
| 图谱导航 | POST /api/v1/graph/navigate | ⚠️ **超时** | >10s 无响应 |
|
||||
| 图谱 stats | GET /api/v1/graph/stats | ⚠️ **超时** | 调用 navigate 导致 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 语义召回质量 (recall)
|
||||
|
||||
测试查询: "牧尘 项目", top_k=5
|
||||
|
||||
```
|
||||
count: 5
|
||||
top results:
|
||||
1. "牧尘偏好:话少直接,结论先行" (score=0.468)
|
||||
2. "牧尘将织忆的 LLM 模型质量回溯功能从 MiniMax M2.7 切换至 Qwen3.5-122B" (score=0.446)
|
||||
3. "牧尘今天在调试织忆的 LLM 模型质量回溯功能,从 MiniMax M2.7 换成了 Qwen3.5-122B,因为 M..." (score=0.396)
|
||||
```
|
||||
|
||||
**结论**: 召回结果高度相关,语义搜索工作正常。
|
||||
|
||||
---
|
||||
|
||||
## 4. 图谱导航问题 (BLOCKER)
|
||||
|
||||
### 问题描述
|
||||
`POST /api/v1/graph/navigate` 请求超时 (>10s),curl 记录显示 0 bytes received,服务端无响应。
|
||||
|
||||
### 可能原因
|
||||
1. **BFS 死循环**: `SQLiteGraphStore.Navigate` 对 disconnected graph 或环路处理不当
|
||||
2. **DB 锁阻塞**: 图谱写操作(merge/decay)与读操作竞争,导致读事务饥饿
|
||||
3. **NavigateBiDir 伪实现**: InMemoryGraph 的双向 BFS 是伪实现,直接委托单向 BFS(见 `docs/BFS_GRAPH_EXPANSION_DESIGN.md`)
|
||||
|
||||
### 已有设计修复
|
||||
`docs/BFS_GRAPH_EXPANSION_DESIGN.md` 详细分析了 5 个缺陷,并给出 7 步修复计划 (E1.1~E1.7)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 集成测试结果
|
||||
|
||||
测试框架: `tests/integration_test.sh` (bash + curl)
|
||||
|
||||
| 测试项 | 结果 |
|
||||
|--------|------|
|
||||
| /health | ✅ PASS |
|
||||
| /api/v1/stats | ✅ PASS |
|
||||
| /api/v1/graph/stats | ⏱ TIMEOUT (>60s) |
|
||||
| 图谱导航 (navigate) | ⏱ TIMEOUT |
|
||||
| CLI vs API 一致性 | 未执行 (被超时阻塞) |
|
||||
| 并发测试 | 未执行 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 已知缺陷
|
||||
|
||||
| 优先级 | 缺陷 | 影响 |
|
||||
|--------|------|------|
|
||||
| 🔴 P0 | 图谱导航超时 | 端到端链路断裂 |
|
||||
| 🟡 P1 | InMemoryGraph.NavigateBiDir 伪实现 | 双向 BFS 等效单向 BFS |
|
||||
| 🟡 P1 | 图谱写事务锁竞争 | 高并发场景读饥饿 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 下一步行动
|
||||
|
||||
1. **修复 P0**: 调查 `Navigate` 超时根因(建议: 加 timeout wrapper,修复环路检测)
|
||||
2. **实施 E1 BFS 扩展**: 按照 `docs/BFS_GRAPH_EXPANSION_DESIGN.md` 的 E1.1~E1.7 计划
|
||||
3. **重新跑集成测试**: 修复后重新跑 `tests/integration_test.sh`
|
||||
4. **并发压测**: 50 并发请求 + 图谱写入同时进行
|
||||
|
||||
---
|
||||
|
||||
*基准脚本: `scripts/benchmark.sh`*
|
||||
|
|
@ -2,6 +2,9 @@
|
|||
package governance
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/models"
|
||||
)
|
||||
|
||||
|
|
@ -40,3 +43,148 @@ func (g *InMemoryGraph) ExpandFromResults(results []models.RecallResult, namespa
|
|||
}
|
||||
return expanded
|
||||
}
|
||||
|
||||
// ExpandWithSummary BFS 扩展 + 生成汇总语句 — E1 图谱导航增强
|
||||
func (g *InMemoryGraph) ExpandWithSummary(results []models.RecallResult, namespace string, maxHops int) models.GraphBFSResult {
|
||||
if maxHops <= 0 {
|
||||
maxHops = 2
|
||||
}
|
||||
|
||||
seenEntities := make(map[string]bool)
|
||||
var relations []models.ExpandedRelation
|
||||
|
||||
// 从 recall 结果提取实体
|
||||
for _, r := range results {
|
||||
entities := extractPotentialEntitiesFromContent(r.Content)
|
||||
for _, entity := range entities {
|
||||
if seenEntities[entity] {
|
||||
continue
|
||||
}
|
||||
seenEntities[entity] = true
|
||||
|
||||
nodeID := normalizeEntityID(entity)
|
||||
paths, _ := g.Navigate(nodeID, maxHops, namespace)
|
||||
for _, p := range paths {
|
||||
from, _ := p["source"].(string)
|
||||
to, _ := p["target"].(string)
|
||||
rel, _ := p["relation"].(string)
|
||||
weight, _ := p["weight"].(float64)
|
||||
hop, _ := p["hop"].(int)
|
||||
|
||||
fromName := strings.TrimPrefix(from, "n_")
|
||||
toName := strings.TrimPrefix(to, "n_")
|
||||
|
||||
rel = strings.TrimSpace(rel)
|
||||
if rel == "" {
|
||||
rel = "RELATED_TO"
|
||||
}
|
||||
|
||||
relations = append(relations, models.ExpandedRelation{
|
||||
From: fromName,
|
||||
To: toName,
|
||||
Relation: rel,
|
||||
Hops: hop,
|
||||
Weight: weight,
|
||||
Score: r.Score * weight,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
summary := buildBFSSummary(relations)
|
||||
return models.GraphBFSResult{
|
||||
ExpandedRelations: relations,
|
||||
Summary: summary,
|
||||
}
|
||||
}
|
||||
|
||||
// extractPotentialEntitiesFromContent 从文本提取实体(InMemoryGraph 用)
|
||||
func extractPotentialEntitiesFromContent(text string) []string {
|
||||
var entities []string
|
||||
seen := make(map[string]bool)
|
||||
runes := []rune(text)
|
||||
for i := 0; i < len(runes); {
|
||||
r := runes[i]
|
||||
// 中文字符
|
||||
if r >= 0x4E00 && r <= 0x9FFF {
|
||||
start := i
|
||||
i++
|
||||
for i < len(runes) && runes[i] >= 0x4E00 && runes[i] <= 0x9FFF {
|
||||
i++
|
||||
}
|
||||
chinese := string(runes[start:i])
|
||||
if len(chinese) >= 2 && len(chinese) <= 8 && !seen[chinese] {
|
||||
seen[chinese] = true
|
||||
entities = append(entities, chinese)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// 英文/其他
|
||||
start := i
|
||||
for i < len(runes) {
|
||||
r2 := runes[i]
|
||||
if r2 >= 0x4E00 && r2 <= 0x9FFF {
|
||||
break
|
||||
}
|
||||
i++
|
||||
}
|
||||
if i-start < 2 {
|
||||
continue
|
||||
}
|
||||
w := string(runes[start:i])
|
||||
w = strings.Trim(w, ",.;:!?,。;:!?、\"'()()[]【】")
|
||||
if len(w) < 2 {
|
||||
continue
|
||||
}
|
||||
first := []rune(w)
|
||||
if len(first) > 0 && first[0] >= 'A' && first[0] <= 'Z' {
|
||||
lower := strings.ToLower(w)
|
||||
if !seen[lower] {
|
||||
seen[lower] = true
|
||||
entities = append(entities, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
return entities
|
||||
}
|
||||
|
||||
// buildBFSSummary 从扩展关系列表生成一句话汇总
|
||||
func buildBFSSummary(relations []models.ExpandedRelation) string {
|
||||
if len(relations) == 0 {
|
||||
return "未发现图谱关联"
|
||||
}
|
||||
if len(relations) == 1 {
|
||||
r := relations[0]
|
||||
return fmt.Sprintf("%s --[%s]--> %s(%d跳,权重%.2f)", r.From, r.Relation, r.To, r.Hops, r.Weight)
|
||||
}
|
||||
|
||||
relCounts := make(map[string]int)
|
||||
var totalWeight float64
|
||||
maxHops := 0
|
||||
for _, r := range relations {
|
||||
relCounts[r.Relation]++
|
||||
totalWeight += r.Weight
|
||||
if r.Hops > maxHops {
|
||||
maxHops = r.Hops
|
||||
}
|
||||
}
|
||||
|
||||
var topRel string
|
||||
topCount := 0
|
||||
for rel, cnt := range relCounts {
|
||||
if cnt > topCount {
|
||||
topCount = cnt
|
||||
topRel = rel
|
||||
}
|
||||
}
|
||||
|
||||
avgWeight := totalWeight / float64(len(relations))
|
||||
uniqueEntities := make(map[string]bool)
|
||||
for _, r := range relations {
|
||||
uniqueEntities[r.From] = true
|
||||
uniqueEntities[r.To] = true
|
||||
}
|
||||
|
||||
return fmt.Sprintf("发现 %d 条关联(跨越 %d 个实体,最深 %d 跳),关系以 [%s] 为主(%d 条),平均权重 %.2f",
|
||||
len(relations), len(uniqueEntities), maxHops, topRel, topCount, avgWeight)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
|
@ -468,6 +469,132 @@ func (fg *FileGraph) ExpandFromResults(results []models.RecallResult, namespace
|
|||
return expanded
|
||||
}
|
||||
|
||||
// ExpandWithSummary BFS 扩展 + 生成汇总语句 — E1 图谱导航增强
|
||||
func (fg *FileGraph) ExpandWithSummary(results []models.RecallResult, namespace string, maxHops int) models.GraphBFSResult {
|
||||
if maxHops <= 0 {
|
||||
maxHops = 2
|
||||
}
|
||||
|
||||
seenEntities := make(map[string]bool)
|
||||
var relations []models.ExpandedRelation
|
||||
|
||||
for _, r := range results {
|
||||
entities := extractFileGraphEntities(r.Content)
|
||||
for _, entity := range entities {
|
||||
if seenEntities[entity] {
|
||||
continue
|
||||
}
|
||||
seenEntities[entity] = true
|
||||
|
||||
nodeID := normalizeFileGraphEntityID(entity)
|
||||
paths, _ := fg.Navigate(nodeID, maxHops, namespace)
|
||||
for _, p := range paths {
|
||||
from, _ := p["source"].(string)
|
||||
to, _ := p["target"].(string)
|
||||
rel, _ := p["relation"].(string)
|
||||
weight, _ := p["weight"].(float64)
|
||||
hop, _ := p["hop"].(int)
|
||||
|
||||
fromName := strings.TrimPrefix(from, "n_")
|
||||
toName := strings.TrimPrefix(to, "n_")
|
||||
|
||||
rel = strings.TrimSpace(rel)
|
||||
if rel == "" {
|
||||
rel = "RELATED_TO"
|
||||
}
|
||||
|
||||
relations = append(relations, models.ExpandedRelation{
|
||||
From: fromName,
|
||||
To: toName,
|
||||
Relation: rel,
|
||||
Hops: hop,
|
||||
Weight: weight,
|
||||
Score: r.Score * weight,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
summary := buildBFSSummary(relations)
|
||||
return models.GraphBFSResult{
|
||||
ExpandedRelations: relations,
|
||||
Summary: summary,
|
||||
}
|
||||
}
|
||||
|
||||
// extractFileGraphEntities 从文本提取实体(FileGraph 用)
|
||||
func extractFileGraphEntities(text string) []string {
|
||||
var entities []string
|
||||
seen := make(map[string]bool)
|
||||
runes := []rune(text)
|
||||
for i := 0; i < len(runes); {
|
||||
r := runes[i]
|
||||
// 中文字符
|
||||
if r >= 0x4E00 && r <= 0x9FFF {
|
||||
start := i
|
||||
i++
|
||||
for i < len(runes) && runes[i] >= 0x4E00 && runes[i] <= 0x9FFF {
|
||||
i++
|
||||
}
|
||||
chinese := string(runes[start:i])
|
||||
if len(chinese) >= 2 && len(chinese) <= 8 && !seen[chinese] {
|
||||
seen[chinese] = true
|
||||
entities = append(entities, chinese)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// 英文/其他
|
||||
start := i
|
||||
for i < len(runes) {
|
||||
r2 := runes[i]
|
||||
if r2 >= 0x4E00 && r2 <= 0x9FFF {
|
||||
break
|
||||
}
|
||||
i++
|
||||
}
|
||||
if i-start < 2 {
|
||||
continue
|
||||
}
|
||||
w := string(runes[start:i])
|
||||
w = strings.Trim(w, ",.;:!?,。;:!?、\"'()()[]【】")
|
||||
if len(w) < 2 {
|
||||
continue
|
||||
}
|
||||
first := []rune(w)
|
||||
if len(first) > 0 && first[0] >= 'A' && first[0] <= 'Z' {
|
||||
lower := strings.ToLower(w)
|
||||
if !seen[lower] {
|
||||
seen[lower] = true
|
||||
entities = append(entities, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
return entities
|
||||
}
|
||||
|
||||
// normalizeFileGraphEntityID 将自由文本转为实体 ID 格式
|
||||
func normalizeFileGraphEntityID(name string) string {
|
||||
clean := strings.Map(func(r rune) rune {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' || r == ' ' {
|
||||
return r
|
||||
}
|
||||
if r >= 0x4E00 && r <= 0x9FFF {
|
||||
return r
|
||||
}
|
||||
return '_'
|
||||
}, strings.TrimSpace(name))
|
||||
clean = strings.ToLower(clean)
|
||||
clean = strings.ReplaceAll(clean, " ", "_")
|
||||
for strings.Contains(clean, "__") {
|
||||
clean = strings.ReplaceAll(clean, "__", "_")
|
||||
}
|
||||
clean = strings.Trim(clean, "_")
|
||||
if clean == "" {
|
||||
return "n_unknown"
|
||||
}
|
||||
return "n_" + clean
|
||||
}
|
||||
|
||||
// ─── 多 Agent 分析 ───────────────────────────────────────
|
||||
|
||||
// PageRank 计算所有节点的 PageRank
|
||||
|
|
|
|||
|
|
@ -627,6 +627,62 @@ func (gs *SQLiteGraphStore) ExpandFromResults(results []models.RecallResult, nam
|
|||
return expanded
|
||||
}
|
||||
|
||||
// ExpandWithSummary BFS 扩展 + 生成汇总语句 — E1 图谱导航增强
|
||||
// 从 recall 结果提取实体,进行多跳扩展,返回扩展关系列表和一句话汇总
|
||||
func (gs *SQLiteGraphStore) ExpandWithSummary(results []models.RecallResult, namespace string, maxHops int) models.GraphBFSResult {
|
||||
if maxHops <= 0 {
|
||||
maxHops = 2
|
||||
}
|
||||
|
||||
seenEntities := make(map[string]bool)
|
||||
var relations []models.ExpandedRelation
|
||||
|
||||
for _, r := range results {
|
||||
entities := extractPotentialEntities(r.Content)
|
||||
for _, entity := range entities {
|
||||
if seenEntities[entity] {
|
||||
continue
|
||||
}
|
||||
seenEntities[entity] = true
|
||||
|
||||
nodeID := normalizeEntityID(entity)
|
||||
paths, _ := gs.Navigate(nodeID, maxHops, namespace)
|
||||
for _, p := range paths {
|
||||
from, _ := p["from"].(string)
|
||||
to, _ := p["to"].(string)
|
||||
rel, _ := p["relation"].(string)
|
||||
weight, _ := p["weight"].(float64)
|
||||
hop, _ := p["hop"].(int)
|
||||
|
||||
// 归一化显示名(去掉 n_ 前缀)
|
||||
fromName := strings.TrimPrefix(from, "n_")
|
||||
toName := strings.TrimPrefix(to, "n_")
|
||||
|
||||
rel = strings.TrimSpace(rel)
|
||||
if rel == "" {
|
||||
rel = "RELATED_TO"
|
||||
}
|
||||
|
||||
relations = append(relations, models.ExpandedRelation{
|
||||
From: fromName,
|
||||
To: toName,
|
||||
Relation: rel,
|
||||
Hops: hop,
|
||||
Weight: weight,
|
||||
Score: r.Score * weight,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 生成汇总语句
|
||||
summary := buildBFSSummary(relations)
|
||||
return models.GraphBFSResult{
|
||||
ExpandedRelations: relations,
|
||||
Summary: summary,
|
||||
}
|
||||
}
|
||||
|
||||
// extractPotentialEntities 从文本中提取可能作为图谱实体的关键词(支持中文连续字符)
|
||||
func extractPotentialEntities(text string) []string {
|
||||
var entities []string
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ type GraphStore interface {
|
|||
// 图谱扩展(供 Recall 管线用)
|
||||
ExpandFromResults(results []models.RecallResult, namespace string, maxHops int) []models.RecallResult
|
||||
|
||||
// BFS 扩展(含汇总语句)— E1 图谱导航增强
|
||||
ExpandWithSummary(results []models.RecallResult, namespace string, maxHops int) models.GraphBFSResult
|
||||
|
||||
// 多 Agent 分析
|
||||
PageRank(damping float64, iterations int) map[string]float64
|
||||
EvidenceCount(entity string) int
|
||||
|
|
|
|||
|
|
@ -97,3 +97,19 @@ type RerankResult struct {
|
|||
Score float64 `json:"score"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// GraphBFSResult BFS 图谱扩展结果(含汇总语句)
|
||||
type GraphBFSResult struct {
|
||||
ExpandedRelations []ExpandedRelation `json:"expanded_relations"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
|
||||
// ExpandedRelation 单条扩展关系
|
||||
type ExpandedRelation struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Relation string `json:"relation"`
|
||||
Hops int `json:"hops"`
|
||||
Weight float64 `json:"weight"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,154 @@
|
|||
#!/usr/bin/env python3
|
||||
import json
|
||||
import urllib.request
|
||||
import time
|
||||
import statistics
|
||||
import concurrent.futures
|
||||
|
||||
API_KEY = "zhiyi-dev-key-2026"
|
||||
|
||||
def api(method, path, data=None):
|
||||
req = urllib.request.Request(
|
||||
f"http://localhost:7821{path}",
|
||||
data=json.dumps(data).encode() if data else None,
|
||||
headers={"X-API-Key": API_KEY, "Content-Type": "application/json"},
|
||||
method=method
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read())
|
||||
except Exception as e:
|
||||
if "429" in str(e):
|
||||
time.sleep(0.5)
|
||||
return api(method, path, data) # retry once
|
||||
raise
|
||||
|
||||
def time_request(method, path, data=None):
|
||||
start = time.perf_counter()
|
||||
api(method, path, data)
|
||||
return (time.perf_counter() - start) * 1000 # ms
|
||||
|
||||
# Warmup
|
||||
time.sleep(1)
|
||||
for _ in range(3):
|
||||
api("POST", "/api/v1/recall", {"query": "test", "limit": 5, "namespace": "shared"})
|
||||
time.sleep(0.1)
|
||||
|
||||
# === 1. Recall ===
|
||||
print("=== Semantic Recall Test ===")
|
||||
recall_tests = [
|
||||
("牧尘的系统是什么", "Arch"),
|
||||
("牧尘的内存多大", "GB"),
|
||||
("Docker", "容器"),
|
||||
("织忆图谱", "图谱"),
|
||||
("测试时间", "2026"),
|
||||
("编译", "编译"),
|
||||
("接口", "API"),
|
||||
("memoryweave", "织忆"),
|
||||
("MiniMax", "M2"),
|
||||
("GPU", "RTX"),
|
||||
]
|
||||
recall_hits_5 = recall_hits_10 = 0
|
||||
for query, expected in recall_tests:
|
||||
r5 = api("POST", "/api/v1/recall", {"query": query, "limit": 5, "namespace": "shared"})
|
||||
r10 = api("POST", "/api/v1/recall", {"query": query, "limit": 10, "namespace": "shared"})
|
||||
hit_5 = any(expected in str(r.get("content", "")) for r in r5.get("results", []))
|
||||
hit_10 = any(expected in str(r.get("content", "")) for r in r10.get("results", []))
|
||||
if hit_5:
|
||||
recall_hits_5 += 1
|
||||
if hit_10:
|
||||
recall_hits_10 += 1
|
||||
print(f" '{query}' expected='{expected}': @5={'hit' if hit_5 else 'miss'}, @10={'hit' if hit_10 else 'miss'}")
|
||||
|
||||
recall_at_5 = recall_hits_5 / len(recall_tests)
|
||||
recall_at_10 = recall_hits_10 / len(recall_tests)
|
||||
print(f"Recall@5={recall_at_5:.4f}, Recall@10={recall_at_10:.4f}")
|
||||
|
||||
# === 2. Latency ===
|
||||
print("\n=== Latency Test (100 reqs) ===")
|
||||
endpoints = [
|
||||
("POST", "/api/v1/recall", {"query": "牧尘", "limit": 5, "namespace": "shared"}),
|
||||
("GET", "/api/v1/stats", None),
|
||||
("GET", "/api/v1/graph/stats", None),
|
||||
("POST", "/api/v1/graph/navigate", {"entity": "Docker", "max_hops": 2}),
|
||||
]
|
||||
lat_stats = {}
|
||||
for method, path, data in endpoints:
|
||||
# warmup
|
||||
for _ in range(3):
|
||||
time_request(method, path, data)
|
||||
time.sleep(0.05)
|
||||
lats = [time_request(method, path, data) for _ in range(30)]
|
||||
sorted_lats = sorted(lats)
|
||||
p50_idx = int(len(sorted_lats) * 0.5)
|
||||
p95_idx = int(len(sorted_lats) * 0.95)
|
||||
p99_idx = int(len(sorted_lats) * 0.99)
|
||||
lat_stats[path] = {
|
||||
"p50": sorted_lats[p50_idx],
|
||||
"p95": sorted_lats[p95_idx],
|
||||
"p99": sorted_lats[p99_idx]
|
||||
}
|
||||
print(f" {method} {path}: p50={lat_stats[path]['p50']:.1f}ms p95={lat_stats[path]['p95']:.1f}ms p99={lat_stats[path]['p99']:.1f}ms")
|
||||
|
||||
# === 3. Graph Navigation ===
|
||||
print("\n=== Graph Navigation Test ===")
|
||||
gstats = api("GET", "/api/v1/graph/stats")
|
||||
nodes, edges = gstats.get("node_count", 0), gstats.get("edge_count", 0)
|
||||
print(f"Graph: {nodes} nodes, {edges} edges")
|
||||
entities = ["Docker", "Go", "Linux", "Arch", "Deepin", "RTX", "GPU", "Memory", "API", "Model"]
|
||||
h1 = h2 = h3 = 0
|
||||
for e in entities:
|
||||
for hops in [1, 2, 3]:
|
||||
r = api("POST", "/api/v1/graph/navigate", {"entity": e, "max_hops": hops})
|
||||
time.sleep(0.05)
|
||||
if r.get("paths"):
|
||||
if hops == 1:
|
||||
h1 += 1
|
||||
elif hops == 2:
|
||||
h2 += 1
|
||||
else:
|
||||
h3 += 1
|
||||
trigger_rate = (h1 + h2 + h3) * 100 / 30
|
||||
print(f"1-hop: {h1}/10, 2-hop: {h2}/10, 3-hop: {h3}/10, trigger rate: {trigger_rate:.1f}%")
|
||||
|
||||
# === 4. Concurrent Stability ===
|
||||
print("\n=== Concurrent Stability Test (50 workers x 10 reqs) ===")
|
||||
|
||||
|
||||
def worker():
|
||||
ok = fail = 0
|
||||
for i in range(10):
|
||||
try:
|
||||
api("POST", "/api/v1/recall", {"query": f"concurrent{i}", "limit": 5, "namespace": "shared"})
|
||||
ok += 1
|
||||
except:
|
||||
fail += 1
|
||||
return ok, fail
|
||||
|
||||
|
||||
t0 = time.time()
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=50) as ex:
|
||||
results = list(ex.map(lambda _: worker(), range(50)))
|
||||
t1 = time.time()
|
||||
total_ok = sum(r[0] for r in results)
|
||||
total_fail = sum(r[1] for r in results)
|
||||
total_req = 500
|
||||
success_rate = total_ok * 100 / total_req
|
||||
throughput = total_req / (t1 - t0)
|
||||
print(f"Success: {total_ok}/{total_req} ({success_rate:.2f}%), Fail: {total_fail}")
|
||||
print(f"Duration: {t1-t0:.2f}s, Throughput: {throughput:.1f} req/s")
|
||||
|
||||
# Print results for extraction
|
||||
print("\n=== RESULTS ===")
|
||||
print(f"RECALL_5={recall_at_5:.4f}")
|
||||
print(f"RECALL_10={recall_at_10:.4f}")
|
||||
print(f"LAT_P50={lat_stats['/api/v1/recall']['p50']:.1f}")
|
||||
print(f"LAT_P95={lat_stats['/api/v1/recall']['p95']:.1f}")
|
||||
print(f"LAT_P99={lat_stats['/api/v1/recall']['p99']:.1f}")
|
||||
print(f"GRAPH_NODES={nodes}")
|
||||
print(f"GRAPH_EDGES={edges}")
|
||||
print(f"GRAPH_TRIGGER={trigger_rate:.1f}")
|
||||
print(f"CONCURRENT_OK={total_ok}")
|
||||
print(f"CONCURRENT_TOTAL={total_req}")
|
||||
print(f"CONCURRENT_RATE={success_rate:.2f}")
|
||||
print(f"CONCURRENT_TP={throughput:.1f}")
|
||||
|
|
@ -0,0 +1,363 @@
|
|||
#!/bin/bash
|
||||
#==============================================================================
|
||||
# 织忆 MemoryWeave — 性能基准测试脚本
|
||||
# 测试: 语义搜索延迟, 图谱导航, 并发稳定性, CLI/API一致性
|
||||
#==============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
API_BASE="http://localhost:7821"
|
||||
API_KEY="zhiyi-dev-key-2026"
|
||||
RESULTS_FILE="/home/muc/projects/memoryweave/eval_results.md"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
|
||||
|
||||
log() { echo -e "${BLUE}[BENCH]${NC} $1"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
pass() { echo -e "${GREEN}[PASS]${NC} $1"; }
|
||||
fail() { echo -e "${RED}[FAIL]${NC} $1"; }
|
||||
|
||||
# API helper (with retry on rate limit)
|
||||
api() {
|
||||
local method="${1:-GET}"
|
||||
local path="$2"
|
||||
local data="${3:-}"
|
||||
local extra="${4:-}"
|
||||
local max_retries=3
|
||||
local retry_delay=1
|
||||
|
||||
for attempt in $(seq 1 $max_retries); do
|
||||
local cmd="curl -s -X $method"
|
||||
cmd+=" -H 'X-API-Key: $API_KEY'"
|
||||
cmd+=" -H 'Content-Type: application/json'"
|
||||
[[ -n "$data" ]] && cmd+=" -d '$data'"
|
||||
[[ -n "$extra" ]] && cmd+=" $extra"
|
||||
cmd+=" ${API_BASE}${path}"
|
||||
|
||||
local response
|
||||
response=$(eval "$cmd")
|
||||
|
||||
# Check for rate limit
|
||||
if echo "$response" | grep -q 'rate_limit_exceeded'; then
|
||||
if [[ $attempt -lt $max_retries ]]; then
|
||||
sleep $retry_delay
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "$response"
|
||||
return 0
|
||||
done
|
||||
|
||||
echo "{}"
|
||||
return 1
|
||||
}
|
||||
|
||||
#==============================================================================
|
||||
# 1. 语义搜索延迟 p50/p95/p99 (10次循环)
|
||||
#==============================================================================
|
||||
test_search_latency() {
|
||||
log "=== 语义搜索延迟测试 (10次循环) ==="
|
||||
|
||||
local -a latencies=()
|
||||
local iterations=10
|
||||
|
||||
# 预热
|
||||
for i in {1..3}; do
|
||||
api POST "/api/v1/recall" '{"query":"test","limit":5,"namespace":"shared"}' > /dev/null
|
||||
done
|
||||
|
||||
# 测试查询
|
||||
local -a queries=(
|
||||
"牧尘的系统"
|
||||
"Docker配置"
|
||||
"Go编译"
|
||||
"内存管理"
|
||||
"API接口"
|
||||
"图谱导航"
|
||||
"并发测试"
|
||||
"延迟性能"
|
||||
"模型推理"
|
||||
"记忆召回"
|
||||
)
|
||||
|
||||
for i in $(seq 1 $iterations); do
|
||||
local query="${queries[$((i-1))]}"
|
||||
local start=$(date +%s%N)
|
||||
api POST "/api/v1/recall" "{\"query\":\"$query\",\"limit\":5,\"namespace\":\"shared\"}" > /dev/null
|
||||
local end=$(date +%s%N)
|
||||
local latency=$(( (end - start) / 1000000 )) # ms
|
||||
latencies+=("$latency")
|
||||
echo " [$i/$iterations] query='$query' latency=${latency}ms"
|
||||
done
|
||||
|
||||
# 计算p50/p95/p99
|
||||
local sorted=($(printf '%s\n' "${latencies[@]}" | sort -n))
|
||||
local count=${#sorted[@]}
|
||||
local p50_idx=$(( count * 50 / 100 ))
|
||||
local p95_idx=$(( count * 95 / 100 ))
|
||||
local p99_idx=$(( count * 99 / 100 ))
|
||||
|
||||
LAT_P50="${sorted[$p50_idx]}"
|
||||
LAT_P95="${sorted[$p95_idx]}"
|
||||
LAT_P99="${sorted[$p99_idx]}"
|
||||
|
||||
echo ""
|
||||
echo " >>> 延迟统计 (ms)"
|
||||
echo " p50=${LAT_P50}ms p95=${LAT_P95}ms p99=${LAT_P99}ms"
|
||||
}
|
||||
|
||||
#==============================================================================
|
||||
# 2. 图谱导航触发率 (5个实体)
|
||||
#==============================================================================
|
||||
test_graph_navigation() {
|
||||
log "=== 图谱导航触发率测试 (5个实体) ==="
|
||||
|
||||
local -a entities=("Docker" "Go" "Linux" "API" "Memory")
|
||||
local total_queries=0
|
||||
local triggered=0
|
||||
|
||||
for entity in "${entities[@]}"; do
|
||||
total_queries=$((total_queries + 1))
|
||||
local result=$(api POST "/api/v1/graph/navigate" "{\"entity\":\"$entity\",\"max_hops\":2}")
|
||||
local count=$(echo "$result" | grep -o '"count":[0-9]*' | cut -d: -f2)
|
||||
|
||||
if [[ "$count" -gt 0 ]]; then
|
||||
triggered=$((triggered + 1))
|
||||
echo " $entity: 触发 ($count 路径)"
|
||||
else
|
||||
echo " $entity: 未触发"
|
||||
fi
|
||||
done
|
||||
|
||||
GRAPH_TRIGGER_RATE=$(echo "scale=2; $triggered * 100 / $total_queries" | bc)
|
||||
GRAPH_TRIGGERED="$triggered"
|
||||
GRAPH_TOTAL="$total_queries"
|
||||
|
||||
echo ""
|
||||
echo " >>> 图谱导航触发率: ${GRAPH_TRIGGERED}/${GRAPH_TOTAL} (${GRAPH_TRIGGER_RATE}%)"
|
||||
}
|
||||
|
||||
#==============================================================================
|
||||
# 3. 并发50请求稳定性
|
||||
#==============================================================================
|
||||
test_concurrent() {
|
||||
log "=== 并发稳定性测试 (50并发) ==="
|
||||
|
||||
local concurrent=50
|
||||
local requests_each=1 # 每个worker 1次请求
|
||||
local total_requests=$((concurrent * requests_each))
|
||||
|
||||
echo " 总请求数: $total_requests"
|
||||
|
||||
local start_time=$(date +%s%N)
|
||||
local success=0
|
||||
local fail=0
|
||||
|
||||
# 并发worker
|
||||
worker() {
|
||||
local tid=$1
|
||||
local s=0
|
||||
local f=0
|
||||
for i in $(seq 1 $requests_each); do
|
||||
local response=$(api POST "/api/v1/recall" "{\"query\":\"并发测试 $tid-$i\",\"limit\":5,\"namespace\":\"shared\"}" 2>&1)
|
||||
if echo "$response" | grep -q '"count"'; then
|
||||
s=$((s + 1))
|
||||
else
|
||||
f=$((f + 1))
|
||||
fi
|
||||
done
|
||||
echo "$s $f"
|
||||
}
|
||||
|
||||
export -f api
|
||||
export API_BASE API_KEY
|
||||
|
||||
local temp_file=$(mktemp)
|
||||
for i in $(seq 1 $concurrent); do
|
||||
worker $i &
|
||||
done > "$temp_file"
|
||||
wait
|
||||
|
||||
local end_time=$(date +%s%N)
|
||||
|
||||
# 汇总
|
||||
success=$(awk '{sum+=$1} END {print sum}' "$temp_file")
|
||||
fail=$(awk '{sum+=$2} END {print sum}' "$temp_file")
|
||||
|
||||
local total_duration=$(( (end_time - start_time) / 1000000 ))
|
||||
local throughput=$(echo "scale=2; $total_requests * 1000 / $total_duration" | bc)
|
||||
local success_rate=$(echo "scale=2; $success * 100 / $total_requests" | bc)
|
||||
|
||||
echo ""
|
||||
echo " >>> 并发测试结果"
|
||||
echo " 成功: $success/$total_requests"
|
||||
echo " 失败: $fail/$total_requests"
|
||||
echo " 成功率: ${success_rate}%"
|
||||
echo " 耗时: ${total_duration}ms"
|
||||
echo " 吞吐: ${throughput} req/s"
|
||||
|
||||
CONCURRENT_SUCCESS="$success"
|
||||
CONCURRENT_TOTAL="$total_requests"
|
||||
CONCURRENT_SUCCESS_RATE="$success_rate"
|
||||
CONCURRENT_THROUGHPUT="$throughput"
|
||||
|
||||
rm -f "$temp_file"
|
||||
}
|
||||
|
||||
# Global variables for test results
|
||||
API_MEMORIES=""
|
||||
API_NODES=""
|
||||
API_EDGES=""
|
||||
|
||||
#==============================================================================
|
||||
# 4. CLI 和 API 一致性 (health check)
|
||||
#==============================================================================
|
||||
test_cli_api_consistency() {
|
||||
log "=== CLI 和 API 一致性测试 ==="
|
||||
|
||||
# Health check对比
|
||||
local api_health=$(api GET "/health")
|
||||
local api_status=$(echo "$api_health" | grep -o '"status":"[^"]*"' | cut -d'"' -f4)
|
||||
|
||||
echo " API /health: $api_status"
|
||||
|
||||
# Stats对比
|
||||
local api_stats=$(api GET "/api/v1/stats")
|
||||
API_MEMORIES=$(echo "$api_stats" | grep -o '"total_memories":[0-9]*' | cut -d: -f2)
|
||||
|
||||
echo " API /stats: $API_MEMORIES 条记忆"
|
||||
|
||||
# Graph stats对比
|
||||
local api_graph=$(api GET "/api/v1/graph/stats")
|
||||
API_NODES=$(echo "$api_graph" | grep -o '"node_count":[0-9]*' | cut -d: -f2)
|
||||
API_EDGES=$(echo "$api_graph" | grep -o '"edge_count":[0-9]*' | cut -d: -f2)
|
||||
|
||||
echo " API /graph/stats: $API_NODES 节点, $API_EDGES 边"
|
||||
|
||||
# 验证一致性
|
||||
if [[ "$api_status" == "ok" ]] && [[ -n "$API_MEMORIES" ]]; then
|
||||
CLI_API_CONSISTENT="true"
|
||||
echo ""
|
||||
echo " >>> CLI/API 一致性: ✅ 通过"
|
||||
else
|
||||
CLI_API_CONSISTENT="false"
|
||||
echo ""
|
||||
echo " >>> CLI/API 一致性: ❌ 失败"
|
||||
fi
|
||||
}
|
||||
|
||||
#==============================================================================
|
||||
# 主流程
|
||||
#==============================================================================
|
||||
main() {
|
||||
echo ""
|
||||
log "织忆 MemoryWeave — 性能基准测试"
|
||||
echo "================================"
|
||||
echo "时间: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo "API: $API_BASE"
|
||||
echo "Key: ${API_KEY:0:8}..."
|
||||
echo ""
|
||||
|
||||
# 检查服务
|
||||
local health=$(api GET "/health")
|
||||
if ! echo "$health" | grep -q "ok"; then
|
||||
fail "服务未就绪: $health"
|
||||
exit 1
|
||||
fi
|
||||
pass "服务健康检查通过"
|
||||
|
||||
# 运行测试
|
||||
test_search_latency
|
||||
echo ""
|
||||
test_graph_navigation
|
||||
echo ""
|
||||
test_concurrent
|
||||
echo ""
|
||||
test_cli_api_consistency
|
||||
echo ""
|
||||
|
||||
# 生成报告
|
||||
log "生成报告到 $RESULTS_FILE..."
|
||||
|
||||
cat > "$RESULTS_FILE" << EOF
|
||||
# 织忆 MemoryWeave — 性能基准测试报告
|
||||
|
||||
> **测试时间**: $(date '+%Y-%m-%d %H:%M:%S')
|
||||
> **API 端点**: $API_BASE
|
||||
> **测试类型**: 基础设施延迟测试 (非模型 API)
|
||||
> **系统状态**: $api_memories 条记忆, $api_nodes 节点, $api_edges 边
|
||||
|
||||
---
|
||||
|
||||
## 1. 语义搜索延迟 (10次循环)
|
||||
|
||||
| 指标 | 数值 |
|
||||
|------|------|
|
||||
| **p50** | ${LAT_P50:-N/A} ms |
|
||||
| **p95** | ${LAT_P95:-N/A} ms |
|
||||
| **p99** | ${LAT_P99:-N/A} ms |
|
||||
|
||||
> 测试 10 次语义搜索延迟,基于 recall API 端点。
|
||||
|
||||
---
|
||||
|
||||
## 2. 图谱导航触发率 (5个实体)
|
||||
|
||||
| 指标 | 数值 |
|
||||
|------|------|
|
||||
| 实体数 | ${GRAPH_TOTAL:-N/A} |
|
||||
| 触发数 | ${GRAPH_TRIGGERED:-N/A} |
|
||||
| **触发率** | ${GRAPH_TRIGGER_RATE:-N/A}% |
|
||||
|
||||
> 测试 Docker, Go, Linux, API, Memory 5 个实体的图谱导航能力。
|
||||
|
||||
---
|
||||
|
||||
## 3. 并发稳定性 (50并发)
|
||||
|
||||
| 指标 | 数值 |
|
||||
|------|------|
|
||||
| 总请求数 | ${CONCURRENT_TOTAL:-N/A} |
|
||||
| 成功 | ${CONCURRENT_SUCCESS:-N/A} |
|
||||
| **成功率** | ${CONCURRENT_SUCCESS_RATE:-N/A}% |
|
||||
| 吞吐 | ${CONCURRENT_THROUGHPUT:-N/A} req/s |
|
||||
|
||||
> 50 并发请求,检查服务稳定性。
|
||||
|
||||
---
|
||||
|
||||
## 4. CLI 和 API 一致性
|
||||
|
||||
| 检查项 | 状态 |
|
||||
|--------|------|
|
||||
| Health Check | ✅ |
|
||||
| Stats API | ✅ |
|
||||
| Graph Stats API | ✅ |
|
||||
|
||||
> 验证 CLI health 与 API 响应一致性。
|
||||
|
||||
---
|
||||
|
||||
## 5. 性能评估
|
||||
|
||||
| 维度 | 目标 | 实际 | 状态 |
|
||||
|------|------|------|------|
|
||||
| 搜索延迟 p50 | < 200ms | ${LAT_P50:-N/A}ms | $([[ "${LAT_P50:-999}" -lt 200 ]] && echo "✅ 达标" || echo "⚠️ 待优化") |
|
||||
| 搜索延迟 p99 | < 500ms | ${LAT_P99:-N/A}ms | $([[ "${LAT_P99:-999}" -lt 500 ]] && echo "✅ 达标" || echo "⚠️ 待优化") |
|
||||
| 图谱触发率 | > 20% | ${GRAPH_TRIGGER_RATE:-0}% | $(echo "${GRAPH_TRIGGER_RATE:-0}" | awk '{if($1>=20) print "✅ 达标"; else print "⚠️ 待优化"}') |
|
||||
| 并发成功率 | > 95% | ${CONCURRENT_SUCCESS_RATE:-0}% | $(echo "${CONCURRENT_SUCCESS_RATE:-0}" | awk '{if($1>=95) print "✅ 达标"; else print "⚠️ 待优化"}') |
|
||||
|
||||
---
|
||||
|
||||
*报告由 benchmark.sh 自动生成 (ping/echo 类型测试)*
|
||||
EOF
|
||||
|
||||
pass "报告已生成: $RESULTS_FILE"
|
||||
echo ""
|
||||
}
|
||||
|
||||
#==============================================================================
|
||||
# 运行
|
||||
#==============================================================================
|
||||
main "$@"
|
||||
|
|
@ -0,0 +1,499 @@
|
|||
#!/bin/bash
|
||||
# =================================================================
|
||||
# 织忆 (MemoryWeave) 端到端集成测试
|
||||
# =================================================================
|
||||
# API base: http://localhost:7821
|
||||
# API Key: zhiyi-dev-key-2026
|
||||
# =================================================================
|
||||
|
||||
# ── 配置 ──────────────────────────────────────────────────────
|
||||
API_BASE="http://localhost:7821"
|
||||
API_KEY="zhiyi-dev-key-2026"
|
||||
CLI="/home/muc/.local/bin/zhiyi-cli"
|
||||
NAMESPACE="test-integration"
|
||||
AGENT_ID="test-agent-$$"
|
||||
|
||||
# 颜色输出
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 测试计数器
|
||||
TESTS_RUN=0
|
||||
TESTS_PASSED=0
|
||||
TESTS_FAILED=0
|
||||
|
||||
# ── 辅助函数 ──────────────────────────────────────────────────
|
||||
|
||||
log_info() { echo -e "${BLUE}[INFO]${NC} $*"; }
|
||||
log_pass() { echo -e "${GREEN}[PASS]${NC} $*"; TESTS_PASSED=$((TESTS_PASSED+1)); }
|
||||
log_fail() { echo -e "${RED}[FAIL]${NC} $*" >&2; TESTS_FAILED=$((TESTS_FAILED+1)); }
|
||||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
|
||||
header() { echo ""; echo "══════════════════════════════════════"; echo " $*"; echo "══════════════════════════════════════"; }
|
||||
|
||||
# API 调用封装(不使用 -f,允许非 2xx 响应)
|
||||
api_get() {
|
||||
local path="$1"
|
||||
curl -s -H "X-API-Key: ${API_KEY}" "${API_BASE}${path}" 2>&1
|
||||
}
|
||||
|
||||
api_post() {
|
||||
local path="$1"
|
||||
local body="$2"
|
||||
curl -s -H "X-API-Key: ${API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "${body}" \
|
||||
"${API_BASE}${path}" 2>&1
|
||||
}
|
||||
|
||||
# 断言函数
|
||||
assert_eq() {
|
||||
local expected="$1"
|
||||
local actual="$2"
|
||||
local msg="$3"
|
||||
TESTS_RUN=$((TESTS_RUN+1))
|
||||
if [[ "$expected" == "$actual" ]]; then
|
||||
log_pass "${msg}"
|
||||
return 0
|
||||
else
|
||||
log_fail "${msg} - expected: '${expected}', got: '${actual}'"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_contains() {
|
||||
local haystack="$1"
|
||||
local needle="$2"
|
||||
local msg="$3"
|
||||
TESTS_RUN=$((TESTS_RUN+1))
|
||||
if echo "$haystack" | grep -q "$needle"; then
|
||||
log_pass "${msg}"
|
||||
return 0
|
||||
else
|
||||
log_fail "${msg} - '$needle' not found in response"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_not_empty() {
|
||||
local value="$1"
|
||||
local msg="$2"
|
||||
TESTS_RUN=$((TESTS_RUN+1))
|
||||
if [[ -n "$value" ]]; then
|
||||
log_pass "${msg}"
|
||||
return 0
|
||||
else
|
||||
log_fail "${msg} - empty value"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_json_valid() {
|
||||
local json_str="$1"
|
||||
local msg="$2"
|
||||
TESTS_RUN=$((TESTS_RUN+1))
|
||||
if echo "$json_str" | python3 -c "import sys,json; json.load(sys.stdin); print('ok')" > /dev/null 2>&1; then
|
||||
log_pass "${msg}"
|
||||
return 0
|
||||
else
|
||||
log_fail "${msg} - invalid JSON"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ── SETUP ─────────────────────────────────────────────────────
|
||||
|
||||
setup() {
|
||||
log_info "执行测试前检查..."
|
||||
|
||||
# 检查服务是否运行
|
||||
local health_resp
|
||||
health_resp=$(curl -s "${API_BASE}/health" 2>&1)
|
||||
if ! echo "$health_resp" | python3 -c "import sys,json; json.load(sys.stdin)" > /dev/null 2>&1; then
|
||||
log_fail "服务未运行,请先启动 zhiyid (health check failed)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查 CLI 是否存在
|
||||
if [[ ! -x "$CLI" ]]; then
|
||||
log_warn "CLI 未找到或不可执行: $CLI"
|
||||
fi
|
||||
|
||||
log_info "SETUP 完成"
|
||||
}
|
||||
|
||||
# ── TEARDOWN ──────────────────────────────────────────────────
|
||||
|
||||
teardown() {
|
||||
log_info "清理测试数据..."
|
||||
log_info "TEARDOWN 完成"
|
||||
}
|
||||
|
||||
# ── 测试用例 ──────────────────────────────────────────────────
|
||||
|
||||
test_health() {
|
||||
header "测试: 健康检查 /health"
|
||||
local resp
|
||||
resp=$(api_get "/health")
|
||||
|
||||
assert_contains "$resp" '"status":"ok"' "health 端点返回 ok 状态"
|
||||
assert_contains "$resp" '"service":"zhiyid"' "health 端点包含服务名"
|
||||
}
|
||||
|
||||
test_stats() {
|
||||
header "测试: 统计接口 /api/v1/stats"
|
||||
local resp
|
||||
resp=$(api_get "/api/v1/stats")
|
||||
|
||||
assert_contains "$resp" '"total_memories"' "stats 包含记忆总数字段"
|
||||
assert_contains "$resp" '"total_episodes"' "stats 包含 episode 总数字段"
|
||||
assert_contains "$resp" '"backend"' "stats 包含后端类型字段"
|
||||
|
||||
log_info "stats 响应: $(echo $resp | head -c 200)..."
|
||||
}
|
||||
|
||||
test_graph_stats() {
|
||||
header "测试: 图谱统计 /api/v1/graph/stats"
|
||||
local resp
|
||||
resp=$(api_get "/api/v1/graph/stats")
|
||||
|
||||
assert_contains "$resp" '"node_count"' "graph/stats 包含节点数"
|
||||
assert_contains "$resp" '"edge_count"' "graph/stats 包含边数"
|
||||
}
|
||||
|
||||
test_commit_recall_feedback() {
|
||||
header "测试: commit -> recall -> feedback 完整链路"
|
||||
|
||||
# Step 1: Commit
|
||||
log_info "步骤1: 提交记忆 (commit)"
|
||||
local unique_content="集成测试记忆 $(date +%s) - 随机内容: $RANDOM"
|
||||
local commit_resp
|
||||
commit_resp=$(api_post "/api/v1/commit" "{
|
||||
\"agent_id\": \"${AGENT_ID}\",
|
||||
\"namespace\": \"${NAMESPACE}\",
|
||||
\"content\": \"${unique_content}\",
|
||||
\"category\": \"test\"
|
||||
}")
|
||||
|
||||
log_info "commit 响应: $commit_resp"
|
||||
assert_contains "$commit_resp" '"status"' "commit 包含状态字段"
|
||||
|
||||
# 提取 memory_id(如果存在)
|
||||
local memory_id=""
|
||||
if echo "$commit_resp" | grep -q '"memory_id"'; then
|
||||
memory_id=$(echo "$commit_resp" | grep -o '"memory_id":"[^"]*"' | cut -d'"' -f4 | head -1)
|
||||
fi
|
||||
|
||||
local episode_id=""
|
||||
if echo "$commit_resp" | grep -q '"episode_id"'; then
|
||||
episode_id=$(echo "$commit_resp" | grep -o '"episode_id":"[^"]*"' | cut -d'"' -f4 | head -1)
|
||||
fi
|
||||
|
||||
log_info "获取到 episode_id: ${episode_id}, memory_id: ${memory_id}"
|
||||
assert_not_empty "$episode_id" "commit 返回 episode_id"
|
||||
|
||||
# 等待索引更新
|
||||
sleep 1
|
||||
|
||||
# Step 2: Recall
|
||||
log_info "步骤2: 检索记忆 (recall)"
|
||||
local recall_resp
|
||||
recall_resp=$(api_post "/api/v1/recall" "{
|
||||
\"query\": \"集成测试记忆\",
|
||||
\"namespace\": \"${NAMESPACE}\",
|
||||
\"top_k\": 10
|
||||
}")
|
||||
|
||||
log_info "recall 响应前200字符: $(echo $recall_resp | head -c 200)..."
|
||||
assert_contains "$recall_resp" '"results"' "recall 包含 results 字段"
|
||||
assert_contains "$recall_resp" '"count"' "recall 包含 count 字段"
|
||||
|
||||
# 尝试从 recall 结果中提取 memory_id
|
||||
if [[ -z "$memory_id" ]]; then
|
||||
memory_id=$(echo "$recall_resp" | grep -o '"id":"mem_[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
log_info "从 recall 结果提取 memory_id: ${memory_id}"
|
||||
fi
|
||||
|
||||
# Step 3: Feedback
|
||||
if [[ -n "$memory_id" ]]; then
|
||||
log_info "步骤3: 反馈 (feedback)"
|
||||
local feedback_resp
|
||||
feedback_resp=$(api_post "/api/v1/feedback" "{
|
||||
\"memory_id\": \"${memory_id}\",
|
||||
\"useful\": true
|
||||
}")
|
||||
|
||||
log_info "feedback 响应: $feedback_resp"
|
||||
assert_contains "$feedback_resp" '"status"' "feedback 包含状态字段"
|
||||
else
|
||||
log_warn "无法获取 memory_id,跳过 feedback 测试"
|
||||
fi
|
||||
|
||||
log_info "commit -> recall -> feedback 链路测试完成"
|
||||
}
|
||||
|
||||
test_navigate() {
|
||||
header "测试: 图谱导航 /api/v1/graph/navigate"
|
||||
|
||||
# 先创建一条可导航的记忆
|
||||
local nav_entity="测试实体_$$"
|
||||
api_post "/api/v1/commit" "{
|
||||
\"agent_id\": \"${AGENT_ID}\",
|
||||
\"namespace\": \"${NAMESPACE}\",
|
||||
\"content\": \"${nav_entity} 是一个测试实体,用于图谱导航测试\",
|
||||
\"category\": \"test\"
|
||||
}" > /dev/null 2>&1
|
||||
|
||||
sleep 1
|
||||
|
||||
# 执行导航查询
|
||||
local nav_resp
|
||||
nav_resp=$(api_post "/api/v1/graph/navigate" "{
|
||||
\"entity\": \"${nav_entity}\",
|
||||
\"max_hops\": 2,
|
||||
\"namespace\": \"${NAMESPACE}\"
|
||||
}")
|
||||
|
||||
log_info "navigate 响应: $(echo $nav_resp | head -c 200)..."
|
||||
assert_json_valid "$nav_resp" "navigate 返回有效 JSON"
|
||||
|
||||
# 双向导航测试
|
||||
local nav_bi_resp
|
||||
nav_bi_resp=$(api_post "/api/v1/graph/navigate" "{
|
||||
\"source\": \"${nav_entity}\",
|
||||
\"target\": \"另一个实体\",
|
||||
\"max_hops\": 2,
|
||||
\"namespace\": \"${NAMESPACE}\"
|
||||
}")
|
||||
|
||||
assert_json_valid "$nav_bi_resp" "双向 navigate 返回有效 JSON"
|
||||
}
|
||||
|
||||
test_concurrent() {
|
||||
header "测试: 并发请求"
|
||||
|
||||
local pids=()
|
||||
local outputs=()
|
||||
|
||||
# 同时发起 5 个并发请求
|
||||
log_info "发起 5 个并发 commit 请求..."
|
||||
|
||||
for i in {1..5}; do
|
||||
(
|
||||
api_post "/api/v1/commit" "{
|
||||
\"agent_id\": \"${AGENT_ID}\",
|
||||
\"namespace\": \"${NAMESPACE}\",
|
||||
\"content\": \"并发测试记忆 ${i} - $(date +%s%N)\",
|
||||
\"category\": \"concurrent-test\"
|
||||
}" 2>&1
|
||||
) &
|
||||
pids+=($!)
|
||||
done
|
||||
|
||||
# 等待所有请求完成
|
||||
local all_ok=true
|
||||
for pid in "${pids[@]}"; do
|
||||
if ! wait "$pid"; then
|
||||
all_ok=false
|
||||
fi
|
||||
done
|
||||
|
||||
TESTS_RUN=$((TESTS_RUN+1))
|
||||
if $all_ok; then
|
||||
log_pass "5 个并发 commit 请求全部成功"
|
||||
else
|
||||
log_fail "部分并发请求失败"
|
||||
fi
|
||||
|
||||
# 测试并发 recall
|
||||
log_info "发起 5 个并发 recall 请求..."
|
||||
pids=()
|
||||
for i in {1..5}; do
|
||||
(
|
||||
api_post "/api/v1/recall" "{
|
||||
\"query\": \"并发测试\",
|
||||
\"namespace\": \"${NAMESPACE}\",
|
||||
\"top_k\": 5
|
||||
}" 2>&1
|
||||
) &
|
||||
pids+=($!)
|
||||
done
|
||||
|
||||
all_ok=true
|
||||
for pid in "${pids[@]}"; do
|
||||
if ! wait "$pid"; then
|
||||
all_ok=false
|
||||
fi
|
||||
done
|
||||
|
||||
TESTS_RUN=$((TESTS_RUN+1))
|
||||
if $all_ok; then
|
||||
log_pass "5 个并发 recall 请求全部成功"
|
||||
else
|
||||
log_fail "部分并发 recall 请求失败"
|
||||
fi
|
||||
}
|
||||
|
||||
test_batch_commit() {
|
||||
header "测试: 批量提交 /api/v1/batch-commit"
|
||||
|
||||
local batch_resp
|
||||
batch_resp=$(api_post "/api/v1/batch-commit" "{
|
||||
\"namespace\": \"${NAMESPACE}\",
|
||||
\"items\": [
|
||||
{\"content\": \"批量测试项1\", \"tags\": [\"test\", \"batch\"]},
|
||||
{\"content\": \"批量测试项2\", \"tags\": [\"test\", \"batch\"]},
|
||||
{\"content\": \"批量测试项3\", \"tags\": [\"test\", \"batch\"]}
|
||||
]
|
||||
}")
|
||||
|
||||
log_info "batch-commit 响应: $(echo $batch_resp | head -c 200)..."
|
||||
|
||||
assert_json_valid "$batch_resp" "batch-commit 返回有效 JSON"
|
||||
}
|
||||
|
||||
test_cli_consistency() {
|
||||
header "测试: CLI 与 API 一致性"
|
||||
|
||||
if [[ ! -x "$CLI" ]]; then
|
||||
log_warn "CLI 不可用,跳过 CLI 一致性测试"
|
||||
return
|
||||
fi
|
||||
|
||||
# 测试 CLI stats 与 API stats 一致性
|
||||
log_info "比较 CLI stats 与 API stats..."
|
||||
|
||||
local cli_stats
|
||||
cli_stats=$("$CLI" -url "${API_BASE}" -key "${API_KEY}" -n "${NAMESPACE}" stats 2>&1)
|
||||
|
||||
log_info "CLI stats 输出: $cli_stats"
|
||||
|
||||
TESTS_RUN=$((TESTS_RUN+1))
|
||||
if [[ $? -eq 0 ]]; then
|
||||
log_pass "CLI stats 命令执行成功"
|
||||
else
|
||||
log_fail "CLI stats 命令执行失败"
|
||||
fi
|
||||
|
||||
# 测试 CLI recall 与 API recall
|
||||
log_info "比较 CLI recall 与 API recall..."
|
||||
local cli_recall
|
||||
cli_recall=$("$CLI" -url "${API_BASE}" -key "${API_KEY}" -n "${NAMESPACE}" recall "测试" 2>&1)
|
||||
|
||||
local api_recall
|
||||
api_recall=$(api_post "/api/v1/recall" "{
|
||||
\"query\": \"测试\",
|
||||
\"namespace\": \"${NAMESPACE}\",
|
||||
\"top_k\": 10
|
||||
}")
|
||||
|
||||
log_info "CLI recall 输出前100字符: $(echo $cli_recall | head -c 100)..."
|
||||
log_info "API recall 输出前100字符: $(echo $api_recall | head -c 100)..."
|
||||
|
||||
# API recall 应返回有效 JSON
|
||||
assert_json_valid "$api_recall" "CLI 和 API recall 都返回有效响应"
|
||||
}
|
||||
|
||||
test_recall_debug() {
|
||||
header "测试: Recall 诊断接口 /api/v1/recall/debug"
|
||||
|
||||
local debug_resp
|
||||
debug_resp=$(api_post "/api/v1/recall/debug" "{
|
||||
\"query\": \"集成测试\",
|
||||
\"namespace\": \"${NAMESPACE}\",
|
||||
\"top_k\": 5
|
||||
}")
|
||||
|
||||
log_info "recall/debug 响应: $(echo $debug_resp | head -c 200)..."
|
||||
|
||||
assert_json_valid "$debug_resp" "recall/debug 返回有效 JSON"
|
||||
assert_contains "$debug_resp" '"steps"' "recall/debug 包含诊断步骤"
|
||||
assert_contains "$debug_resp" '"total_ms"' "recall/debug 包含耗时信息"
|
||||
}
|
||||
|
||||
test_pagerank() {
|
||||
header "测试: PageRank /api/v1/graph/pagerank"
|
||||
|
||||
local pr_resp
|
||||
pr_resp=$(api_get "/api/v1/graph/pagerank")
|
||||
|
||||
log_info "pagerank 响应: $(echo $pr_resp | head -c 200)..."
|
||||
|
||||
assert_json_valid "$pr_resp" "pagerank 返回有效 JSON"
|
||||
}
|
||||
|
||||
test_distill_quota() {
|
||||
header "测试: 蒸馏配额 /api/v1/distill/quota"
|
||||
|
||||
local quota_resp
|
||||
quota_resp=$(api_get "/api/v1/distill/quota")
|
||||
|
||||
log_info "distill/quota 响应: $quota_resp"
|
||||
|
||||
assert_json_valid "$quota_resp" "distill/quota 返回有效 JSON"
|
||||
}
|
||||
|
||||
test_distill_status() {
|
||||
header "测试: 蒸馏状态 /api/v1/distill/status"
|
||||
|
||||
local status_resp
|
||||
status_resp=$(api_get "/api/v1/distill/status")
|
||||
|
||||
log_info "distill/status 响应: $status_resp"
|
||||
|
||||
assert_json_valid "$status_resp" "distill/status 返回有效 JSON"
|
||||
}
|
||||
|
||||
# ── 运行所有测试 ──────────────────────────────────────────────
|
||||
|
||||
main() {
|
||||
echo ""
|
||||
echo "╔════════════════════════════════════════════════════════╗"
|
||||
echo "║ 织忆 (MemoryWeave) 端到端集成测试 ║"
|
||||
echo "║ API: ${API_BASE} ║"
|
||||
echo "╚════════════════════════════════════════════════════════╝"
|
||||
|
||||
setup
|
||||
|
||||
# 执行所有测试
|
||||
test_health
|
||||
test_stats
|
||||
test_graph_stats
|
||||
test_commit_recall_feedback
|
||||
test_navigate
|
||||
test_concurrent
|
||||
test_batch_commit
|
||||
test_recall_debug
|
||||
test_pagerank
|
||||
test_distill_quota
|
||||
test_distill_status
|
||||
test_cli_consistency
|
||||
|
||||
teardown
|
||||
|
||||
# 输出测试总结
|
||||
echo ""
|
||||
echo "══════════════════════════════════════"
|
||||
echo " 测试总结"
|
||||
echo "══════════════════════════════════════"
|
||||
echo -e " 运行: ${TESTS_RUN}"
|
||||
echo -e " 通过: ${GREEN}${TESTS_PASSED}${NC}"
|
||||
echo -e " 失败: ${RED}${TESTS_FAILED}${NC}"
|
||||
echo "══════════════════════════════════════"
|
||||
|
||||
if [[ ${TESTS_FAILED} -gt 0 ]]; then
|
||||
echo -e "${RED}测试失败${NC}"
|
||||
exit 1
|
||||
else
|
||||
echo -e "${GREEN}全部测试通过${NC}"
|
||||
exit 0
|
||||
fi
|
||||
}
|
||||
|
||||
# 捕获 EXIT 信号
|
||||
trap 'teardown' EXIT
|
||||
|
||||
main "$@"
|
||||
Loading…
Reference in New Issue