Files
DadaoTask/backend/app/models_knowledge.py
T
dadao d079564d6c
CI / backend (push) Canceled after 0s
feat(knowledge): 语义检索打通——切片、混合打分、只搜已发布
之前知识只能"存",不能"找"。这一版把链路补上:切片 → 向量 → 打分 → 带来源返回。

- chunk 表 + 迁移 0006:正文按标题归段切、代码块整块保留、超长按句切、
  相邻片重叠 80 字;RLS 与 GRANT 与其它租户表一致
- 向量提供方做成协议:内置 hash(字符二元组,零依赖可复现,作为基线)
  与 OpenAI 兼容端点两种,换实现不动业务代码
- 检索是混合打分:向量 0.6 + pg_trgm 0.4。专有编号、单号这类必须原样命中的词
  靠词法兜,换个说法表达的同一件事靠向量兜
- 三条纪律:只搜 published(过滤写在 SQL 里)、低于阈值宁可不返回、
  每条命中都带来源标题与 item_id
- 发布时自动建索引,改完正文用 POST /knowledge/{id}/reindex 重建
- Web 知识页加检索框与命中卡片;新增 e2e/retrieval.mjs 6 项

为什么不是 pgvector:本机 Docker 起不来、fnsvr-01 没有该扩展且明确不动。
先用 real[] + SQL 余弦把链路跑通,将来换列类型与索引即可,接口不变。

后端 79 passed,e2e smoke/knowledge/richtext/retrieval 全绿。
2026-10-29 04:28:09 +08:00

113 lines
5.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""知识域模型(M3):knowledge_doc。
三条设计约束(对应 PRODUCT-PLAN 的 D1/D3):
1. **知识也是 `item`**。标题、正文、标签、评论、事件流全部复用;`knowledge_doc`
只补"从哪来的、是什么类型、什么时候被人认可过"。同一套列表、搜索、详情、权限对知识依然成立。
2. **知识必须有来源**(D3)。`source_kind` 为 `task` / `ticket` 时必须给出 `source_item_id`,
只有 `manual`(自己记的笔记)允许留空。这条写在数据库 CHECK 里,不靠 API 自律——
将来接 LLM 自动归档时,没有来源的知识等于"没人能核对的断言",比没有更糟。
3. **状态机是 `draft → in_review → published → archived`**。草稿可以随时改,发布的要有
人来点头(`published_at` 记时间),归档的不删只藏(历史结论不该消失,只该退出检索)。
"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import (
CheckConstraint,
DateTime,
Float,
ForeignKey,
Index,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import ARRAY
from sqlalchemy.dialects.postgresql import UUID as PgUUID
from sqlalchemy.orm import Mapped, mapped_column
from .models import Base, Timestamps, new_uuid
# 知识状态机(与任务/工单共用 item.status 一列,取值集合不同)
KNOWLEDGE_STATUSES: tuple[str, ...] = ("draft", "in_review", "published", "archived")
DOC_TYPES: tuple[str, ...] = ("note", "howto", "faq", "postmortem", "decision")
SOURCE_KINDS: tuple[str, ...] = ("manual", "task", "ticket")
GENERATED_BY: tuple[str, ...] = ("human", "rule")
class KnowledgeDoc(Base, Timestamps):
"""知识的附加信息,与 item 一比一。"""
__tablename__ = "knowledge_doc"
__table_args__ = (
Index("ix_knowledge_ws_status", "workspace_id", "status_hint"),
Index("ix_knowledge_ws_source", "workspace_id", "source_item_id"),
CheckConstraint(
"source_kind = 'manual' OR source_item_id IS NOT NULL",
name="ck_knowledge_source_required",
),
CheckConstraint("chunk_count >= 0", name="ck_knowledge_chunk_count"),
)
# item_id 既是主键又是外键:一条 item 最多一条 knowledge_doc,天然 1:1
item_id: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="CASCADE"), primary_key=True
)
workspace_id: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False
)
author_id: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="RESTRICT"), nullable=False
)
# 知识的来源。NULL 只允许出现在 source_kind='manual' 上(见 CHECK)
source_item_id: Mapped[uuid.UUID | None] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="SET NULL")
)
source_kind: Mapped[str] = mapped_column(String(16), nullable=False, default="manual")
doc_type: Mapped[str] = mapped_column(String(16), nullable=False, default="note")
# 冗余一份状态:列表页要按知识自己的口径筛,不想每次都回 item 表
status_hint: Mapped[str] = mapped_column(String(16), nullable=False, default="draft")
summary: Mapped[str] = mapped_column(Text, nullable=False, default="")
generated_by: Mapped[str] = mapped_column(String(16), nullable=False, default="human")
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
chunk_count: Mapped[int] = mapped_column(nullable=False, default=0)
class Chunk(Base):
"""知识正文的切片。
为什么切片与文档同库:检索要按工作区过滤,跨库做权限校验就得把两边数据捞到应用里,
既不安全也不快。向量本身是普通数组,换 pgvector 时只换列类型与索引。
"""
__tablename__ = "chunk"
__table_args__ = (
UniqueConstraint("item_id", "idx", name="uq_chunk_item_idx"),
Index("ix_chunk_ws_item", "workspace_id", "item_id"),
{"comment": "知识正文切片,检索的最小单位"},
)
id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid)
workspace_id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), nullable=False)
item_id: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="CASCADE"), nullable=False
)
# 片内顺序,用于把命中片段按原顺序还原上下文
idx: Mapped[int] = mapped_column(nullable=False)
heading: Mapped[str | None] = mapped_column(String(200))
content: Mapped[str] = mapped_column(Text, nullable=False)
char_count: Mapped[int] = mapped_column(nullable=False, default=0)
# 向量。hash 提供方是 256 维,OpenAI 兼容端点通常是 1024/1536 维,
# 所以不固定维度——换模型时老切片要么重算,要么维度不匹配(检索会拒绝并提示重建)。
embedding: Mapped[list[float] | None] = mapped_column(ARRAY(Float))
embed_model: Mapped[str | None] = mapped_column(String(64))
KNOWLEDGE_TENANT_TABLES: tuple[str, ...] = ("knowledge_doc", "chunk")