Files
deerflow-code/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/search.py
T
AdministratorandClaude Opus 4.8 cc99ebba81 feat: 产品内知识库(obsidian-wiki 兼容 vault)
后端:新增自包含 deerflow.knowledge 模块(models/repository/schemas/
extractor/sources/markdown_vault/manifest/obsidian_adapter/exporter/
search/service),通过 deps.py 装配为 app.state.knowledge_service,
挂载 /api/knowledge 路由;config.yaml 增加 knowledge 配置段,
AppConfig 注册 KnowledgeConfig,ORM 注册 4 张 knowledge_* 表。

前端:新增知识库页面(KnowledgeBasePage/KnowledgeGraphPage)、
core/knowledge api/hooks/types、聊天侧 knowledge-save-trigger,
侧边栏增加「知识库」入口。

知识库为全局共享,不做 user_id 隔离,写入记录 created_by/updated_by
作为审计。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:38:28 +08:00

91 lines
3.0 KiB
Python

"""Keyword / frontmatter / Markdown-content search (first release).
Vector and hybrid modes are reserved for phase 2 (see the dev doc §9.9 / §13);
for now ``vector`` and ``hybrid`` transparently fall back to keyword search so
the API contract is stable. Scoring weights title > tags > summary > body.
"""
from __future__ import annotations
import re
from typing import Any
from deerflow.knowledge.repository import KnowledgeRepository
_TOKEN_RE = re.compile(r"[\w一-鿿]+", re.UNICODE)
def _tokens(text: str) -> list[str]:
return [t.lower() for t in _TOKEN_RE.findall(text or "")]
def _snippet(content: str, terms: list[str], *, width: int = 160) -> str:
body = re.sub(r"\s+", " ", content or "").strip()
low = body.lower()
for term in terms:
idx = low.find(term)
if idx >= 0:
start = max(0, idx - width // 3)
end = min(len(body), start + width)
prefix = "…" if start > 0 else ""
suffix = "…" if end < len(body) else ""
return f"{prefix}{body[start:end]}{suffix}"
return body[:width] + ("…" if len(body) > width else "")
async def search_notes(
repo: KnowledgeRepository,
query: str,
*,
mode: str = "keyword",
limit: int = 8,
candidate_cap: int = 500,
) -> list[dict[str, Any]]:
"""Score notes against ``query`` and return the top ``limit`` matches."""
terms = _tokens(query)
if not terms:
return []
# Pull a candidate window (non-archived). Phase 2 will replace this with
# an index; for the first release in-Python scoring is sufficient.
candidates, _total = await repo.list_notes(limit=candidate_cap, offset=0)
scored: list[tuple[float, dict[str, Any]]] = []
for note in candidates:
title = (note.get("title") or "").lower()
summary = (note.get("summary") or "").lower()
content = (note.get("content_md") or "").lower()
tags = " ".join(note.get("tags") or []).lower()
score = 0.0
for term in terms:
if term in title:
score += 3.0
if term in tags:
score += 2.0
if term in summary:
score += 2.0
score += min(content.count(term), 5) * 0.5
if score <= 0:
continue
scored.append((score, note))
scored.sort(key=lambda x: x[0], reverse=True)
max_score = scored[0][0] if scored else 1.0
results = []
for score, note in scored[:limit]:
results.append(
{
"note_id": note["id"],
"chunk_id": None,
"title": note.get("title"),
"summary": note.get("summary"),
"snippet": _snippet(note.get("content_md") or note.get("summary") or "", terms),
"score": round(score / max_score, 4),
"tags": note.get("tags") or [],
"source_type": note.get("source_type"),
"updated_at": note.get("updated_at"),
"sources": [],
}
)
return results