feat: 同步知识库与工作台相关改动
This commit is contained in:
@@ -0,0 +1,385 @@
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import faiss
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from transformers import AutoModel, AutoModelForSequenceClassification, AutoTokenizer
|
||||
|
||||
|
||||
app = FastAPI(title="Bosun Knowledge Service", version="1.0.0")
|
||||
|
||||
|
||||
class ClassifyRequest(BaseModel):
|
||||
query: str
|
||||
|
||||
|
||||
class SearchRequest(BaseModel):
|
||||
query: str
|
||||
space_key: str = "general"
|
||||
top_k: int = 5
|
||||
|
||||
|
||||
class IndexItem(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
content: str
|
||||
chunk_index: int = 0
|
||||
source_type: str = ""
|
||||
source_id: str = ""
|
||||
knowledge_space_key: str = "general"
|
||||
knowledge_space_name: str = "通用知识库"
|
||||
|
||||
|
||||
class RebuildIndexRequest(BaseModel):
|
||||
index_dir: str
|
||||
items: list[IndexItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class KnowledgeEngine:
|
||||
def __init__(self) -> None:
|
||||
self.device = "cuda" if os.getenv("KNOWLEDGE_DEVICE", "cpu") == "cuda" and torch.cuda.is_available() else "cpu"
|
||||
self.classifier_encoder_name = os.getenv("KNOWLEDGE_BERT_MODEL", "bert-base-chinese")
|
||||
self.classifier_model_name = os.getenv("KNOWLEDGE_BERT_CLASSIFIER_MODEL", "")
|
||||
self.embedding_model_name = os.getenv("KNOWLEDGE_BGE_MODEL", "BAAI/bge-small-zh-v1.5")
|
||||
self.lock = threading.RLock()
|
||||
self.classifier_tokenizer = None
|
||||
self.classifier_encoder = None
|
||||
self.classifier_head_tokenizer = None
|
||||
self.classifier_head_model = None
|
||||
self.embedding_tokenizer = None
|
||||
self.embedding_model = None
|
||||
self.index = None
|
||||
self.metadata: list[dict[str, Any]] = []
|
||||
self.index_dir = Path(os.getenv("KNOWLEDGE_INDEX_DIR", Path(__file__).resolve().parent / "data" / "faiss"))
|
||||
self.index_path = self.index_dir / "knowledge.index"
|
||||
self.meta_path = self.index_dir / "knowledge_meta.json"
|
||||
self.label_descriptions = {
|
||||
"faq": "标准业务问答、高频固定问题、制度规则查询、功能操作说明",
|
||||
"document": "需要查阅资料、归纳文档、分析内容、总结合同与报告的复杂问题",
|
||||
"smalltalk": "闲聊、故事、笑话、天气、娱乐、无业务价值的聊天问题",
|
||||
"out_of_scope": "股票、医疗、法律、旅游、影视等超出企业知识库范围的问题",
|
||||
"invalid": "信息过少、语义不完整、无法判断意图的无效提问",
|
||||
}
|
||||
self._load_index_if_exists()
|
||||
|
||||
def health(self) -> dict[str, Any]:
|
||||
return {
|
||||
"device": self.device,
|
||||
"classifier_encoder": self.classifier_encoder_name,
|
||||
"classifier_model": self.classifier_model_name or "prototype-bert",
|
||||
"embedding_model": self.embedding_model_name,
|
||||
"index_dir": str(self.index_dir),
|
||||
"indexed_items": len(self.metadata),
|
||||
}
|
||||
|
||||
def classify(self, query: str) -> dict[str, Any]:
|
||||
query = (query or "").strip()
|
||||
if len(query) < 2:
|
||||
return {"intent": "invalid", "score": 1.0, "reason": "query_too_short"}
|
||||
|
||||
if self.classifier_model_name:
|
||||
try:
|
||||
return self._classify_with_sequence_model(query)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
return self._classify_with_prototypes(query)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
rule_result = self._rule_classify(query)
|
||||
if rule_result:
|
||||
return rule_result
|
||||
|
||||
return {"intent": "document", "score": 0.0, "reason": "bert_classifier_unavailable"}
|
||||
|
||||
def _rule_classify(self, query: str) -> dict[str, Any] | None:
|
||||
text = query.strip().lower()
|
||||
if not text:
|
||||
return {"intent": "invalid", "score": 1.0, "reason": "empty_query"}
|
||||
|
||||
smalltalk_keywords = [
|
||||
"讲个故事",
|
||||
"小故事",
|
||||
"笑话",
|
||||
"闲聊",
|
||||
"聊天",
|
||||
"夸夸我",
|
||||
"今天天气",
|
||||
"天气",
|
||||
"星座",
|
||||
"你好",
|
||||
"hi",
|
||||
"hello",
|
||||
"你是谁",
|
||||
"你能做什么",
|
||||
"你是怎么工作的",
|
||||
]
|
||||
if any(keyword in text for keyword in smalltalk_keywords):
|
||||
return {"intent": "smalltalk", "score": 0.98, "reason": "rule_smalltalk"}
|
||||
|
||||
out_of_scope_keywords = [
|
||||
"股票",
|
||||
"彩票",
|
||||
"电影",
|
||||
"明星",
|
||||
"旅游",
|
||||
"菜谱",
|
||||
"医学",
|
||||
"法律咨询",
|
||||
"写诗",
|
||||
"翻译成英文",
|
||||
]
|
||||
if any(keyword in text for keyword in out_of_scope_keywords):
|
||||
return {"intent": "out_of_scope", "score": 0.98, "reason": "rule_out_of_scope"}
|
||||
|
||||
document_keywords = [
|
||||
"总结",
|
||||
"梳理",
|
||||
"分析",
|
||||
"对比",
|
||||
"归纳",
|
||||
"提纲",
|
||||
"解读",
|
||||
"起草",
|
||||
"生成",
|
||||
"合同",
|
||||
"报告",
|
||||
"方案",
|
||||
"条款",
|
||||
"根据资料",
|
||||
"根据文档",
|
||||
"根据知识库",
|
||||
"整理",
|
||||
"审批要点",
|
||||
]
|
||||
if any(keyword in text for keyword in document_keywords):
|
||||
return {"intent": "document", "score": 0.97, "reason": "rule_document"}
|
||||
|
||||
faq_keywords = [
|
||||
"如何",
|
||||
"怎么",
|
||||
"哪里",
|
||||
"在哪",
|
||||
"是否",
|
||||
"有没有",
|
||||
"可以",
|
||||
"支持",
|
||||
"密码",
|
||||
"登录",
|
||||
"佣金",
|
||||
"规则",
|
||||
"流程",
|
||||
"审批",
|
||||
"上传",
|
||||
"删除",
|
||||
"新建",
|
||||
]
|
||||
if any(keyword in text for keyword in faq_keywords):
|
||||
return {"intent": "faq", "score": 0.96, "reason": "rule_faq"}
|
||||
|
||||
if len(query) <= 18:
|
||||
return {"intent": "faq", "score": 0.75, "reason": "rule_short_query"}
|
||||
|
||||
return None
|
||||
|
||||
def search(self, query: str, space_key: str, top_k: int) -> list[dict[str, Any]]:
|
||||
query = (query or "").strip()
|
||||
if not query:
|
||||
return []
|
||||
with self.lock:
|
||||
if self.index is None or not self.metadata:
|
||||
self._load_index_if_exists()
|
||||
if self.index is None or not self.metadata:
|
||||
return []
|
||||
|
||||
query_vec = self._encode_embedding([query])[0].astype("float32")
|
||||
query_vec = np.expand_dims(query_vec, axis=0)
|
||||
fetch_k = min(len(self.metadata), max(top_k * 8, 32))
|
||||
scores, indices = self.index.search(query_vec, fetch_k)
|
||||
items: list[dict[str, Any]] = []
|
||||
for score, idx in zip(scores[0].tolist(), indices[0].tolist()):
|
||||
if idx < 0 or idx >= len(self.metadata):
|
||||
continue
|
||||
meta = self.metadata[idx]
|
||||
if not self._match_space(space_key, meta.get("knowledge_space_key", "general")):
|
||||
continue
|
||||
items.append(
|
||||
{
|
||||
**meta,
|
||||
"snippet": self._build_snippet(meta.get("content", ""), query),
|
||||
"score": float(score),
|
||||
}
|
||||
)
|
||||
if len(items) >= top_k:
|
||||
break
|
||||
return items
|
||||
|
||||
def rebuild_index(self, index_dir: str, items: list[IndexItem]) -> dict[str, Any]:
|
||||
target_dir = Path(index_dir or self.index_dir)
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
records = [item.model_dump() for item in items if item.content.strip()]
|
||||
|
||||
if not records:
|
||||
empty_index = faiss.IndexFlatIP(384)
|
||||
faiss.write_index(empty_index, str(target_dir / "knowledge.index"))
|
||||
(target_dir / "knowledge_meta.json").write_text("[]", encoding="utf-8")
|
||||
with self.lock:
|
||||
self.index_dir = target_dir
|
||||
self.index_path = target_dir / "knowledge.index"
|
||||
self.meta_path = target_dir / "knowledge_meta.json"
|
||||
self.index = empty_index
|
||||
self.metadata = []
|
||||
return {"indexed_items": 0}
|
||||
|
||||
embeddings = self._encode_embedding([item["content"] for item in records]).astype("float32")
|
||||
dim = int(embeddings.shape[1])
|
||||
index = faiss.IndexFlatIP(dim)
|
||||
index.add(embeddings)
|
||||
faiss.write_index(index, str(target_dir / "knowledge.index"))
|
||||
(target_dir / "knowledge_meta.json").write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
with self.lock:
|
||||
self.index_dir = target_dir
|
||||
self.index_path = target_dir / "knowledge.index"
|
||||
self.meta_path = target_dir / "knowledge_meta.json"
|
||||
self.index = index
|
||||
self.metadata = records
|
||||
return {"indexed_items": len(records)}
|
||||
|
||||
def _load_index_if_exists(self) -> None:
|
||||
with self.lock:
|
||||
if self.index_path.exists() and self.meta_path.exists():
|
||||
self.index = faiss.read_index(str(self.index_path))
|
||||
self.metadata = json.loads(self.meta_path.read_text(encoding="utf-8"))
|
||||
|
||||
def _classify_with_sequence_model(self, query: str) -> dict[str, Any]:
|
||||
if self.classifier_head_tokenizer is None or self.classifier_head_model is None:
|
||||
self.classifier_head_tokenizer = AutoTokenizer.from_pretrained(self.classifier_model_name)
|
||||
self.classifier_head_model = AutoModelForSequenceClassification.from_pretrained(self.classifier_model_name).to(self.device)
|
||||
self.classifier_head_model.eval()
|
||||
encoded = self.classifier_head_tokenizer(query, return_tensors="pt", truncation=True, max_length=256).to(self.device)
|
||||
with torch.no_grad():
|
||||
logits = self.classifier_head_model(**encoded).logits
|
||||
probs = F.softmax(logits, dim=-1)[0].detach().cpu().numpy()
|
||||
labels = self.classifier_head_model.config.id2label or {}
|
||||
best_idx = int(np.argmax(probs))
|
||||
intent = str(labels.get(best_idx, "document")).lower()
|
||||
if intent not in self.label_descriptions:
|
||||
intent = "document"
|
||||
return {"intent": intent, "score": float(probs[best_idx]), "reason": "sequence_classifier"}
|
||||
|
||||
def _classify_with_prototypes(self, query: str) -> dict[str, Any]:
|
||||
texts = [query] + list(self.label_descriptions.values())
|
||||
vectors = self._encode_classifier(texts)
|
||||
query_vec = vectors[0]
|
||||
label_vecs = vectors[1:]
|
||||
scores = np.dot(label_vecs, query_vec)
|
||||
labels = list(self.label_descriptions.keys())
|
||||
best_idx = int(np.argmax(scores))
|
||||
return {
|
||||
"intent": labels[best_idx],
|
||||
"score": float(scores[best_idx]),
|
||||
"reason": "prototype_bert",
|
||||
}
|
||||
|
||||
def _encode_classifier(self, texts: list[str]) -> np.ndarray:
|
||||
if self.classifier_tokenizer is None or self.classifier_encoder is None:
|
||||
self.classifier_tokenizer = AutoTokenizer.from_pretrained(self.classifier_encoder_name)
|
||||
self.classifier_encoder = AutoModel.from_pretrained(self.classifier_encoder_name).to(self.device)
|
||||
self.classifier_encoder.eval()
|
||||
return self._encode(texts, self.classifier_tokenizer, self.classifier_encoder)
|
||||
|
||||
def _encode_embedding(self, texts: list[str]) -> np.ndarray:
|
||||
if self.embedding_tokenizer is None or self.embedding_model is None:
|
||||
self.embedding_tokenizer = AutoTokenizer.from_pretrained(self.embedding_model_name)
|
||||
self.embedding_model = AutoModel.from_pretrained(self.embedding_model_name).to(self.device)
|
||||
self.embedding_model.eval()
|
||||
return self._encode(texts, self.embedding_tokenizer, self.embedding_model)
|
||||
|
||||
def _encode(self, texts: list[str], tokenizer: Any, model: Any) -> np.ndarray:
|
||||
batches: list[np.ndarray] = []
|
||||
batch_size = 16
|
||||
for start in range(0, len(texts), batch_size):
|
||||
batch = texts[start : start + batch_size]
|
||||
encoded = tokenizer(
|
||||
batch,
|
||||
padding=True,
|
||||
truncation=True,
|
||||
max_length=512,
|
||||
return_tensors="pt",
|
||||
).to(self.device)
|
||||
with torch.no_grad():
|
||||
output = model(**encoded)
|
||||
pooled = self._mean_pool(output.last_hidden_state, encoded["attention_mask"])
|
||||
pooled = F.normalize(pooled, p=2, dim=1)
|
||||
batches.append(pooled.detach().cpu().numpy())
|
||||
return np.vstack(batches)
|
||||
|
||||
@staticmethod
|
||||
def _mean_pool(last_hidden_state: torch.Tensor, attention_mask: torch.Tensor) -> torch.Tensor:
|
||||
mask = attention_mask.unsqueeze(-1).expand(last_hidden_state.size()).float()
|
||||
summed = torch.sum(last_hidden_state * mask, dim=1)
|
||||
counts = torch.clamp(mask.sum(dim=1), min=1e-9)
|
||||
return summed / counts
|
||||
|
||||
@staticmethod
|
||||
def _match_space(selected: str, current: str) -> bool:
|
||||
selected = (selected or "general").strip().lower()
|
||||
current = (current or "general").strip().lower()
|
||||
if selected in {"", "general", "all"}:
|
||||
return True
|
||||
return current in {selected, "general"}
|
||||
|
||||
@staticmethod
|
||||
def _build_snippet(content: str, query: str) -> str:
|
||||
text = " ".join(content.split())
|
||||
if not text:
|
||||
return ""
|
||||
terms = [term for term in query.replace(",", " ").replace("。", " ").split() if term]
|
||||
hit = next((term for term in terms if term in text), "")
|
||||
if not hit:
|
||||
return text[:180]
|
||||
pos = text.find(hit)
|
||||
start = max(0, pos - 60)
|
||||
end = min(len(text), pos + 120)
|
||||
return text[start:end]
|
||||
|
||||
|
||||
engine = KnowledgeEngine()
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict[str, Any]:
|
||||
return {"status": "ok", "data": engine.health()}
|
||||
|
||||
|
||||
@app.post("/classify")
|
||||
def classify(req: ClassifyRequest) -> dict[str, Any]:
|
||||
try:
|
||||
return {"data": engine.classify(req.query)}
|
||||
except Exception as exc: # pragma: no cover - runtime guard
|
||||
raise HTTPException(status_code=500, detail=f"classify failed: {exc}") from exc
|
||||
|
||||
|
||||
@app.post("/search")
|
||||
def search(req: SearchRequest) -> dict[str, Any]:
|
||||
try:
|
||||
return {"data": {"items": engine.search(req.query, req.space_key, max(1, min(req.top_k, 20))) }}
|
||||
except Exception as exc: # pragma: no cover - runtime guard
|
||||
raise HTTPException(status_code=500, detail=f"search failed: {exc}") from exc
|
||||
|
||||
|
||||
@app.post("/index/rebuild")
|
||||
def rebuild_index(req: RebuildIndexRequest) -> dict[str, Any]:
|
||||
try:
|
||||
return {"data": engine.rebuild_index(req.index_dir, req.items)}
|
||||
except Exception as exc: # pragma: no cover - runtime guard
|
||||
raise HTTPException(status_code=500, detail=f"rebuild failed: {exc}") from exc
|
||||
@@ -0,0 +1,6 @@
|
||||
fastapi==0.116.1
|
||||
uvicorn==0.35.0
|
||||
numpy==2.3.2
|
||||
faiss-cpu==1.12.0
|
||||
torch==2.8.0
|
||||
transformers==4.55.4
|
||||
Reference in New Issue
Block a user