Files
2026-07-27 19:01:19 +08:00

545 lines
19 KiB
Python
Raw Permalink 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.
"""知识索引与混合检索服务。
生产环境组合 PostgreSQL 全文、pgvector、结构化范围和可选重排;非 PostgreSQL 环境仅用于
测试与本地兼容验证。
"""
import math
import re
from django.conf import settings
from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVector
from django.db import connection, transaction
from django.db.models import Q
from django.utils import timezone
from pgvector.django import CosineDistance
from apps.knowledge.embeddings import embed_contents, hash_embedding
from apps.knowledge.models import (
GovernedAssetType,
KnowledgeBase,
KnowledgeChunk,
KnowledgeDocument,
KnowledgeItem,
TroubleshootingPlan,
)
from apps.knowledge.reranking import rerank_candidates
from apps.solutions.models import Solution
DEFAULT_CHUNK_SIZE = 600
def split_text(content: str, chunk_size: int = DEFAULT_CHUNK_SIZE) -> list[str]:
normalized = "\n".join(line.strip() for line in content.splitlines() if line.strip())
if not normalized:
return []
return [
normalized[index : index + chunk_size]
for index in range(0, len(normalized), chunk_size)
]
def embed_text(content: str, dimensions: int | None = None) -> list[float]:
return hash_embedding(content, dimensions)
def _terms(value: str) -> set[str]:
normalized = value.casefold()
words = set(re.findall(r"[a-z0-9][a-z0-9_.:-]{1,}|[\u4e00-\u9fff]{2,}", normalized))
chinese = "".join(re.findall(r"[\u4e00-\u9fff]", normalized))
words.update(chinese[index : index + 2] for index in range(max(len(chinese) - 1, 0)))
return {term for term in words if term}
def keyword_score(query: str, content: str) -> float:
query_terms = _terms(query)
content_terms = _terms(content)
if not query_terms or not content_terms:
return 0.0
return len(query_terms & content_terms) / len(query_terms)
def vector_score(left: list[float], right: list[float]) -> float:
if not left or not right:
return 0.0
left_length = math.sqrt(sum(value * value for value in left)) or 1.0
right_length = math.sqrt(sum(value * value for value in right)) or 1.0
return float(
sum(a * b for a, b in zip(left, right, strict=False))
/ (left_length * right_length)
)
def _create_chunks(
*,
knowledge_base: KnowledgeBase,
texts: list[str],
metadata: dict,
item: KnowledgeItem | None = None,
troubleshooting_plan: TroubleshootingPlan | None = None,
document: KnowledgeDocument | None = None,
historical_ticket=None,
replace_queryset=None,
) -> list[KnowledgeChunk]:
batch = embed_contents(texts)
with transaction.atomic():
if replace_queryset is not None:
replace_queryset.delete()
return [
KnowledgeChunk.objects.create(
knowledge_base=knowledge_base,
item=item,
troubleshooting_plan=troubleshooting_plan,
document=document,
historical_ticket=historical_ticket,
chunk_index=index,
content=chunk_text,
embedding=vector,
embedding_vector=vector,
embedding_model=batch.model_name,
metadata={
**metadata,
"embedding_deployment_id": batch.deployment_id,
"embedding_fallback": batch.fallback,
},
)
for index, (chunk_text, vector) in enumerate(
zip(texts, batch.vectors, strict=True)
)
]
def rebuild_item_chunks(item: KnowledgeItem) -> list[KnowledgeChunk]:
content = "\n".join(
[
item.title,
item.problem,
item.applicable_scope,
item.category_code,
item.related_system,
item.environment_scope,
item.error_code,
item.root_cause,
item.troubleshooting_steps,
item.solution,
item.risk_note,
]
)
return _create_chunks(
knowledge_base=item.knowledge_base,
item=item,
texts=split_text(content),
metadata={
"source_type": "knowledge_item",
"title": item.title,
"item_id": item.id,
"category_code": item.category_code,
"related_system": item.related_system,
"environment": item.environment_scope,
},
replace_queryset=item.chunks.all(),
)
def rebuild_troubleshooting_plan_chunks(
plan: TroubleshootingPlan,
) -> list[KnowledgeChunk]:
content = "\n".join(
[
plan.title,
plan.problem_pattern,
plan.applicable_scope,
plan.category_code,
plan.related_system,
plan.environment_scope,
plan.decision_rules,
plan.troubleshooting_steps,
plan.risk_note,
]
)
return _create_chunks(
knowledge_base=plan.knowledge_base,
troubleshooting_plan=plan,
texts=split_text(content),
metadata={
"source_type": "troubleshooting_plan",
"title": plan.title,
"troubleshooting_plan_id": plan.id,
"category_code": plan.category_code,
"related_system": plan.related_system,
"environment": plan.environment_scope,
},
replace_queryset=plan.chunks.all(),
)
def ingest_document_text(
knowledge_base_id: int,
file_name: str,
file_type: str,
content: str,
operator=None,
) -> KnowledgeDocument:
document = KnowledgeDocument.objects.create(
knowledge_base_id=knowledge_base_id,
file_name=file_name,
file_type=file_type,
content_text=content,
scan_status="not_applicable",
parse_status="success",
created_by=operator,
)
rebuild_document_chunks(document)
document.indexed_at = timezone.now()
document.save(update_fields=["indexed_at", "updated_at"])
return document
def rebuild_document_chunks(document: KnowledgeDocument) -> list[KnowledgeChunk]:
return _create_chunks(
knowledge_base=document.knowledge_base,
document=document,
texts=split_text(document.content_text),
metadata={
"source_type": "document",
"title": document.file_name,
"document_id": document.id,
},
replace_queryset=document.chunks.all(),
)
def index_published_solution(solution: Solution) -> list[KnowledgeChunk]:
base, _ = KnowledgeBase.objects.get_or_create(
name="解决方案知识库",
defaults={"description": "从已发布解决方案生成的检索知识", "scope": "solutions"},
)
content = "\n".join(
[
solution.title,
solution.problem_signature,
solution.related_system,
solution.environment_scope,
solution.root_cause,
solution.solution,
solution.risk_note,
]
)
return _create_chunks(
knowledge_base=base,
texts=split_text(content),
metadata={
"source_type": "solution",
"title": solution.title,
"solution_id": solution.id,
"solution_version": solution.updated_at.isoformat(),
"category_code": solution.category.code if solution.category_id else "",
"related_system": solution.related_system,
"environment": solution.environment_scope,
},
replace_queryset=KnowledgeChunk.objects.filter(metadata__solution_id=solution.id),
)
def index_resolved_ticket(ticket) -> list[KnowledgeChunk]:
if ticket.status not in {"resolved", "closed"} or ticket.archived_at is not None:
KnowledgeChunk.objects.filter(metadata__historical_ticket_id=ticket.id).delete()
return []
base, _ = KnowledgeBase.objects.get_or_create(
name="已解决工单案例库",
defaults={
"description": "从已解决或已关闭工单生成的检索案例",
"scope": "historical_tickets",
},
)
resolution = getattr(ticket, "resolution", None)
content = "\n".join(
[
ticket.title,
ticket.description,
ticket.problem_performance,
ticket.error_information,
resolution.root_cause if resolution else "",
resolution.solution_text if resolution else "",
]
)
return _create_chunks(
knowledge_base=base,
historical_ticket=ticket,
texts=split_text(content),
metadata={
"source_type": "historical_ticket",
"title": ticket.title,
"historical_ticket_id": ticket.id,
"ticket_version": ticket.updated_at.isoformat(),
"category_code": ticket.category.code if ticket.category_id else "",
"related_system": ticket.related_system,
"environment": ticket.environment,
},
replace_queryset=KnowledgeChunk.objects.filter(
metadata__historical_ticket_id=ticket.id
),
)
def sync_ticket_search_index(ticket_id: int) -> int:
from apps.tickets.models import Ticket
ticket = (
Ticket.objects.select_related("category", "resolution")
.filter(id=ticket_id)
.first()
)
if ticket is None:
KnowledgeChunk.objects.filter(metadata__historical_ticket_id=ticket_id).delete()
return 0
return len(index_resolved_ticket(ticket))
def sync_governed_asset_index(asset_type: str, object_id: int) -> int:
if asset_type == GovernedAssetType.KNOWLEDGE_ITEM:
item = KnowledgeItem.objects.filter(id=object_id).first()
if item is None:
return 0
if item.status != "published":
item.chunks.all().delete()
return 0
return len(rebuild_item_chunks(item))
if asset_type == GovernedAssetType.TROUBLESHOOTING_PLAN:
plan = TroubleshootingPlan.objects.filter(id=object_id).first()
if plan is None:
return 0
if plan.status != "published":
plan.chunks.all().delete()
return 0
return len(rebuild_troubleshooting_plan_chunks(plan))
if asset_type == GovernedAssetType.SOLUTION:
solution = Solution.objects.filter(id=object_id).first()
if solution is None or solution.status != "published":
KnowledgeChunk.objects.filter(metadata__solution_id=object_id).delete()
return 0
return len(index_published_solution(solution))
raise ValueError("Unknown governed asset type")
def _base_retrieval_queryset(
knowledge_base_id: int | None,
published_only: bool,
scope: dict[str, str] | None,
):
queryset = KnowledgeChunk.objects.select_related(
"knowledge_base",
"item",
"troubleshooting_plan",
"document",
"historical_ticket",
).filter(knowledge_base__enabled=True)
if knowledge_base_id:
queryset = queryset.filter(knowledge_base_id=knowledge_base_id)
if published_only:
published_solution_ids = Solution.objects.filter(status="published").values_list(
"id",
flat=True,
)
queryset = queryset.filter(
Q(item__status="published")
| Q(troubleshooting_plan__status="published")
| Q(metadata__solution_id__in=list(published_solution_ids))
| Q(
historical_ticket__status__in=["resolved", "closed"],
historical_ticket__archived_at__isnull=True,
)
)
for key, value in (scope or {}).items():
normalized = value.strip()
if not normalized:
continue
queryset = queryset.filter(
Q(**{f"metadata__{key}__isnull": True})
| Q(**{f"metadata__{key}": ""})
| Q(**{f"metadata__{key}__iexact": normalized})
)
return queryset
def _structured_scope_score(metadata: dict, scope: dict[str, str] | None) -> float:
expected = {
key: value.strip().casefold()
for key, value in (scope or {}).items()
if value.strip()
}
if not expected:
return 1.0
scores = []
for key, value in expected.items():
candidate = str(metadata.get(key, "") or "").strip().casefold()
scores.append(1.0 if candidate == value else 0.5 if not candidate else 0.0)
return sum(scores) / len(scores)
def _postgres_candidates(queryset, query: str, query_vector: list[float], candidate_limit: int):
text_query = SearchQuery(query, search_type="websearch", config="simple")
text_rows = list(
queryset.annotate(
text_rank=SearchRank(SearchVector("content", config="simple"), text_query),
)
.filter(text_rank__gt=0)
.order_by("-text_rank")[:candidate_limit]
)
vector_rows = list(
queryset.filter(embedding_vector__isnull=False)
.annotate(vector_distance=CosineDistance("embedding_vector", query_vector))
.order_by("vector_distance")[:candidate_limit]
)
candidates: dict[int, dict] = {}
for chunk in text_rows:
candidates.setdefault(chunk.id, {"chunk": chunk, "text_score": 0.0, "vector_score": 0.0})
candidates[chunk.id]["text_score"] = max(min(float(chunk.text_rank), 1.0), 0.0)
for chunk in vector_rows:
candidates.setdefault(chunk.id, {"chunk": chunk, "text_score": 0.0, "vector_score": 0.0})
candidates[chunk.id]["vector_score"] = max(
min(1.0 - float(chunk.vector_distance), 1.0),
0.0,
)
return list(candidates.values())
def _portable_candidates(queryset, query: str, query_vector: list[float], candidate_limit: int):
candidates = []
for chunk in queryset[:1000]:
stored_vector = (
list(chunk.embedding_vector)
if chunk.embedding_vector is not None
else list(chunk.embedding or [])
)
text_value = keyword_score(query, chunk.content)
vector_value = max(vector_score(query_vector, stored_vector), 0.0)
if text_value > 0 or vector_value > 0:
candidates.append(
{
"chunk": chunk,
"text_score": text_value,
"vector_score": vector_value,
}
)
candidates.sort(
key=lambda item: (item["text_score"], item["vector_score"]),
reverse=True,
)
return candidates[: candidate_limit * 2]
def retrieve_knowledge(
query: str,
knowledge_base_id: int | None = None,
limit: int = 5,
*,
published_only: bool = False,
scope: dict[str, str] | None = None,
) -> list[dict]:
"""执行全文、向量、范围加权和可选模型重排,并返回完整检索审计元数据。"""
batch = embed_contents([query])
query_vector = batch.vectors[0]
queryset = _base_retrieval_queryset(knowledge_base_id, published_only, scope)
candidate_limit = max(limit * 10, 50)
if connection.vendor == "postgresql":
candidates = _postgres_candidates(queryset, query, query_vector, candidate_limit)
channels = ["postgresql_full_text", "pgvector_cosine"]
else:
# SQLite 路径只服务测试和本地兼容,不作为生产召回质量的验收证据。
candidates = _portable_candidates(queryset, query, query_vector, candidate_limit)
channels = ["portable_text", "portable_vector"]
text_weight = settings.KNOWLEDGE_RETRIEVAL_TEXT_WEIGHT
vector_weight = settings.KNOWLEDGE_RETRIEVAL_VECTOR_WEIGHT
scope_weight = settings.KNOWLEDGE_RETRIEVAL_SCOPE_WEIGHT
for candidate in candidates:
candidate["scope_score"] = _structured_scope_score(
candidate["chunk"].metadata,
scope,
)
candidate["score"] = min(
candidate["text_score"] * text_weight
+ candidate["vector_score"] * vector_weight
+ candidate["scope_score"] * scope_weight,
1.0,
)
candidates.sort(
key=lambda item: (item["score"], item["text_score"], item["vector_score"]),
reverse=True,
)
rerank_limit = min(
max(settings.KNOWLEDGE_RERANK_CANDIDATE_LIMIT, limit),
len(candidates),
)
rerank_input = [
{
"id": item["chunk"].id,
"title": item["chunk"].metadata.get(
"title",
item["chunk"].knowledge_base.name,
),
"content": item["chunk"].content,
"base_score": item["score"],
}
for item in candidates[:rerank_limit]
]
rerank = rerank_candidates(query, rerank_input)
model_weight = (
min(max(settings.KNOWLEDGE_RERANK_MODEL_WEIGHT, 0.0), 1.0)
if rerank.status == "succeeded"
else 0.0
)
for candidate in candidates:
candidate_id = candidate["chunk"].id
model_score = rerank.scores.get(candidate_id, candidate["score"])
candidate["rerank_score"] = model_score
candidate["score"] = (
candidate["score"] * (1.0 - model_weight) + model_score * model_weight
)
candidates.sort(
key=lambda item: (item["score"], item["text_score"], item["vector_score"]),
reverse=True,
)
return [
{
"id": item["chunk"].id,
"knowledge_base_id": item["chunk"].knowledge_base_id,
"title": item["chunk"].metadata.get(
"title",
item["chunk"].knowledge_base.name,
),
"source_type": item["chunk"].metadata.get("source_type", "chunk"),
"content": item["chunk"].content,
"score": round(item["score"], 4),
"metadata": {
**item["chunk"].metadata,
"retrieval": {
"channels": channels,
"text_score": round(item["text_score"], 4),
"vector_score": round(item["vector_score"], 4),
"scope_score": round(item["scope_score"], 4),
"weights": {
"text": text_weight,
"vector": vector_weight,
"scope": scope_weight,
},
"query_embedding_model": batch.model_name,
"query_embedding_deployment_id": batch.deployment_id,
"query_embedding_fallback": batch.fallback,
"chunk_embedding_model": item["chunk"].embedding_model,
"rerank": {
"status": rerank.status,
"model_name": rerank.model_name,
"deployment_id": rerank.deployment_id,
"model_score": round(item["rerank_score"], 4),
"model_weight": model_weight,
"candidate_limit": rerank_limit,
},
},
},
}
for item in candidates[:limit]
]