nonebot-plugin-llm-qa-system 0.1.3__py3-none-any.whl

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.
@@ -0,0 +1,354 @@
1
+ """nonebot_plugin_llm_qa_system - 基于 RAG 的智能问答系统
2
+
3
+ 基于本地 Ollama 大模型 + 语义检索的知识问答机器人。
4
+
5
+ 命令:
6
+ 问答 <问题> — 基于知识库回答用户问题
7
+ 添加知识 <标题> <内容> — 向知识库添加条目
8
+ 删除知识 <id> — 删除指定知识条目
9
+ 列出知识 — 列出知识库所有条目
10
+ 清空知识 — 清空知识库(需确认)
11
+ 搜索知识 <关键词> — 搜索知识库
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from typing import Any
18
+
19
+ from nonebot import on_command, logger, require
20
+ from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent, permission as perm
21
+ from nonebot.params import CommandArg
22
+ from nonebot.permission import SUPERUSER
23
+ from nonebot.plugin import PluginMetadata
24
+
25
+ require("nonebot_plugin_orm")
26
+
27
+ from nonebot_plugin_orm import get_session as get_orm_session
28
+ from sqlalchemy import delete, select
29
+
30
+ from .config import Config
31
+ from .models import KnowledgeEntry
32
+ from .rag_engine import RAGEngine
33
+
34
+ __plugin_meta__ = PluginMetadata(
35
+ name="nonebot-plugin-llm-qa-system",
36
+ description="基于 Ollama + RAG 的智能问答系统",
37
+ usage=(
38
+ "问答 <问题> — 基于知识库回答\n"
39
+ "添加知识 <标题> <内容> — 添加知识条目\n"
40
+ "删除知识 <id> — 删除指定条目\n"
41
+ "列出知识 — 列出所有条目\n"
42
+ "搜索知识 <关键词> — 语义搜索\n"
43
+ "清空知识 — 清空全部(需确认)"
44
+ ),
45
+ type="application",
46
+ config=Config,
47
+ )
48
+
49
+ # ==================== 配置加载 ====================
50
+
51
+ try:
52
+ from nonebot import get_plugin_config
53
+ plugin_config = get_plugin_config(Config)
54
+ except ImportError:
55
+ from nonebot import get_driver
56
+ plugin_config = Config.parse_obj(get_driver().config)
57
+
58
+ # ==================== 全局引擎 ====================
59
+
60
+ _engine: RAGEngine | None = None
61
+
62
+
63
+ async def _get_engine() -> RAGEngine:
64
+ """获取或初始化 RAG 引擎。"""
65
+ global _engine
66
+ if _engine is None:
67
+ _engine = RAGEngine(plugin_config)
68
+ return _engine
69
+
70
+
71
+ # ==================== 问答命令 ====================
72
+
73
+ qa_cmd = on_command("问答", permission=perm.GROUP, priority=10, block=True)
74
+
75
+
76
+ @qa_cmd.handle()
77
+ async def handle_qa(
78
+ bot: Bot,
79
+ event: GroupMessageEvent,
80
+ args: Any = CommandArg(),
81
+ ) -> None:
82
+ """基于知识库回答用户问题。"""
83
+ query = args.extract_plain_text().strip()
84
+ if not query:
85
+ await qa_cmd.finish("用法:问答 <你的问题>")
86
+
87
+ await qa_cmd.send(f"🔍 正在思考:{query}")
88
+
89
+ # 加载知识库
90
+ async with get_orm_session() as session:
91
+ stmt = select(KnowledgeEntry)
92
+ result = await session.execute(stmt)
93
+ entries = result.scalars().all()
94
+
95
+ if not entries:
96
+ await qa_cmd.finish("知识库为空,请先添加知识。\n用法:添加知识 <标题> <内容>")
97
+
98
+ # 转为 dict 供检索
99
+ entry_dicts = [
100
+ {
101
+ "id": e.id,
102
+ "title": e.title,
103
+ "content": e.content,
104
+ "embedding": e.embedding,
105
+ }
106
+ for e in entries
107
+ ]
108
+
109
+ engine = await _get_engine()
110
+
111
+ # 检索
112
+ await qa_cmd.send("📚 正在检索相关知识...")
113
+ try:
114
+ relevant = await engine.retrieve(query, entry_dicts)
115
+ except Exception as e:
116
+ logger.error(f"llm_qa: 检索失败: {e}")
117
+ await qa_cmd.finish(f"❌ 检索失败:{e}")
118
+ return
119
+
120
+ if not relevant:
121
+ await qa_cmd.finish("未找到相关问题,请尝试换一种问法。")
122
+
123
+ # 生成回答
124
+ await qa_cmd.send("🤖 正在生成回答...")
125
+ answer = await engine.ask(query, relevant)
126
+
127
+ # 构建回复
128
+ sources = [f"[{i+1}] {c.get('title', '未知')}" for i, c in enumerate(relevant)]
129
+ reply = (
130
+ f"💡 回答:\n{answer}\n\n"
131
+ f"📎 参考来源:\n" + "\n".join(sources)
132
+ )
133
+ await qa_cmd.finish(reply)
134
+
135
+
136
+ # ==================== 知识管理命令 ====================
137
+
138
+ add_cmd = on_command("添加知识", permission=perm.GROUP, priority=10, block=True)
139
+
140
+
141
+ @add_cmd.handle()
142
+ async def handle_add_knowledge(
143
+ bot: Bot,
144
+ event: GroupMessageEvent,
145
+ args: Any = CommandArg(),
146
+ ) -> None:
147
+ """添加知识条目。"""
148
+ text = args.extract_plain_text().strip()
149
+ parts = text.split(maxsplit=1)
150
+ if len(parts) < 2:
151
+ await add_cmd.finish("用法:添加知识 <标题> <内容>")
152
+
153
+ title = parts[0]
154
+ content = parts[1]
155
+
156
+ # 生成嵌入
157
+ engine = await _get_engine()
158
+ try:
159
+ embedding = await engine.embed(f"{title}\n{content}")
160
+ except Exception as e:
161
+ logger.error(f"llm_qa: 生成嵌入失败: {e}")
162
+ await add_cmd.finish(f"❌ 生成嵌入向量失败,无法添加知识:{e}")
163
+ return
164
+
165
+ if not embedding:
166
+ await add_cmd.finish("❌ 嵌入向量返回为空,请检查 Ollama 嵌入模型是否可用。")
167
+ return
168
+
169
+ # 入库
170
+ async with get_orm_session() as session:
171
+ entry = KnowledgeEntry(
172
+ title=title,
173
+ content=content,
174
+ embedding=json.dumps(embedding),
175
+ )
176
+ session.add(entry)
177
+ await session.flush() # 先 flush 让数据库生成自增 ID
178
+ entry_id = entry.id # flush 后 id 已填充到实例中,此时访问不会触发 lazy load
179
+ await session.commit()
180
+
181
+ await add_cmd.finish(
182
+ f"✅ 已添加知识 #{entry_id}\n"
183
+ f"标题:{title}\n"
184
+ f"内容:{content[:100]}{'...' if len(content) > 100 else ''}"
185
+ )
186
+
187
+
188
+ # ==================== 列出知识 ====================
189
+
190
+ list_cmd = on_command("列出知识", permission=perm.GROUP, priority=10, block=True)
191
+
192
+
193
+ @list_cmd.handle()
194
+ async def handle_list_knowledge(
195
+ bot: Bot,
196
+ event: GroupMessageEvent,
197
+ ) -> None:
198
+ """列出知识库所有条目。"""
199
+ async with get_orm_session() as session:
200
+ stmt = select(KnowledgeEntry).order_by(KnowledgeEntry.id)
201
+ result = await session.execute(stmt)
202
+ entries = result.scalars().all()
203
+
204
+ if not entries:
205
+ await list_cmd.finish("📭 知识库为空")
206
+
207
+ lines = ["📚 知识库列表:"]
208
+ for e in entries:
209
+ preview = e.content[:80].replace("\n", " ")
210
+ lines.append(f" #{e.id} {e.title} — {preview}{'...' if len(e.content) > 80 else ''}")
211
+ lines.append(f"\n共 {len(entries)} 条")
212
+
213
+ # 分批发送避免消息过长
214
+ msg = "\n".join(lines)
215
+ if len(msg) > 1500:
216
+ chunks = []
217
+ current = []
218
+ for line in lines:
219
+ if current and len("\n".join(current + [line])) > 1000:
220
+ chunks.append("\n".join(current))
221
+ current = [line]
222
+ else:
223
+ current.append(line)
224
+ if current:
225
+ chunks.append("\n".join(current))
226
+ # 除最后一条外都用 send,最后一条用 finish
227
+ for chunk in chunks[:-1]:
228
+ await list_cmd.send(chunk)
229
+ await list_cmd.finish(chunks[-1])
230
+ else:
231
+ await list_cmd.finish(msg)
232
+
233
+
234
+ # ==================== 搜索知识 ====================
235
+
236
+ search_cmd = on_command("搜索知识", permission=perm.GROUP, priority=10, block=True)
237
+
238
+
239
+ @search_cmd.handle()
240
+ async def handle_search_knowledge(
241
+ bot: Bot,
242
+ event: GroupMessageEvent,
243
+ args: Any = CommandArg(),
244
+ ) -> None:
245
+ """语义搜索知识库。"""
246
+ query = args.extract_plain_text().strip()
247
+ if not query:
248
+ await search_cmd.finish("用法:搜索知识 <关键词>")
249
+
250
+ async with get_orm_session() as session:
251
+ stmt = select(KnowledgeEntry)
252
+ result = await session.execute(stmt)
253
+ entries = result.scalars().all()
254
+
255
+ if not entries:
256
+ await search_cmd.finish("📭 知识库为空")
257
+
258
+ entry_dicts = [
259
+ {"id": e.id, "title": e.title, "content": e.content, "embedding": e.embedding}
260
+ for e in entries
261
+ ]
262
+
263
+ engine = await _get_engine()
264
+ try:
265
+ relevant = await engine.retrieve(query, entry_dicts, top_k=5)
266
+ except Exception as e:
267
+ logger.error(f"llm_qa: 搜索失败: {e}")
268
+ await search_cmd.finish(f"❌ 搜索失败:{e}")
269
+ return
270
+
271
+ if not relevant:
272
+ await search_cmd.finish(f"未找到与「{query}」相关的内容")
273
+
274
+ lines = [f"🔍 搜索「{query}」结果:"]
275
+ for i, c in enumerate(relevant, 1):
276
+ content_preview = c["content"][:100].replace("\n", " ")
277
+ lines.append(f" #{c['id']} [{i}] {c['title']}")
278
+ lines.append(f" {content_preview}{'...' if len(c['content']) > 100 else ''}")
279
+
280
+ await search_cmd.finish("\n".join(lines))
281
+
282
+
283
+ # ==================== 删除知识 ====================
284
+
285
+ del_cmd = on_command("删除知识", permission=SUPERUSER, priority=10, block=True)
286
+
287
+
288
+ @del_cmd.handle()
289
+ async def handle_delete_knowledge(
290
+ bot: Bot,
291
+ event: GroupMessageEvent,
292
+ args: Any = CommandArg(),
293
+ ) -> None:
294
+ """删除指定知识条目。"""
295
+ text = args.extract_plain_text().strip()
296
+ if not text.isdigit():
297
+ await del_cmd.finish("用法:删除知识 <ID>")
298
+
299
+ entry_id = int(text)
300
+
301
+ async with get_orm_session() as session:
302
+ stmt = select(KnowledgeEntry).where(KnowledgeEntry.id == entry_id)
303
+ result = await session.execute(stmt)
304
+ entry = result.scalar_one_or_none()
305
+ if entry is None:
306
+ await del_cmd.finish(f"❌ 未找到 ID 为 {entry_id} 的知识条目")
307
+
308
+ title = entry.title
309
+ await session.delete(entry)
310
+ await session.commit()
311
+
312
+ await del_cmd.finish(f"🗑️ 已删除 #{entry_id} {title}")
313
+
314
+
315
+ # ==================== 清空知识 ====================
316
+
317
+ clear_cmd = on_command("清空知识", permission=SUPERUSER, priority=10, block=True)
318
+
319
+
320
+ @clear_cmd.handle()
321
+ async def handle_clear_knowledge(
322
+ bot: Bot,
323
+ event: GroupMessageEvent,
324
+ args: Any = CommandArg(),
325
+ ) -> None:
326
+ """清空知识库。"""
327
+ confirm = args.extract_plain_text().strip()
328
+ if confirm != "确认":
329
+ await clear_cmd.finish(
330
+ "⚠️ 确认要清空所有知识条目吗?\n"
331
+ "此操作不可撤销。\n"
332
+ "请发送:清空知识 确认"
333
+ )
334
+
335
+ async with get_orm_session() as session:
336
+ stmt = delete(KnowledgeEntry)
337
+ result = await session.execute(stmt)
338
+ await session.commit()
339
+ count = result.rowcount
340
+
341
+ await clear_cmd.finish(f"🗑️ 已清空知识库,共删除 {count} 条")
342
+
343
+
344
+ # ==================== 启动/关闭事件 ====================
345
+
346
+ from nonebot import get_driver
347
+
348
+ driver = get_driver()
349
+
350
+
351
+ @driver.on_shutdown
352
+ async def _():
353
+ if _engine is not None:
354
+ await _engine.close()
@@ -0,0 +1,29 @@
1
+ """nonebot_plugin_llm_qa_system - 配置"""
2
+
3
+ from pydantic import BaseModel, Extra
4
+
5
+
6
+ class Config(BaseModel, extra=Extra.ignore):
7
+ """插件配置项,在 .env 文件中设置"""
8
+
9
+ # Ollama 服务地址
10
+ llm_qa_ollama_host: str = "http://localhost:11434"
11
+
12
+ # 对话模型名称
13
+ llm_qa_chat_model: str = "qwen3:1.7b"
14
+
15
+ # 嵌入模型名称
16
+ llm_qa_embed_model: str = "nomic-embed-text"
17
+
18
+ # RAG 检索返回的最大相关文档数
19
+ llm_qa_top_k: int = 3
20
+
21
+ # 余弦相似度最低阈值,低于该值的条目不返回也不展示
22
+ llm_qa_min_score: float = 0.3
23
+
24
+ # 系统提示词
25
+ llm_qa_system_prompt: str = (
26
+ "你是一个智能问答助手。请根据提供的参考信息,"
27
+ "用中文回答用户的问题。如果参考信息不足以回答问题,"
28
+ "请如实告知,不要编造答案。"
29
+ )
@@ -0,0 +1,20 @@
1
+ """nonebot_plugin_llm_qa_system - ORM 数据模型"""
2
+
3
+ from nonebot import require
4
+
5
+ require("nonebot_plugin_orm")
6
+
7
+ from nonebot_plugin_orm import Model
8
+ from sqlalchemy import TEXT, Integer, String
9
+ from sqlalchemy.orm import Mapped, mapped_column
10
+
11
+
12
+ class KnowledgeEntry(Model):
13
+ """知识条目表"""
14
+
15
+ __tablename__ = "llm_qa_knowledge"
16
+
17
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
18
+ title: Mapped[str] = mapped_column(String(255), comment="标题/关键词")
19
+ content: Mapped[str] = mapped_column(TEXT, comment="知识内容")
20
+ embedding: Mapped[str] = mapped_column(TEXT, comment="嵌入向量(JSON数组)", default="[]")
@@ -0,0 +1,210 @@
1
+ """nonebot_plugin_llm_qa_system - RAG 引擎(Ollama 嵌入 + 语义搜索 + LLM 生成)"""
2
+
3
+ import json
4
+ import math
5
+ from typing import Any
6
+
7
+ import httpx
8
+ from nonebot import logger
9
+
10
+ from .config import Config
11
+
12
+
13
+ class RAGEngine:
14
+ """基于 Ollama 的 RAG 引擎,提供嵌入、检索、问答能力。"""
15
+
16
+ def __init__(self, config: Config) -> None:
17
+ self.config = config
18
+ self._http = httpx.AsyncClient(
19
+ base_url=config.llm_qa_ollama_host,
20
+ timeout=60,
21
+ )
22
+ self._embed_api_ver: int | None = None # 1 = /api/embeddings, 2 = /api/embed
23
+
24
+ # ==================== 嵌入 ====================
25
+
26
+ async def embed(self, text: str) -> list[float]:
27
+ """调用 Ollama 生成文本嵌入向量。
28
+
29
+ 优先尝试新版 /api/embed API,失败时回退到旧版 /api/embeddings。
30
+ """
31
+ if self._embed_api_ver == 2 or self._embed_api_ver is None:
32
+ try:
33
+ return await self._embed_v2(text)
34
+ except Exception as e:
35
+ if self._embed_api_ver == 2:
36
+ raise
37
+ logger.warning(f"llm_qa: /api/embed 失败,尝试 /api/embeddings: {e}")
38
+
39
+ return await self._embed_v1(text)
40
+
41
+ async def _embed_v2(self, text: str) -> list[float]:
42
+ """新版 Ollama 嵌入 API (>=0.1.24)"""
43
+ resp = await self._http.post(
44
+ "/api/embed",
45
+ json={
46
+ "model": self.config.llm_qa_embed_model,
47
+ "input": text,
48
+ },
49
+ )
50
+ resp.raise_for_status()
51
+ data = resp.json()
52
+
53
+ # 新版返回 embeddings: list[list[float]]
54
+ embeddings = data.get("embeddings")
55
+ if embeddings and isinstance(embeddings, list) and len(embeddings) > 0:
56
+ self._embed_api_ver = 2
57
+ return embeddings[0]
58
+
59
+ # 某些版本可能返回 embedding: list[float]
60
+ single = data.get("embedding")
61
+ if single and isinstance(single, list):
62
+ self._embed_api_ver = 2
63
+ return single
64
+
65
+ raise RuntimeError(f"无法解析 /api/embed 响应: {data.keys()}")
66
+
67
+ async def _embed_v1(self, text: str) -> list[float]:
68
+ """旧版 Ollama 嵌入 API"""
69
+ resp = await self._http.post(
70
+ "/api/embeddings",
71
+ json={
72
+ "model": self.config.llm_qa_embed_model,
73
+ "prompt": text,
74
+ },
75
+ )
76
+ resp.raise_for_status()
77
+ data = resp.json()
78
+
79
+ embedding = data.get("embedding")
80
+ if embedding and isinstance(embedding, list):
81
+ self._embed_api_ver = 1
82
+ return embedding
83
+
84
+ raise RuntimeError(f"无法解析 /api/embeddings 响应: {data.keys()}")
85
+
86
+ # ==================== 相似度 ====================
87
+
88
+ @staticmethod
89
+ def cosine_similarity(a: list[float], b: list[float]) -> float:
90
+ """计算两个向量的余弦相似度。"""
91
+ if not a or not b or len(a) != len(b):
92
+ return 0.0
93
+ dot = sum(x * y for x, y in zip(a, b))
94
+ norm_a = math.sqrt(sum(x * x for x in a))
95
+ norm_b = math.sqrt(sum(x * x for x in b))
96
+ if norm_a == 0 or norm_b == 0:
97
+ return 0.0
98
+ return dot / (norm_a * norm_b)
99
+
100
+ # ==================== 检索 ====================
101
+
102
+ async def retrieve(
103
+ self,
104
+ query: str,
105
+ entries: list[dict[str, Any]],
106
+ top_k: int | None = None,
107
+ ) -> list[dict[str, Any]]:
108
+ """检索与查询最相关的知识条目。
109
+
110
+ Args:
111
+ query: 用户查询文本。
112
+ entries: 知识条目列表,每项含 id, title, content, embedding。
113
+ top_k: 返回条数,默认使用配置值。
114
+
115
+ Returns:
116
+ 按相似度降序排列的条目列表。
117
+
118
+ Raises:
119
+ RuntimeError: 嵌入生成失败时抛出。
120
+ """
121
+ if not entries:
122
+ return []
123
+
124
+ top_k = top_k or self.config.llm_qa_top_k
125
+ query_emb = await self.embed(query)
126
+ if not query_emb:
127
+ raise RuntimeError("查询嵌入生成失败,无法执行检索")
128
+
129
+ scored: list[tuple[float, dict[str, Any]]] = []
130
+ for entry in entries:
131
+ emb = json.loads(entry.get("embedding", "[]") or "[]")
132
+ if not emb:
133
+ continue
134
+ score = self.cosine_similarity(query_emb, emb)
135
+ scored.append((score, entry))
136
+
137
+ scored.sort(key=lambda x: -x[0])
138
+ min_score = self.config.llm_qa_min_score
139
+ return [entry for score, entry in scored[:top_k] if score >= min_score]
140
+
141
+ # ==================== 问答 ====================
142
+
143
+ async def ask(
144
+ self,
145
+ query: str,
146
+ context_chunks: list[dict[str, Any]],
147
+ max_context_chars: int = 6000,
148
+ ) -> str:
149
+ """调用 Ollama 生成回答。
150
+
151
+ Args:
152
+ query: 用户问题。
153
+ context_chunks: 检索到的相关条目。
154
+ max_context_chars: 上下文最大字符数,超出时逐个截断条目内容。
155
+
156
+ Returns:
157
+ LLM 生成的回答文本。
158
+ """
159
+ # 构建上下文文本(带长度限制)
160
+ context_parts: list[str] = []
161
+ current_len = 0
162
+ for i, chunk in enumerate(context_chunks, 1):
163
+ title = chunk.get("title", f"文档{i}")
164
+ content = chunk.get("content", "")
165
+ part = f"[{i}] {title}\n{content}"
166
+
167
+ remaining = max_context_chars - current_len
168
+ if remaining <= 0:
169
+ break
170
+ if len(part) > remaining:
171
+ part = part[:max(remaining - 30, 0)] + "\n...[内容过长,已截断]"
172
+ context_parts.append(part)
173
+ current_len += len(part)
174
+
175
+ context_text = "\n\n".join(context_parts)
176
+
177
+ messages = [
178
+ {"role": "system", "content": self.config.llm_qa_system_prompt},
179
+ ]
180
+ if context_text:
181
+ messages.append({
182
+ "role": "user",
183
+ "content": (
184
+ f"请根据以下参考信息回答问题。\n\n"
185
+ f"参考信息:\n{context_text}\n\n"
186
+ f"问题:{query}"
187
+ ),
188
+ })
189
+ else:
190
+ messages.append({"role": "user", "content": query})
191
+
192
+ try:
193
+ resp = await self._http.post(
194
+ "/api/chat",
195
+ json={
196
+ "model": self.config.llm_qa_chat_model,
197
+ "messages": messages,
198
+ "stream": False,
199
+ },
200
+ )
201
+ resp.raise_for_status()
202
+ data = resp.json()
203
+ return data.get("message", {}).get("content", "抱歉,我没有得到有效的回答。")
204
+ except Exception as e:
205
+ logger.error(f"llm_qa: LLM 调用失败: {e}")
206
+ return f"抱歉,调用语言模型时出错:{e}"
207
+
208
+ async def close(self) -> None:
209
+ """关闭 HTTP 客户端。"""
210
+ await self._http.aclose()
@@ -0,0 +1,145 @@
1
+ Metadata-Version: 2.4
2
+ Name: nonebot-plugin-llm-qa-system
3
+ Version: 0.1.3
4
+ Summary: 基于 Ollama + RAG 的智能问答系统
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Keywords: nonebot,nonebot2,ollama,rag,qa
8
+ Author: BG4JEC
9
+ Author-email: BG4JEC@hotmail.com
10
+ Requires-Python: >=3.9
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Requires-Dist: httpx (>=0.24.0)
20
+ Requires-Dist: nonebot-adapter-onebot (>=2.0.0,<3.0.0)
21
+ Requires-Dist: nonebot-plugin-orm (>=1.0.0)
22
+ Requires-Dist: nonebot2 (>=2.0.0,<3.0.0)
23
+ Project-URL: Repository, https://github.com/2580m/nonebot-plugin-llm-qa-system
24
+ Description-Content-Type: text/markdown
25
+
26
+ # nonebot-plugin-llm-qa-system
27
+
28
+ 基于本地 Ollama 大模型 + RAG(检索增强生成)的 NoneBot2 智能问答插件。
29
+
30
+ ## 功能
31
+
32
+ - **问答**:基于知识库的内容,利用 LLM 生成回答
33
+ - **添加知识**:向知识库添加条目,自动生成语义嵌入向量
34
+ - **语义搜索**:通过余弦相似度检索相关知识
35
+ - **知识管理**:列出、删除、清空知识条目
36
+
37
+ ## 安装
38
+
39
+ ```bash
40
+ pip install nonebot-plugin-llm-qa-system
41
+ ```
42
+
43
+ 或者将本插件目录复制到项目的 `src/plugins/` 下,然后在 `pyproject.toml` 中注册:
44
+
45
+ ```toml
46
+ [tool.nonebot]
47
+ plugins = ["nonebot_plugin_llm_qa_system"]
48
+ ```
49
+
50
+ ## 前置依赖
51
+
52
+ - [Ollama](https://ollama.com/) 本地运行
53
+ - 所需的模型(首次使用前需拉取):
54
+
55
+ ```bash
56
+ ollama pull qwen3:1.7b # 对话模型(默认)
57
+ ollama pull nomic-embed-text # 嵌入模型
58
+ ```
59
+
60
+ ## 配置
61
+
62
+ 在项目 `.env` 文件中添加以下配置项:
63
+
64
+ ```env
65
+ # —— 数据库(必须)——
66
+ # 默认使用 SQLite,通过 nonebot-plugin-orm 管理
67
+ # 可自定义路径,确保目录已创建
68
+ SQLALCHEMY_DATABASE_URL=sqlite+aiosqlite:///path/to/data/llm_qa.db
69
+
70
+ # —— 插件配置 ——
71
+ # Ollama 服务地址(默认值 http://localhost:11434)
72
+ llm_qa_ollama_host=http://localhost:11434
73
+
74
+ # 对话模型名称(默认值 qwen3:1.7b)
75
+ llm_qa_chat_model=qwen3:1.7b
76
+
77
+ # 嵌入模型名称(默认值 nomic-embed-text)
78
+ llm_qa_embed_model=nomic-embed-text
79
+
80
+ # RAG 检索返回的最大相关文档数(默认值 3)
81
+ llm_qa_top_k=3
82
+
83
+ # 余弦相似度最低阈值,低于该值的结果不返回(默认值 0.3)
84
+ llm_qa_min_score=0.3
85
+ ```
86
+
87
+ ## 使用
88
+
89
+ 插件目前仅支持 **QQ 群聊**,所有命令通过群消息触发。
90
+
91
+ | 命令 | 权限 | 说明 |
92
+ |------|------|------|
93
+ | `问答 <问题>` | 群员 | 基于知识库回答用户问题 |
94
+ | `添加知识 <标题> <内容>` | 群员 | 向知识库添加条目 |
95
+ | `删除知识 <id>` | SUPERUSER | 删除指定条目 |
96
+ | `列出知识` | 群员 | 列出知识库所有条目 |
97
+ | `搜索知识 <关键词>` | 群员 | 语义搜索知识库 |
98
+ | `清空知识` | SUPERUSER | 清空全部条目(需确认) |
99
+
100
+ ### 示例
101
+
102
+ ```
103
+ 问答 RHEL 是什么?
104
+ 添加知识 Docker安装 使用以下命令安装 Docker...
105
+ 删除知识 3
106
+ 列出知识
107
+ 搜索知识 防火墙配置
108
+ 清空知识 确认
109
+ ```
110
+
111
+ ## 工作原理
112
+
113
+ ```
114
+ 用户提问 → 嵌入查询向量 → 余弦相似度检索知识库 → 拼接上下文 → LLM 生成回答
115
+ ↓ ↑
116
+ Ollama nomic-embed-text 知识库(SQLite + SQLAlchemy ORM)
117
+
118
+ Ollama qwen3:1.7b
119
+ ```
120
+
121
+ 1. 用户发送 `问答 <问题>`
122
+ 2. 插件从 SQLite 加载全部知识条目
123
+ 3. 调用 Ollama 的嵌入 API 将问题转为向量
124
+ 4. 计算所有条目的余弦相似度,返回 top_k 中高于 min_score 的条目
125
+ 5. 拼接为 Prompt 发送给 Ollama 对话模型
126
+ 6. 返回 LLM 生成的回答和参考来源
127
+
128
+ ## 兼容性
129
+
130
+ 插件自动兼容不同版本的 Ollama 嵌入 API:
131
+ - 优先尝试新版 `/api/embed`(Ollama >= 0.1.24)
132
+ - 失败时自动降级到旧版 `/api/embeddings`
133
+
134
+ ## 依赖
135
+
136
+ - `nonebot2>=2.0.0`
137
+ - `nonebot-adapter-onebot>=2.0.0`
138
+ - `nonebot-plugin-orm>=1.0.0`
139
+ - `httpx>=0.24.0`
140
+ - `Ollama`(外部服务)
141
+
142
+ ## 许可证
143
+
144
+ MIT
145
+
@@ -0,0 +1,8 @@
1
+ nonebot_plugin_llm_qa_system/__init__.py,sha256=5QC3ZBn1lT2Zwxov1K50Nwe33uy-OznhbnQrvAuDUDQ,10676
2
+ nonebot_plugin_llm_qa_system/config.py,sha256=5MwqhNqyzLjymOW0wLxqgcA1sRAu7J2vlg6DQj11RPY,872
3
+ nonebot_plugin_llm_qa_system/models.py,sha256=jz4_yNzBBgXUgI0WFKA0aoU9DZRau2I3tKieWJed6Oo,672
4
+ nonebot_plugin_llm_qa_system/rag_engine.py,sha256=8cgpIvvxlLwsQWlCAMHsWjwBGORTekuaWQpwYrTTzWc,7031
5
+ nonebot_plugin_llm_qa_system-0.1.3.dist-info/licenses/LICENSE,sha256=FyJNmoVZFPTaveI-LXwvas62zHdjc9fiH6D84Y9_rF4,1083
6
+ nonebot_plugin_llm_qa_system-0.1.3.dist-info/METADATA,sha256=pEMLKUYLIEdOfy2-WcULpecDZ8_eGDC3IDAUg_kVjEg,4310
7
+ nonebot_plugin_llm_qa_system-0.1.3.dist-info/WHEEL,sha256=EGEvSphFYqXKs23-kQBeyNoJP1nrT8ZJKQoi5p5DYL8,88
8
+ nonebot_plugin_llm_qa_system-0.1.3.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.4.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BG4JEC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.