nonebot-plugin-group-chat-memory 0.1.0__tar.gz → 0.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {nonebot_plugin_group_chat_memory-0.1.0 → nonebot_plugin_group_chat_memory-0.1.2}/PKG-INFO +6 -2
- {nonebot_plugin_group_chat_memory-0.1.0 → nonebot_plugin_group_chat_memory-0.1.2}/README.md +5 -1
- {nonebot_plugin_group_chat_memory-0.1.0 → nonebot_plugin_group_chat_memory-0.1.2}/nonebot_plugin_group_chat_memory/api.py +38 -7
- {nonebot_plugin_group_chat_memory-0.1.0 → nonebot_plugin_group_chat_memory-0.1.2}/nonebot_plugin_group_chat_memory/handlers.py +6 -3
- {nonebot_plugin_group_chat_memory-0.1.0 → nonebot_plugin_group_chat_memory-0.1.2}/nonebot_plugin_group_chat_memory/store.py +125 -18
- {nonebot_plugin_group_chat_memory-0.1.0 → nonebot_plugin_group_chat_memory-0.1.2}/pyproject.toml +1 -1
- {nonebot_plugin_group_chat_memory-0.1.0 → nonebot_plugin_group_chat_memory-0.1.2}/LICENSE +0 -0
- {nonebot_plugin_group_chat_memory-0.1.0 → nonebot_plugin_group_chat_memory-0.1.2}/nonebot_plugin_group_chat_memory/__init__.py +0 -0
- {nonebot_plugin_group_chat_memory-0.1.0 → nonebot_plugin_group_chat_memory-0.1.2}/nonebot_plugin_group_chat_memory/config.py +0 -0
- {nonebot_plugin_group_chat_memory-0.1.0 → nonebot_plugin_group_chat_memory-0.1.2}/nonebot_plugin_group_chat_memory/context.py +0 -0
- {nonebot_plugin_group_chat_memory-0.1.0 → nonebot_plugin_group_chat_memory-0.1.2}/nonebot_plugin_group_chat_memory/memory.py +0 -0
- {nonebot_plugin_group_chat_memory-0.1.0 → nonebot_plugin_group_chat_memory-0.1.2}/nonebot_plugin_group_chat_memory/models.py +0 -0
- {nonebot_plugin_group_chat_memory-0.1.0 → nonebot_plugin_group_chat_memory-0.1.2}/nonebot_plugin_group_chat_memory/replies.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nonebot-plugin-group-chat-memory
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: 为 NoneBot2 群聊提供共享长期记忆、点名对话和按群概率回复
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -26,7 +26,7 @@ Description-Content-Type: text/markdown
|
|
|
26
26
|
|
|
27
27
|
# nonebot-plugin-group-chat-memory
|
|
28
28
|
|
|
29
|
-
为 NoneBot2 OneBot v11 群聊提供按群隔离的长期向量记忆、@/回复对话和按群概率随机回复。插件使用 SQLite
|
|
29
|
+
为 NoneBot2 OneBot v11 群聊提供按群隔离的长期向量记忆、@/回复对话和按群概率随机回复。插件使用 SQLite 保存原文、分块、向量和群配置,聊天与 Embedding 均请求可配置的 OpenAI 兼容 HTTP API。
|
|
30
30
|
|
|
31
31
|
## 环境与安装
|
|
32
32
|
|
|
@@ -34,6 +34,8 @@ Description-Content-Type: text/markdown
|
|
|
34
34
|
- NoneBot2 2.2+
|
|
35
35
|
- OneBot v11 适配器与对应的 OneBot 协议端
|
|
36
36
|
|
|
37
|
+
优先使用 sqlite-vec 原生向量索引。如果当前 Python 的 `sqlite3` 不支持加载扩展(例如部分 python.org/Homebrew Python 3.13/3.14 构建),插件会自动改用 SQLite BLOB 保存向量并在 Python 中计算余弦相似度;启动日志会给出提示,功能保持可用。旧 sqlite-vec 数据库中缺少 BLOB 向量的已就绪片段会自动重新排队向量化。
|
|
38
|
+
|
|
37
39
|
选择一种安装方式:
|
|
38
40
|
|
|
39
41
|
```bash
|
|
@@ -83,6 +85,8 @@ COMMAND_START=["/"]
|
|
|
83
85
|
|
|
84
86
|
聊天地址填到 API 根路径(通常包含 `/v1`),插件分别请求其下的 `/chat/completions` 和 `/embeddings`。默认聊天模型标识是 `deepseek-v4.1-flash`,但**模型标识必须以实际服务商支持的值为准**。这四项保存到 SQLite,不再读取同名环境变量,更新后立即生效且重启后保留。使用 `/模型配置` 可查询脱敏状态。
|
|
85
87
|
|
|
88
|
+
使用 OpenCode Go 直连 API 时,地址是 `https://opencode.ai/inference/go/openai/v1`,模型是 `deepseek-v4.1-flash`。插件会发送自己的 User-Agent,并对 OpenCode 请求附带按群稳定的 `x-opencode-session`,避免被网关当作通用 SDK 流量。
|
|
89
|
+
|
|
86
90
|
## 其余环境配置字段
|
|
87
91
|
|
|
88
92
|
四个模型运行时字段不再通过 env 设置。其余以下字段可写在 NoneBot `.env` 中;集合类值使用 JSON 格式。
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# nonebot-plugin-group-chat-memory
|
|
2
2
|
|
|
3
|
-
为 NoneBot2 OneBot v11 群聊提供按群隔离的长期向量记忆、@/回复对话和按群概率随机回复。插件使用 SQLite
|
|
3
|
+
为 NoneBot2 OneBot v11 群聊提供按群隔离的长期向量记忆、@/回复对话和按群概率随机回复。插件使用 SQLite 保存原文、分块、向量和群配置,聊天与 Embedding 均请求可配置的 OpenAI 兼容 HTTP API。
|
|
4
4
|
|
|
5
5
|
## 环境与安装
|
|
6
6
|
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
- NoneBot2 2.2+
|
|
9
9
|
- OneBot v11 适配器与对应的 OneBot 协议端
|
|
10
10
|
|
|
11
|
+
优先使用 sqlite-vec 原生向量索引。如果当前 Python 的 `sqlite3` 不支持加载扩展(例如部分 python.org/Homebrew Python 3.13/3.14 构建),插件会自动改用 SQLite BLOB 保存向量并在 Python 中计算余弦相似度;启动日志会给出提示,功能保持可用。旧 sqlite-vec 数据库中缺少 BLOB 向量的已就绪片段会自动重新排队向量化。
|
|
12
|
+
|
|
11
13
|
选择一种安装方式:
|
|
12
14
|
|
|
13
15
|
```bash
|
|
@@ -57,6 +59,8 @@ COMMAND_START=["/"]
|
|
|
57
59
|
|
|
58
60
|
聊天地址填到 API 根路径(通常包含 `/v1`),插件分别请求其下的 `/chat/completions` 和 `/embeddings`。默认聊天模型标识是 `deepseek-v4.1-flash`,但**模型标识必须以实际服务商支持的值为准**。这四项保存到 SQLite,不再读取同名环境变量,更新后立即生效且重启后保留。使用 `/模型配置` 可查询脱敏状态。
|
|
59
61
|
|
|
62
|
+
使用 OpenCode Go 直连 API 时,地址是 `https://opencode.ai/inference/go/openai/v1`,模型是 `deepseek-v4.1-flash`。插件会发送自己的 User-Agent,并对 OpenCode 请求附带按群稳定的 `x-opencode-session`,避免被网关当作通用 SDK 流量。
|
|
63
|
+
|
|
60
64
|
## 其余环境配置字段
|
|
61
65
|
|
|
62
66
|
四个模型运行时字段不再通过 env 设置。其余以下字段可写在 NoneBot `.env` 中;集合类值使用 JSON 格式。
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import math
|
|
5
5
|
from typing import Any
|
|
6
|
+
from urllib.parse import urlparse
|
|
6
7
|
|
|
7
8
|
import httpx
|
|
8
9
|
|
|
@@ -11,6 +12,7 @@ from .context import build_chat_messages
|
|
|
11
12
|
from .models import ChatContext
|
|
12
13
|
|
|
13
14
|
CHAT_RETRY_DELAYS: tuple[float, ...] = (0.5, 1.0)
|
|
15
|
+
CLIENT_USER_AGENT = "nonebot-plugin-group-chat-memory/0.1.2"
|
|
14
16
|
|
|
15
17
|
|
|
16
18
|
class ModelAPIError(RuntimeError):
|
|
@@ -24,9 +26,16 @@ class EmbeddingAPIError(ModelAPIError):
|
|
|
24
26
|
class ChatAPIError(ModelAPIError):
|
|
25
27
|
"""聊天请求失败或响应不可用。"""
|
|
26
28
|
|
|
27
|
-
def __init__(
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
message: str,
|
|
32
|
+
*,
|
|
33
|
+
retryable: bool = False,
|
|
34
|
+
status_code: int | None = None,
|
|
35
|
+
) -> None:
|
|
28
36
|
super().__init__(message)
|
|
29
37
|
self.retryable = retryable
|
|
38
|
+
self.status_code = status_code
|
|
30
39
|
|
|
31
40
|
|
|
32
41
|
def _make_client(timeout: float) -> httpx.AsyncClient:
|
|
@@ -39,7 +48,19 @@ def _endpoint(base_url: str, path: str) -> str:
|
|
|
39
48
|
|
|
40
49
|
|
|
41
50
|
def _auth_headers(api_key: str) -> dict[str, str]:
|
|
42
|
-
return {
|
|
51
|
+
return {
|
|
52
|
+
"Authorization": f"Bearer {api_key.strip()}",
|
|
53
|
+
"User-Agent": CLIENT_USER_AGENT,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _chat_headers(
|
|
58
|
+
base_url: str, api_key: str, session_id: str | None
|
|
59
|
+
) -> dict[str, str]:
|
|
60
|
+
headers = _auth_headers(api_key)
|
|
61
|
+
if session_id and urlparse(base_url).hostname == "opencode.ai":
|
|
62
|
+
headers["x-opencode-session"] = session_id
|
|
63
|
+
return headers
|
|
43
64
|
|
|
44
65
|
|
|
45
66
|
class EmbeddingClient:
|
|
@@ -105,7 +126,9 @@ class ChatClient:
|
|
|
105
126
|
self._config = config
|
|
106
127
|
self._retry_delays = tuple(retry_delays)
|
|
107
128
|
|
|
108
|
-
async def complete(
|
|
129
|
+
async def complete(
|
|
130
|
+
self, context: ChatContext, current_text: str, *, session_id: str | None = None
|
|
131
|
+
) -> str:
|
|
109
132
|
"""按上下文请求一条回复;瞬时失败按配置次数重试,最终失败抛出 ChatAPIError。"""
|
|
110
133
|
self._config.require_chat_config()
|
|
111
134
|
if not current_text.strip():
|
|
@@ -118,7 +141,7 @@ class ChatClient:
|
|
|
118
141
|
attempts = len(self._retry_delays) + 1
|
|
119
142
|
for attempt in range(attempts):
|
|
120
143
|
try:
|
|
121
|
-
response = await self._post(payload)
|
|
144
|
+
response = await self._post(payload, session_id)
|
|
122
145
|
except ChatAPIError as exc:
|
|
123
146
|
if not exc.retryable or attempt + 1 >= attempts:
|
|
124
147
|
raise
|
|
@@ -127,9 +150,16 @@ class ChatClient:
|
|
|
127
150
|
return self._parse(response)
|
|
128
151
|
raise ChatAPIError("聊天请求重试次数已用尽。")
|
|
129
152
|
|
|
130
|
-
async def _post(
|
|
131
|
-
|
|
132
|
-
|
|
153
|
+
async def _post(
|
|
154
|
+
self, payload: dict[str, Any], session_id: str | None
|
|
155
|
+
) -> httpx.Response:
|
|
156
|
+
base_url = self._config.group_chat_memory_chat_base_url
|
|
157
|
+
url = _endpoint(base_url, "chat/completions")
|
|
158
|
+
headers = _chat_headers(
|
|
159
|
+
base_url,
|
|
160
|
+
self._config.group_chat_memory_chat_api_key.get_secret_value(),
|
|
161
|
+
session_id,
|
|
162
|
+
)
|
|
133
163
|
timeout = self._config.group_chat_memory_chat_timeout
|
|
134
164
|
try:
|
|
135
165
|
async with _make_client(timeout) as client:
|
|
@@ -142,6 +172,7 @@ class ChatClient:
|
|
|
142
172
|
raise ChatAPIError(
|
|
143
173
|
f"聊天服务返回 HTTP {response.status_code}。",
|
|
144
174
|
retryable=response.status_code == 429 or response.status_code >= 500,
|
|
175
|
+
status_code=response.status_code,
|
|
145
176
|
)
|
|
146
177
|
return response
|
|
147
178
|
|
|
@@ -21,7 +21,7 @@ from nonebot.adapters.onebot.v11 import (
|
|
|
21
21
|
from nonebot.params import CommandArg
|
|
22
22
|
from nonebot.permission import SUPERUSER
|
|
23
23
|
|
|
24
|
-
from .api import ChatClient, EmbeddingClient
|
|
24
|
+
from .api import ChatAPIError, ChatClient, EmbeddingClient
|
|
25
25
|
from .config import get_settings
|
|
26
26
|
from .context import ContextBuilder
|
|
27
27
|
from .memory import MemoryWriter
|
|
@@ -79,9 +79,12 @@ async def reply(group_id: int, message: MemoryMessage) -> None:
|
|
|
79
79
|
bot = _bots[group_id]
|
|
80
80
|
try:
|
|
81
81
|
context = await context_builder.build(group_id, message.text)
|
|
82
|
-
response = await chat_client.complete(
|
|
82
|
+
response = await chat_client.complete(
|
|
83
|
+
context, message.text, session_id=f"group:{group_id}"
|
|
84
|
+
)
|
|
83
85
|
except Exception as exc:
|
|
84
|
-
|
|
86
|
+
status = f" HTTP {exc.status_code}" if isinstance(exc, ChatAPIError) else ""
|
|
87
|
+
logger.error("群 %s 的聊天回复失败:%s%s", group_id, type(exc).__name__, status)
|
|
85
88
|
await bot.send_group_msg(group_id=group_id, message=GENERIC_REPLY_ERROR)
|
|
86
89
|
return
|
|
87
90
|
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import logging
|
|
4
5
|
import math
|
|
6
|
+
import sqlite3
|
|
7
|
+
import struct
|
|
5
8
|
from collections.abc import AsyncIterator, Sequence
|
|
6
|
-
from contextlib import asynccontextmanager
|
|
9
|
+
from contextlib import asynccontextmanager, suppress
|
|
7
10
|
from datetime import datetime, timezone
|
|
8
11
|
|
|
9
12
|
import aiosqlite
|
|
@@ -12,8 +15,14 @@ import sqlite_vec
|
|
|
12
15
|
from .config import RUNTIME_CONFIG_DEFAULTS, Config
|
|
13
16
|
from .models import Chunk, ChunkStatus, Message
|
|
14
17
|
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
15
20
|
DIMENSIONS_META_KEY = "embedding_dimensions"
|
|
16
21
|
|
|
22
|
+
|
|
23
|
+
def _supports_load_extension() -> bool:
|
|
24
|
+
return hasattr(sqlite3.Connection, "enable_load_extension")
|
|
25
|
+
|
|
17
26
|
_SCHEMA_STATEMENTS = (
|
|
18
27
|
"""
|
|
19
28
|
CREATE TABLE IF NOT EXISTS meta (
|
|
@@ -66,6 +75,17 @@ _SCHEMA_STATEMENTS = (
|
|
|
66
75
|
CREATE INDEX IF NOT EXISTS idx_messages_unchunked
|
|
67
76
|
ON messages(group_id, id) WHERE chunk_id IS NULL
|
|
68
77
|
""",
|
|
78
|
+
"""
|
|
79
|
+
CREATE TABLE IF NOT EXISTS memory_vector_blobs (
|
|
80
|
+
chunk_id INTEGER PRIMARY KEY REFERENCES chunks(id) ON DELETE CASCADE,
|
|
81
|
+
group_id INTEGER NOT NULL,
|
|
82
|
+
embedding BLOB NOT NULL
|
|
83
|
+
)
|
|
84
|
+
""",
|
|
85
|
+
"""
|
|
86
|
+
CREATE INDEX IF NOT EXISTS idx_vector_blobs_group
|
|
87
|
+
ON memory_vector_blobs(group_id)
|
|
88
|
+
""",
|
|
69
89
|
)
|
|
70
90
|
|
|
71
91
|
_VECTOR_TABLE_STATEMENT = """
|
|
@@ -85,10 +105,11 @@ class MemoryStore:
|
|
|
85
105
|
self._dimensions = config.group_chat_memory_embedding_dimensions
|
|
86
106
|
self._default_probability = config.group_chat_memory_default_probability
|
|
87
107
|
self._db: aiosqlite.Connection | None = None
|
|
108
|
+
self._use_sqlite_vec = False
|
|
88
109
|
self._lock = asyncio.Lock()
|
|
89
110
|
|
|
90
111
|
async def open(self) -> None:
|
|
91
|
-
"""
|
|
112
|
+
"""打开数据库;优先加载 sqlite-vec,不支持时使用 BLOB 向量回退。"""
|
|
92
113
|
async with self._lock:
|
|
93
114
|
if self._db is not None:
|
|
94
115
|
return
|
|
@@ -96,23 +117,57 @@ class MemoryStore:
|
|
|
96
117
|
path = self._config.database_path
|
|
97
118
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
98
119
|
db = await aiosqlite.connect(path, isolation_level=None)
|
|
120
|
+
use_sqlite_vec = _supports_load_extension()
|
|
99
121
|
try:
|
|
100
122
|
db.row_factory = aiosqlite.Row
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
123
|
+
if use_sqlite_vec:
|
|
124
|
+
try:
|
|
125
|
+
await db.enable_load_extension(True)
|
|
126
|
+
await db.load_extension(sqlite_vec.loadable_path())
|
|
127
|
+
except Exception as exc:
|
|
128
|
+
logger.warning(
|
|
129
|
+
"sqlite-vec 加载失败,改用 SQLite BLOB 向量检索:%s",
|
|
130
|
+
type(exc).__name__,
|
|
131
|
+
)
|
|
132
|
+
use_sqlite_vec = False
|
|
133
|
+
finally:
|
|
134
|
+
if hasattr(db, "enable_load_extension"):
|
|
135
|
+
with suppress(Exception):
|
|
136
|
+
await db.enable_load_extension(False)
|
|
137
|
+
else:
|
|
138
|
+
logger.warning(
|
|
139
|
+
"当前 Python 的 sqlite3 不支持加载扩展,改用 SQLite BLOB 向量检索。"
|
|
140
|
+
)
|
|
104
141
|
await db.execute("PRAGMA journal_mode = WAL")
|
|
105
142
|
await db.execute("PRAGMA foreign_keys = ON")
|
|
106
143
|
await db.execute("PRAGMA busy_timeout = 5000")
|
|
107
144
|
await db.execute("PRAGMA synchronous = NORMAL")
|
|
108
145
|
self._db = db
|
|
146
|
+
self._use_sqlite_vec = use_sqlite_vec
|
|
109
147
|
async with self._transaction_on(db) as tx:
|
|
110
148
|
for statement in _SCHEMA_STATEMENTS:
|
|
111
149
|
await tx.execute(statement)
|
|
112
150
|
await self._ensure_dimensions(tx)
|
|
113
|
-
|
|
151
|
+
if use_sqlite_vec:
|
|
152
|
+
await tx.execute(
|
|
153
|
+
_VECTOR_TABLE_STATEMENT.format(dimensions=self._dimensions)
|
|
154
|
+
)
|
|
155
|
+
else:
|
|
156
|
+
cursor = await tx.execute(
|
|
157
|
+
"UPDATE chunks SET status = ? WHERE status = ? AND NOT EXISTS ("
|
|
158
|
+
"SELECT 1 FROM memory_vector_blobs AS b WHERE b.chunk_id = chunks.id"
|
|
159
|
+
")",
|
|
160
|
+
(ChunkStatus.PENDING.value, ChunkStatus.READY.value),
|
|
161
|
+
)
|
|
162
|
+
if cursor.rowcount > 0:
|
|
163
|
+
logger.warning(
|
|
164
|
+
"%s 个片段缺少 BLOB 向量,已重新排队等待向量化。",
|
|
165
|
+
cursor.rowcount,
|
|
166
|
+
)
|
|
167
|
+
await cursor.close()
|
|
114
168
|
except BaseException:
|
|
115
169
|
self._db = None
|
|
170
|
+
self._use_sqlite_vec = False
|
|
116
171
|
await db.close()
|
|
117
172
|
raise
|
|
118
173
|
|
|
@@ -282,11 +337,21 @@ class MemoryStore:
|
|
|
282
337
|
await cursor.close()
|
|
283
338
|
if row is None:
|
|
284
339
|
return
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
340
|
+
if self._use_sqlite_vec:
|
|
341
|
+
await db.execute("DELETE FROM memory_vectors WHERE chunk_id = ?", (chunk_id,))
|
|
342
|
+
await db.execute(
|
|
343
|
+
"INSERT INTO memory_vectors(chunk_id, group_id, embedding) VALUES (?, ?, ?)",
|
|
344
|
+
(chunk_id, row["group_id"], sqlite_vec.serialize_float32(embedding)),
|
|
345
|
+
)
|
|
346
|
+
else:
|
|
347
|
+
await db.execute(
|
|
348
|
+
"DELETE FROM memory_vector_blobs WHERE chunk_id = ?", (chunk_id,)
|
|
349
|
+
)
|
|
350
|
+
await db.execute(
|
|
351
|
+
"INSERT INTO memory_vector_blobs(chunk_id, group_id, embedding) "
|
|
352
|
+
"VALUES (?, ?, ?)",
|
|
353
|
+
(chunk_id, row["group_id"], _pack_vector(embedding)),
|
|
354
|
+
)
|
|
290
355
|
await db.execute(
|
|
291
356
|
"UPDATE chunks SET status = ? WHERE id = ?",
|
|
292
357
|
(ChunkStatus.READY.value, chunk_id),
|
|
@@ -299,21 +364,46 @@ class MemoryStore:
|
|
|
299
364
|
return []
|
|
300
365
|
async with self._lock:
|
|
301
366
|
db = self._require_db()
|
|
367
|
+
if self._use_sqlite_vec:
|
|
368
|
+
cursor = await db.execute(
|
|
369
|
+
"SELECT c.id, c.group_id, c.text, c.status FROM ("
|
|
370
|
+
"SELECT chunk_id, distance FROM memory_vectors "
|
|
371
|
+
"WHERE embedding MATCH ? AND k = ? AND group_id = ?"
|
|
372
|
+
") AS v JOIN chunks AS c ON c.id = v.chunk_id ORDER BY v.distance",
|
|
373
|
+
(sqlite_vec.serialize_float32(embedding), limit, group_id),
|
|
374
|
+
)
|
|
375
|
+
rows = await cursor.fetchall()
|
|
376
|
+
await cursor.close()
|
|
377
|
+
return [_row_to_chunk(row) for row in rows]
|
|
378
|
+
|
|
302
379
|
cursor = await db.execute(
|
|
303
|
-
"SELECT c.id, c.group_id, c.text, c.status
|
|
304
|
-
"
|
|
305
|
-
"WHERE
|
|
306
|
-
|
|
307
|
-
(sqlite_vec.serialize_float32(embedding), limit, group_id),
|
|
380
|
+
"SELECT c.id, c.group_id, c.text, c.status, b.embedding "
|
|
381
|
+
"FROM memory_vector_blobs AS b JOIN chunks AS c ON c.id = b.chunk_id "
|
|
382
|
+
"WHERE b.group_id = ? AND c.status = ?",
|
|
383
|
+
(group_id, ChunkStatus.READY.value),
|
|
308
384
|
)
|
|
309
385
|
rows = await cursor.fetchall()
|
|
310
386
|
await cursor.close()
|
|
311
|
-
|
|
387
|
+
scored = [
|
|
388
|
+
(
|
|
389
|
+
_cosine_similarity(embedding, _unpack_vector(row["embedding"], self._dimensions)),
|
|
390
|
+
int(row["id"]),
|
|
391
|
+
row,
|
|
392
|
+
)
|
|
393
|
+
for row in rows
|
|
394
|
+
if len(row["embedding"]) == self._dimensions * 4
|
|
395
|
+
]
|
|
396
|
+
scored.sort(key=lambda item: (-item[0], item[1]))
|
|
397
|
+
return [_row_to_chunk(row) for _, _, row in scored[:limit]]
|
|
312
398
|
|
|
313
399
|
async def clear_group(self, group_id: int) -> None:
|
|
314
400
|
"""删除当前群的消息、片段与向量,保留概率配置。"""
|
|
315
401
|
async with self._transaction() as db:
|
|
316
|
-
await db.execute(
|
|
402
|
+
await db.execute(
|
|
403
|
+
"DELETE FROM memory_vector_blobs WHERE group_id = ?", (group_id,)
|
|
404
|
+
)
|
|
405
|
+
if self._use_sqlite_vec:
|
|
406
|
+
await db.execute("DELETE FROM memory_vectors WHERE group_id = ?", (group_id,))
|
|
317
407
|
await db.execute("DELETE FROM messages WHERE group_id = ?", (group_id,))
|
|
318
408
|
await db.execute("DELETE FROM chunks WHERE group_id = ?", (group_id,))
|
|
319
409
|
|
|
@@ -407,6 +497,23 @@ def _validated_vector(vector: list[float], dimensions: int) -> list[float]:
|
|
|
407
497
|
return values
|
|
408
498
|
|
|
409
499
|
|
|
500
|
+
def _pack_vector(vector: list[float]) -> bytes:
|
|
501
|
+
return struct.pack(f"<{len(vector)}f", *vector)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _unpack_vector(value: bytes, dimensions: int) -> list[float]:
|
|
505
|
+
return list(struct.unpack(f"<{dimensions}f", value))
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _cosine_similarity(left: list[float], right: list[float]) -> float:
|
|
509
|
+
dot = sum(a * b for a, b in zip(left, right, strict=True))
|
|
510
|
+
left_norm = math.sqrt(sum(value * value for value in left))
|
|
511
|
+
right_norm = math.sqrt(sum(value * value for value in right))
|
|
512
|
+
if left_norm == 0.0 or right_norm == 0.0:
|
|
513
|
+
return 0.0
|
|
514
|
+
return dot / (left_norm * right_norm)
|
|
515
|
+
|
|
516
|
+
|
|
410
517
|
def _utc_now() -> datetime:
|
|
411
518
|
return datetime.now(timezone.utc)
|
|
412
519
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|