Enhance knowledge base functionality and integrate S3 storage support
- Add new models for `KnowledgeDocument` and `KnowledgeChunk` to manage document ingestion and chunking. - Implement S3-compatible storage integration for knowledge documents, allowing for file uploads and retrieval. - Introduce API endpoints for managing knowledge bases and documents, including creation, deletion, and searching. - Update frontend components to support knowledge base configuration and document management, improving user interaction. - Enhance backend services for knowledge processing and retrieval, ensuring robust handling of document statuses and errors.
This commit is contained in:
@@ -137,6 +137,7 @@ async def resolve_runtime_config(
|
||||
enableInterrupt=assistant.enable_interrupt,
|
||||
turnConfig=assistant.turn_config or {},
|
||||
tools=await _tools_for(session, assistant),
|
||||
knowledge_base_id=assistant.knowledge_base_id,
|
||||
# workflow 图:仅 workflow 类型非空,引擎据此启用图驱动对话
|
||||
graph=(assistant.graph or {}) if assistant.type == "workflow" else {},
|
||||
# 外部托管类型连接信息(DB 存真 key,直接注入)
|
||||
|
||||
235
backend/services/knowledge.py
Normal file
235
backend/services/knowledge.py
Normal file
@@ -0,0 +1,235 @@
|
||||
"""Minimal knowledge ingestion and retrieval, isolated from the voice pipeline."""
|
||||
|
||||
import asyncio
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
import re
|
||||
import uuid
|
||||
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
from botocore.exceptions import ClientError
|
||||
from docx import Document as DocxDocument
|
||||
from openai import AsyncOpenAI
|
||||
from pypdf import PdfReader
|
||||
from sqlalchemy import delete, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
import settings
|
||||
from db.models import KnowledgeBase, KnowledgeChunk, KnowledgeDocument, ModelResource
|
||||
|
||||
|
||||
def _s3_client():
|
||||
return boto3.client(
|
||||
"s3",
|
||||
endpoint_url=settings.S3_ENDPOINT_URL,
|
||||
aws_access_key_id=settings.S3_ACCESS_KEY,
|
||||
aws_secret_access_key=settings.S3_SECRET_KEY,
|
||||
region_name=settings.S3_REGION,
|
||||
config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
|
||||
)
|
||||
|
||||
|
||||
def _ensure_bucket_and_put(key: str, data: bytes, mime_type: str) -> None:
|
||||
client = _s3_client()
|
||||
try:
|
||||
client.head_bucket(Bucket=settings.S3_BUCKET)
|
||||
except ClientError as exc:
|
||||
status = exc.response.get("ResponseMetadata", {}).get("HTTPStatusCode")
|
||||
if status != 404:
|
||||
raise
|
||||
client.create_bucket(Bucket=settings.S3_BUCKET)
|
||||
client.put_object(Bucket=settings.S3_BUCKET, Key=key, Body=data, ContentType=mime_type)
|
||||
|
||||
|
||||
def _delete_object(key: str) -> None:
|
||||
_s3_client().delete_object(Bucket=settings.S3_BUCKET, Key=key)
|
||||
|
||||
|
||||
def _get_object(key: str) -> bytes:
|
||||
response = _s3_client().get_object(Bucket=settings.S3_BUCKET, Key=key)
|
||||
return response["Body"].read()
|
||||
|
||||
|
||||
def extract_text(filename: str, data: bytes) -> str:
|
||||
suffix = Path(filename).suffix.lower()
|
||||
if suffix == ".pdf":
|
||||
return "\n".join(page.extract_text() or "" for page in PdfReader(BytesIO(data)).pages)
|
||||
if suffix == ".docx":
|
||||
document = DocxDocument(BytesIO(data))
|
||||
return "\n".join(paragraph.text for paragraph in document.paragraphs)
|
||||
if suffix in {".txt", ".md", ".csv", ".json", ".html", ".htm"}:
|
||||
return data.decode("utf-8", errors="replace")
|
||||
raise ValueError("暂仅支持 PDF、DOCX、TXT、Markdown、CSV、JSON 和 HTML 文件")
|
||||
|
||||
|
||||
def split_text(text: str, chunk_size: int = 800, overlap: int = 120) -> list[str]:
|
||||
normalized = re.sub(r"[ \t]+", " ", text).strip()
|
||||
if not normalized:
|
||||
return []
|
||||
chunks: list[str] = []
|
||||
start = 0
|
||||
while start < len(normalized):
|
||||
end = min(start + chunk_size, len(normalized))
|
||||
if end < len(normalized):
|
||||
boundary = max(normalized.rfind("\n", start, end), normalized.rfind("。", start, end))
|
||||
if boundary > start + chunk_size // 2:
|
||||
end = boundary + 1
|
||||
chunks.append(normalized[start:end].strip())
|
||||
if end >= len(normalized):
|
||||
break
|
||||
start = max(end - overlap, start + 1)
|
||||
return [chunk for chunk in chunks if chunk]
|
||||
|
||||
|
||||
async def _embedding_resource(session: AsyncSession, kb: KnowledgeBase) -> ModelResource:
|
||||
resource = (
|
||||
await session.get(ModelResource, kb.embedding_model_resource_id)
|
||||
if kb.embedding_model_resource_id
|
||||
else None
|
||||
)
|
||||
if not resource or resource.capability != "Embedding" or not resource.enabled:
|
||||
raise ValueError("请先为知识库选择已启用的 Embedding 模型")
|
||||
return resource
|
||||
|
||||
|
||||
async def _embed(session: AsyncSession, kb: KnowledgeBase, texts: list[str]) -> list[list[float]]:
|
||||
resource = await _embedding_resource(session, kb)
|
||||
values, secrets = resource.values or {}, resource.secrets or {}
|
||||
client = AsyncOpenAI(
|
||||
api_key=str(secrets.get("apiKey") or ""),
|
||||
base_url=str(values.get("apiUrl") or "") or None,
|
||||
)
|
||||
try:
|
||||
response = await client.embeddings.create(
|
||||
model=str(values.get("modelId") or "text-embedding-3-small"), input=texts
|
||||
)
|
||||
return [item.embedding for item in response.data]
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
async def create_document(
|
||||
session: AsyncSession,
|
||||
kb: KnowledgeBase,
|
||||
*,
|
||||
name: str,
|
||||
source_type: str,
|
||||
raw_data: bytes,
|
||||
mime_type: str = "text/plain",
|
||||
) -> KnowledgeDocument:
|
||||
document_id = f"doc_{uuid.uuid4().hex[:12]}"
|
||||
safe_name = Path(name).name
|
||||
extension = ".txt" if source_type == "text" else Path(safe_name).suffix
|
||||
storage_key = f"knowledge/{kb.id}/{document_id}/source{extension}"
|
||||
await asyncio.to_thread(_ensure_bucket_and_put, storage_key, raw_data, mime_type)
|
||||
|
||||
document = KnowledgeDocument(
|
||||
id=document_id,
|
||||
knowledge_base_id=kb.id,
|
||||
name=name,
|
||||
source_type=source_type,
|
||||
mime_type=mime_type,
|
||||
storage_key=storage_key,
|
||||
size_bytes=len(raw_data),
|
||||
status="pending",
|
||||
)
|
||||
session.add(document)
|
||||
await session.commit()
|
||||
await session.refresh(document)
|
||||
return document
|
||||
|
||||
|
||||
async def process_document(document_id: str) -> None:
|
||||
"""Process one persisted source. Safe to call again after a failure."""
|
||||
from db.session import SessionLocal
|
||||
|
||||
async with SessionLocal() as session:
|
||||
document = await session.get(KnowledgeDocument, document_id)
|
||||
if not document:
|
||||
return
|
||||
kb = await session.get(KnowledgeBase, document.knowledge_base_id)
|
||||
if not kb or not document.storage_key:
|
||||
return
|
||||
document.status = "processing"
|
||||
document.error_message = ""
|
||||
document.chunk_count = 0
|
||||
await session.execute(delete(KnowledgeChunk).where(KnowledgeChunk.document_id == document.id))
|
||||
await session.commit()
|
||||
|
||||
try:
|
||||
data = await asyncio.to_thread(_get_object, document.storage_key)
|
||||
text = (
|
||||
data.decode("utf-8", errors="replace")
|
||||
if document.source_type == "text"
|
||||
else await asyncio.to_thread(extract_text, document.name, data)
|
||||
)
|
||||
chunks = split_text(text)
|
||||
if not chunks:
|
||||
raise ValueError("文档中没有可入库的文字")
|
||||
embeddings: list[list[float]] = []
|
||||
for start in range(0, len(chunks), 64):
|
||||
embeddings.extend(await _embed(session, kb, chunks[start : start + 64]))
|
||||
if len(embeddings) != len(chunks):
|
||||
raise ValueError("Embedding 服务返回的向量数量与分块数量不一致")
|
||||
for index, (content, embedding) in enumerate(zip(chunks, embeddings)):
|
||||
session.add(KnowledgeChunk(
|
||||
id=f"chunk_{uuid.uuid4().hex[:12]}", knowledge_base_id=kb.id,
|
||||
document_id=document.id, chunk_index=index, content=content, embedding=embedding,
|
||||
))
|
||||
document.chunk_count = len(chunks)
|
||||
document.status = "ready"
|
||||
await session.commit()
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
document = await session.get(KnowledgeDocument, document_id)
|
||||
if document:
|
||||
document.status = "failed"
|
||||
document.error_message = str(exc)[:2048]
|
||||
document.chunk_count = 0
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def recover_interrupted_documents() -> None:
|
||||
"""Make restart-interrupted work visible and retryable."""
|
||||
from db.session import SessionLocal
|
||||
|
||||
async with SessionLocal() as session:
|
||||
await session.execute(
|
||||
update(KnowledgeDocument)
|
||||
.where(KnowledgeDocument.status.in_(["pending", "processing"]))
|
||||
.values(status="failed", error_message="服务重启导致处理被中断,请点击重试")
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def search(session: AsyncSession, kb_id: str, query: str, top_k: int | None = None) -> list[dict]:
|
||||
kb = await session.get(KnowledgeBase, kb_id)
|
||||
if not kb:
|
||||
return []
|
||||
has_chunks = (await session.execute(
|
||||
select(KnowledgeChunk.id)
|
||||
.join(KnowledgeDocument, KnowledgeDocument.id == KnowledgeChunk.document_id)
|
||||
.where(KnowledgeChunk.knowledge_base_id == kb_id, KnowledgeDocument.status == "ready")
|
||||
.limit(1)
|
||||
)).scalar_one_or_none()
|
||||
if not has_chunks:
|
||||
return []
|
||||
query_embedding = (await _embed(session, kb, [query]))[0]
|
||||
distance = KnowledgeChunk.embedding.cosine_distance(query_embedding)
|
||||
rows = (await session.execute(
|
||||
select(KnowledgeChunk, KnowledgeDocument.name, distance.label("distance"))
|
||||
.join(KnowledgeDocument, KnowledgeDocument.id == KnowledgeChunk.document_id)
|
||||
.where(KnowledgeChunk.knowledge_base_id == kb_id, KnowledgeDocument.status == "ready")
|
||||
.order_by(distance)
|
||||
.limit(top_k or settings.KNOWLEDGE_TOP_K)
|
||||
)).all()
|
||||
return [
|
||||
{"content": chunk.content, "document": name, "score": round(max(0.0, 1.0 - float(dist)), 4)}
|
||||
for chunk, name, dist in rows
|
||||
]
|
||||
|
||||
|
||||
async def delete_storage_object(document: KnowledgeDocument) -> None:
|
||||
if document.storage_key:
|
||||
await asyncio.to_thread(_delete_object, document.storage_key)
|
||||
@@ -27,6 +27,8 @@ from services.pipecat.service_factory import (
|
||||
create_stt,
|
||||
create_tts,
|
||||
)
|
||||
from db.session import SessionLocal
|
||||
from services.knowledge import search as search_knowledge
|
||||
|
||||
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
||||
from pipecat.adapters.schemas.tools_schema import ToolsSchema
|
||||
@@ -36,6 +38,7 @@ from pipecat.frames.frames import (
|
||||
InterruptionFrame,
|
||||
LLMFullResponseEndFrame,
|
||||
LLMFullResponseStartFrame,
|
||||
LLMContextFrame,
|
||||
LLMTextFrame,
|
||||
LLMMessagesAppendFrame,
|
||||
OutputTransportMessageUrgentFrame,
|
||||
@@ -79,6 +82,12 @@ VISION_ANALYSIS_SYSTEM_PROMPT = (
|
||||
"你是一个视觉理解模型。请只根据图片内容和用户问题给出准确、简洁的中文观察结果。"
|
||||
"如果画面不足以判断,请明确说明不确定。"
|
||||
)
|
||||
KNOWLEDGE_TOOL_NAME = "search_knowledge_base"
|
||||
KNOWLEDGE_SYSTEM_HINT = (
|
||||
"你已连接内部知识库。系统会在每轮用户问题前自动提供相关资料;"
|
||||
"回答资料事实时只根据检索内容,资料不足要明确说明。"
|
||||
)
|
||||
KNOWLEDGE_CONTEXT_MARKER = "<!-- knowledge-context -->"
|
||||
|
||||
|
||||
def _require(value: str, label: str) -> str:
|
||||
@@ -309,6 +318,54 @@ class ConversationHistoryProcessor(FrameProcessor):
|
||||
await self._recorder.record_transport_message(frame.message)
|
||||
|
||||
|
||||
class KnowledgeRetrievalProcessor(FrameProcessor):
|
||||
"""Retrieve before local LLM inference without changing Pipecat internals."""
|
||||
|
||||
def __init__(self, knowledge_base_id: str | None):
|
||||
super().__init__()
|
||||
self._knowledge_base_id = knowledge_base_id
|
||||
self._last_signature = ""
|
||||
|
||||
async def process_frame(self, frame, direction: FrameDirection):
|
||||
await super().process_frame(frame, direction)
|
||||
if not self._knowledge_base_id or not isinstance(frame, LLMContextFrame):
|
||||
await self.push_frame(frame, direction)
|
||||
return
|
||||
|
||||
messages = frame.context.get_messages()
|
||||
user_messages = [message for message in messages if message.get("role") == "user"]
|
||||
if not user_messages:
|
||||
await self.push_frame(frame, direction)
|
||||
return
|
||||
query = str(user_messages[-1].get("content") or "").strip()
|
||||
signature = f"{len(user_messages)}:{query}"
|
||||
if not query or signature == self._last_signature:
|
||||
await self.push_frame(frame, direction)
|
||||
return
|
||||
self._last_signature = signature
|
||||
|
||||
try:
|
||||
async with SessionLocal() as session:
|
||||
results = await search_knowledge(session, self._knowledge_base_id, query)
|
||||
except Exception as exc:
|
||||
logger.warning(f"自动知识库检索失败: {exc}")
|
||||
results = []
|
||||
|
||||
sources = "\n\n".join(
|
||||
f"[{index + 1}] 来源:{item['document']}(相关度 {item['score']})\n{item['content']}"
|
||||
for index, item in enumerate(results)
|
||||
) or "未检索到相关资料。"
|
||||
block = f"{KNOWLEDGE_CONTEXT_MARKER}\n当前问题的知识库检索结果:\n{sources}"
|
||||
system_message = next((message for message in messages if message.get("role") == "system"), None)
|
||||
if system_message is None:
|
||||
messages.insert(0, {"role": "system", "content": block})
|
||||
else:
|
||||
content = str(system_message.get("content") or "")
|
||||
base = content.split(KNOWLEDGE_CONTEXT_MARKER, 1)[0].rstrip()
|
||||
system_message["content"] = f"{base}\n\n{block}" if base else block
|
||||
await self.push_frame(frame, direction)
|
||||
|
||||
|
||||
class PassthroughLLMAssistantAggregator(LLMAssistantAggregator):
|
||||
"""聚合 LLM 回复进上下文,同时继续把回复帧交给下游 TTS。"""
|
||||
|
||||
@@ -432,11 +489,12 @@ async def run_pipeline(
|
||||
call_end = CallEndCoordinator(queue_call_end)
|
||||
|
||||
def with_vision_hint(text: str) -> str:
|
||||
if not vision_enabled:
|
||||
return text
|
||||
if not text:
|
||||
return VISION_SYSTEM_HINT
|
||||
return f"{text}\n\n{VISION_SYSTEM_HINT}"
|
||||
hints = []
|
||||
if vision_enabled:
|
||||
hints.append(VISION_SYSTEM_HINT)
|
||||
if cfg.knowledge_base_id:
|
||||
hints.append(KNOWLEDGE_SYSTEM_HINT)
|
||||
return "\n\n".join(part for part in [text, *hints] if part)
|
||||
|
||||
context = LLMContext(
|
||||
messages=[{"role": "system", "content": with_vision_hint(system_content)}]
|
||||
@@ -460,6 +518,7 @@ async def run_pipeline(
|
||||
assistant_aggregator = PassthroughLLMAssistantAggregator(context)
|
||||
text_input = TextInputProcessor(should_ignore_input=lambda: call_end.ending)
|
||||
vision_capture = VisionCaptureProcessor()
|
||||
knowledge_retrieval = KnowledgeRetrievalProcessor(cfg.knowledge_base_id)
|
||||
vision_native_mode = vision_enabled and _vision_uses_main_llm(cfg)
|
||||
vision_state: dict[str, str | None] = {"client_id": None}
|
||||
vision_schema = FunctionSchema(
|
||||
@@ -476,6 +535,27 @@ async def run_pipeline(
|
||||
},
|
||||
required=["question"],
|
||||
)
|
||||
knowledge_schema = FunctionSchema(
|
||||
name=KNOWLEDGE_TOOL_NAME,
|
||||
description="在当前助手绑定的知识库中检索与问题最相关的资料片段。",
|
||||
properties={
|
||||
"query": {"type": "string", "description": "用于检索的完整问题或关键词"}
|
||||
},
|
||||
required=["query"],
|
||||
)
|
||||
|
||||
async def search_bound_knowledge(params: FunctionCallParams):
|
||||
query = str(params.arguments.get("query") or "").strip()
|
||||
if not query or not cfg.knowledge_base_id:
|
||||
await params.result_callback({"status": "error", "message": "检索问题为空或未绑定知识库"})
|
||||
return
|
||||
try:
|
||||
async with SessionLocal() as session:
|
||||
results = await search_knowledge(session, cfg.knowledge_base_id, query)
|
||||
await params.result_callback({"status": "ok", "results": results})
|
||||
except Exception as exc:
|
||||
logger.exception(f"知识库检索失败: {exc}")
|
||||
await params.result_callback({"status": "error", "message": "知识库检索暂时不可用"})
|
||||
|
||||
async def fetch_user_image(params: FunctionCallParams):
|
||||
question = str(params.arguments.get("question") or "请描述当前画面。")
|
||||
@@ -538,11 +618,15 @@ async def run_pipeline(
|
||||
|
||||
if vision_enabled:
|
||||
llm.register_function(VISION_TOOL_NAME, fetch_user_image)
|
||||
if cfg.knowledge_base_id:
|
||||
llm.register_function(KNOWLEDGE_TOOL_NAME, search_bound_knowledge)
|
||||
|
||||
def set_visible_tools(schemas: list[FunctionSchema] | None = None) -> None:
|
||||
tools = list(schemas or [])
|
||||
if vision_enabled:
|
||||
tools.append(vision_schema)
|
||||
if cfg.knowledge_base_id:
|
||||
tools.append(knowledge_schema)
|
||||
if tools:
|
||||
context.set_tools(ToolsSchema(standard_tools=tools))
|
||||
else:
|
||||
@@ -561,6 +645,7 @@ async def run_pipeline(
|
||||
text_input,
|
||||
stt,
|
||||
user_aggregator,
|
||||
knowledge_retrieval,
|
||||
llm,
|
||||
# Aggregate the streamed LLM text before TTS. On interruption,
|
||||
# Pipecat commits the generated prefix immediately instead of
|
||||
|
||||
Reference in New Issue
Block a user