mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
357 lines
16 KiB
Python
357 lines
16 KiB
Python
"""Indexing adapters that materialize database inputs before external I/O."""
|
|
|
|
import logging
|
|
from collections.abc import Callable, Mapping, Sequence
|
|
from uuid import uuid4
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from core.credit_usage import CreditUsageCreatedBy
|
|
from core.model_context import with_credit_usage_created_by
|
|
from core.model_manager import ModelManager
|
|
from core.rag.datasource.keyword.jieba.jieba import Jieba
|
|
from core.rag.datasource.vdb.vector_factory import Vector
|
|
from core.rag.entities import Rule
|
|
from core.rag.index_processor.constant.doc_type import DocType
|
|
from core.rag.index_processor.processor.paragraph_index_processor import ParagraphIndexProcessor
|
|
from core.rag.index_processor.processor.parent_child_index_processor import ParentChildIndexProcessor
|
|
from core.rag.models.document import Document
|
|
from graphon.model_runtime.entities.model_entities import ModelType
|
|
from libs.datetime_utils import naive_utc_now
|
|
from models.enums import SegmentType
|
|
from repositories.knowledge.segment_repository import SegmentIndexingSnapshot, SQLAlchemySegmentRepository
|
|
from repositories.knowledge.upload_file_repository import SQLAlchemyKnowledgeUploadRepository
|
|
from services.knowledge.entities.segments import ChildChunkRecord
|
|
from services.knowledge.resource_scope import DocumentRef, SegmentRef
|
|
from services.knowledge.segments.adapters import RedisSegmentClient
|
|
from services.knowledge.segments.application import ChildChunkState, SegmentDatasetRecord, SegmentIndexTarget
|
|
from services.knowledge.summaries.adapters import SummaryIndexAdapter
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class SegmentIndexingGateway:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
segments: SQLAlchemySegmentRepository,
|
|
uploads: SQLAlchemyKnowledgeUploadRepository,
|
|
redis: RedisSegmentClient,
|
|
new_session: Callable[[], Session],
|
|
delete_task: Callable[..., object],
|
|
enable_task: Callable[..., object],
|
|
disable_task: Callable[..., object],
|
|
) -> None:
|
|
self._segments = segments
|
|
self._uploads = uploads
|
|
self._redis = redis
|
|
self._new_session = new_session
|
|
self._delete_task = delete_task
|
|
self._enable_task = enable_task
|
|
self._disable_task = disable_task
|
|
|
|
def count_tokens(self, dataset: SegmentDatasetRecord, text: str) -> int:
|
|
if dataset.indexing_technique != "high_quality":
|
|
return 0
|
|
model = ModelManager.for_tenant(tenant_id=dataset.workspace_id).get_model_instance(
|
|
tenant_id=dataset.workspace_id,
|
|
provider=dataset.embedding_model_provider or "",
|
|
model_type=ModelType.TEXT_EMBEDDING,
|
|
model=dataset.embedding_model or "",
|
|
)
|
|
return model.get_text_embedding_num_tokens(texts=[text])[0]
|
|
|
|
@staticmethod
|
|
def _vector(snapshot: SegmentIndexingSnapshot) -> Vector:
|
|
return Vector(snapshot.dataset, session=None, vector_type=snapshot.vector_type)
|
|
|
|
@staticmethod
|
|
def _document(snapshot: SegmentIndexingSnapshot) -> Document:
|
|
segment = snapshot.segment
|
|
return Document(
|
|
page_content=segment.content,
|
|
metadata={
|
|
"doc_id": segment.index_node_id,
|
|
"doc_hash": segment.index_node_hash,
|
|
"document_id": segment.document_id,
|
|
"dataset_id": segment.dataset_id,
|
|
"doc_type": DocType.TEXT,
|
|
},
|
|
)
|
|
|
|
@with_credit_usage_created_by(CreditUsageCreatedBy.KNOWLEDGE_INDEXING)
|
|
def create_many(
|
|
self,
|
|
document_ref: DocumentRef,
|
|
*,
|
|
segment_ids: Sequence[str],
|
|
keywords_list: Sequence[Sequence[str] | None] | None,
|
|
) -> None:
|
|
"""Materialize the whole scoped batch before indexing any of its records."""
|
|
snapshots = self._segments.get_indexing_snapshots(document_ref, segment_ids)
|
|
if not snapshots:
|
|
return
|
|
first = snapshots[0]
|
|
documents = [self._document(snapshot) for snapshot in snapshots]
|
|
if first.document.doc_form == "hierarchical_model":
|
|
for snapshot in snapshots:
|
|
self._regenerate_children(document_ref.segment(snapshot.segment.id), snapshot, replace_existing=False)
|
|
elif first.dataset.indexing_technique == "high_quality":
|
|
self._vector(first).create(documents)
|
|
elif first.document.doc_form != "qa_model":
|
|
self._write_keywords(document_ref, first, documents, keywords_list, replace_existing=False)
|
|
if first.dataset.is_multimodal:
|
|
files = self._uploads.get_files(
|
|
workspace_id=document_ref.dataset.tenant_id,
|
|
file_ids=tuple(attachment_id for snapshot in snapshots for attachment_id in snapshot.attachment_ids),
|
|
)
|
|
attachments = [
|
|
document
|
|
for snapshot in snapshots
|
|
for document in self._attachment_documents(
|
|
snapshot, [files[file_id] for file_id in snapshot.attachment_ids if file_id in files]
|
|
)
|
|
]
|
|
if attachments and first.dataset.indexing_technique == "high_quality":
|
|
self._vector(first).create_multimodal(attachments, upload_files=files)
|
|
|
|
@with_credit_usage_created_by(CreditUsageCreatedBy.KNOWLEDGE_INDEXING)
|
|
def create(self, segment_ref: SegmentRef, *, keywords: Sequence[str] | None, attachment_ids: Sequence[str]) -> None:
|
|
snapshot = self._segments.get_indexing_snapshot(segment_ref)
|
|
files = self._uploads.get_files(workspace_id=segment_ref.document.dataset.tenant_id, file_ids=attachment_ids)
|
|
self._segments.replace_attachments(segment_ref, tuple(files))
|
|
if snapshot.document.doc_form == "hierarchical_model":
|
|
self._regenerate_children(segment_ref, snapshot, replace_existing=False)
|
|
elif snapshot.dataset.indexing_technique == "high_quality":
|
|
self._vector(snapshot).create([self._document(snapshot)])
|
|
else:
|
|
self._update_keywords(segment_ref, snapshot, keywords, replace_existing=False)
|
|
if files and snapshot.dataset.is_multimodal:
|
|
self._vector(snapshot).create_multimodal(
|
|
self._attachment_documents(snapshot, files.values()), upload_files=files
|
|
)
|
|
|
|
@with_credit_usage_created_by(CreditUsageCreatedBy.KNOWLEDGE_INDEXING)
|
|
def update(self, segment_ref: SegmentRef, *, keywords: Sequence[str] | None, regenerate_children: bool) -> None:
|
|
snapshot = self._segments.get_indexing_snapshot(segment_ref)
|
|
if regenerate_children:
|
|
self._regenerate_children(segment_ref, snapshot, replace_existing=True)
|
|
elif snapshot.dataset.indexing_technique == "high_quality":
|
|
vector = self._vector(snapshot)
|
|
assert snapshot.segment.index_node_id is not None
|
|
vector.delete_by_ids([snapshot.segment.index_node_id])
|
|
vector.add_texts([self._document(snapshot)], duplicate_check=True)
|
|
else:
|
|
self._update_keywords(segment_ref, snapshot, keywords, replace_existing=True)
|
|
|
|
def _regenerate_children(
|
|
self, segment_ref: SegmentRef, snapshot: SegmentIndexingSnapshot, *, replace_existing: bool
|
|
) -> None:
|
|
if snapshot.dataset.indexing_technique != "high_quality":
|
|
raise ValueError("The knowledge base index technique is not high quality!")
|
|
rule = snapshot.process_rule
|
|
if rule is None:
|
|
if replace_existing:
|
|
return
|
|
raise ValueError("No processing rule found.")
|
|
manager = ModelManager.for_tenant(tenant_id=snapshot.dataset.tenant_id)
|
|
model = (
|
|
manager.get_model_instance(
|
|
tenant_id=snapshot.dataset.tenant_id,
|
|
provider=snapshot.dataset.embedding_model_provider,
|
|
model_type=ModelType.TEXT_EMBEDDING,
|
|
model=snapshot.dataset.embedding_model,
|
|
)
|
|
if snapshot.dataset.embedding_model_provider
|
|
else manager.get_default_model_instance(
|
|
tenant_id=snapshot.dataset.tenant_id, model_type=ModelType.TEXT_EMBEDDING
|
|
)
|
|
)
|
|
children = ParentChildIndexProcessor().split_child_nodes(
|
|
self._document(snapshot), Rule.model_validate(rule.to_dict()["rules"]), rule.mode, model
|
|
)
|
|
previous = self._segments.get_children(segment_ref) or ()
|
|
vector = self._vector(snapshot)
|
|
if replace_existing and previous:
|
|
vector.delete_by_ids([child.index_node_id for child in previous if child.index_node_id])
|
|
if children:
|
|
vector.create([Document.model_validate(child.model_dump()) for child in children])
|
|
now = naive_utc_now()
|
|
added = tuple(
|
|
ChildChunkState(
|
|
data=ChildChunkRecord(
|
|
id=str(uuid4()),
|
|
segment_id=segment_ref.segment_id,
|
|
content=child.page_content,
|
|
position=position,
|
|
word_count=len(child.page_content),
|
|
type=SegmentType.AUTOMATIC,
|
|
created_at=now,
|
|
updated_at=now,
|
|
),
|
|
index_node_id=child.metadata["doc_id"],
|
|
index_node_hash=child.metadata["doc_hash"],
|
|
created_by=snapshot.document.created_by,
|
|
)
|
|
for position, child in enumerate(children, start=1)
|
|
)
|
|
self._segments.save_children(segment_ref, added=added, deleted=previous if replace_existing else ())
|
|
|
|
@with_credit_usage_created_by(CreditUsageCreatedBy.KNOWLEDGE_INDEXING)
|
|
def update_children(
|
|
self,
|
|
segment_ref: SegmentRef,
|
|
*,
|
|
added: Sequence[ChildChunkState] = (),
|
|
updated: Sequence[ChildChunkState] = (),
|
|
deleted: Sequence[ChildChunkState] = (),
|
|
) -> None:
|
|
snapshot = self._segments.get_indexing_snapshot(segment_ref)
|
|
if snapshot.dataset.indexing_technique != "high_quality":
|
|
return
|
|
documents = [
|
|
Document(
|
|
page_content=child.data.content,
|
|
metadata={
|
|
"doc_id": child.index_node_id,
|
|
"doc_hash": child.index_node_hash,
|
|
"document_id": segment_ref.document.document_id,
|
|
"dataset_id": segment_ref.document.dataset.dataset_id,
|
|
},
|
|
)
|
|
for child in (*added, *updated)
|
|
]
|
|
vector = self._vector(snapshot)
|
|
deleted_ids = []
|
|
for child in (*updated, *deleted):
|
|
assert child.index_node_id is not None
|
|
deleted_ids.append(child.index_node_id)
|
|
if deleted_ids:
|
|
vector.delete_by_ids(deleted_ids)
|
|
if documents:
|
|
vector.add_texts(documents, duplicate_check=True)
|
|
|
|
@staticmethod
|
|
def _attachment_documents(snapshot: SegmentIndexingSnapshot, files) -> list[Document]:
|
|
return [
|
|
Document(
|
|
page_content=file.name,
|
|
metadata={
|
|
"doc_id": file.id,
|
|
"doc_hash": "",
|
|
"dataset_id": snapshot.dataset.id,
|
|
"document_id": snapshot.document.id,
|
|
"doc_type": DocType.IMAGE,
|
|
},
|
|
)
|
|
for file in files
|
|
]
|
|
|
|
@with_credit_usage_created_by(CreditUsageCreatedBy.KNOWLEDGE_INDEXING)
|
|
def update_attachments(self, segment_ref: SegmentRef, attachment_ids: Sequence[str]) -> None:
|
|
snapshot = self._segments.get_indexing_snapshot(segment_ref)
|
|
if snapshot.dataset.indexing_technique != "high_quality" or set(snapshot.attachment_ids) == set(attachment_ids):
|
|
return
|
|
files = self._uploads.get_files(workspace_id=snapshot.dataset.tenant_id, file_ids=attachment_ids)
|
|
if snapshot.dataset.is_multimodal:
|
|
vector = self._vector(snapshot)
|
|
if snapshot.attachment_ids:
|
|
vector.delete_by_ids(list(snapshot.attachment_ids))
|
|
if files:
|
|
vector.create_multimodal(self._attachment_documents(snapshot, files.values()), upload_files=files)
|
|
self._segments.replace_attachments(segment_ref, tuple(files))
|
|
|
|
def update_summary(self, segment_ref: SegmentRef, *, summary: str | None, content_changed: bool) -> None:
|
|
if summary is None and not content_changed:
|
|
return
|
|
snapshot = self._segments.get_indexing_snapshot(segment_ref)
|
|
if snapshot.dataset.indexing_technique != "high_quality" or snapshot.document.doc_form == "qa_model":
|
|
return
|
|
setting = snapshot.dataset.summary_index_setting
|
|
previous = snapshot.summary
|
|
if summary is None or (previous is not None and summary == previous.summary_content):
|
|
if not (content_changed and previous and setting and setting.get("enable") is True):
|
|
return
|
|
try:
|
|
self._segments.save_summary(segment_ref, previous.summary_content or "")
|
|
summary, _ = ParagraphIndexProcessor.generate_summary_from_inputs(
|
|
snapshot.dataset.tenant_id,
|
|
snapshot.segment.content,
|
|
setting,
|
|
document_language=snapshot.document.doc_language,
|
|
image_loader=lambda: self._segments.get_summary_images(segment_ref),
|
|
)
|
|
if not summary.strip():
|
|
raise ValueError("Generated summary is empty")
|
|
except Exception as error:
|
|
self._segments.save_summary(segment_ref, previous.summary_content or "", error=str(error))
|
|
raise
|
|
if not summary.strip():
|
|
if previous and previous.summary_index_node_id:
|
|
try:
|
|
self._vector(snapshot).delete_by_ids([previous.summary_index_node_id])
|
|
except Exception:
|
|
logger.exception("Failed to delete summary vector for %s", segment_ref.segment_id)
|
|
self._segments.save_summary(segment_ref, None)
|
|
return
|
|
record = self._segments.save_summary(segment_ref, summary)
|
|
assert record is not None
|
|
SummaryIndexAdapter.vectorize_summary(
|
|
record,
|
|
snapshot.segment,
|
|
snapshot.dataset,
|
|
new_session=self._new_session,
|
|
vector_factory=lambda: self._vector(snapshot),
|
|
)
|
|
|
|
def _update_keywords(
|
|
self,
|
|
segment_ref: SegmentRef,
|
|
snapshot: SegmentIndexingSnapshot,
|
|
keywords: Sequence[str] | None,
|
|
*,
|
|
replace_existing: bool,
|
|
) -> None:
|
|
assert snapshot.segment.index_node_id is not None
|
|
self._write_keywords(
|
|
segment_ref.document, snapshot, [self._document(snapshot)], [keywords], replace_existing=replace_existing
|
|
)
|
|
|
|
def _write_keywords(
|
|
self,
|
|
document_ref: DocumentRef,
|
|
snapshot: SegmentIndexingSnapshot,
|
|
documents: list[Document],
|
|
keywords_list: Sequence[Sequence[str] | None] | None,
|
|
*,
|
|
replace_existing: bool,
|
|
) -> None:
|
|
dataset_ref = document_ref.dataset
|
|
|
|
def persist(storage_type: str, data: str, selected: Mapping[str, Sequence[str]]) -> None:
|
|
self._segments.save_keyword_table(dataset_ref, storage_type=storage_type, data=data, keywords=selected)
|
|
|
|
Jieba(snapshot.dataset).update_texts(
|
|
documents,
|
|
read=lambda: self._segments.get_keyword_table(dataset_ref),
|
|
write=persist,
|
|
lock=self._redis.lock(f"keyword_indexing_lock_{snapshot.dataset.id}", timeout=600),
|
|
keywords_list=keywords_list,
|
|
replace_existing=replace_existing,
|
|
)
|
|
|
|
def delete(
|
|
self, document_ref: DocumentRef, segments: Sequence[SegmentIndexTarget], children: Sequence[ChildChunkState]
|
|
) -> None:
|
|
self._delete_task(
|
|
[segment.index_node_id for segment in segments],
|
|
document_ref.dataset.dataset_id,
|
|
document_ref.document_id,
|
|
[segment.id for segment in segments],
|
|
[child.index_node_id for child in children if child.index_node_id],
|
|
)
|
|
|
|
def change_status(self, document_ref: DocumentRef, segment_ids: Sequence[str], action: str) -> None:
|
|
task = self._enable_task if action == "enable" else self._disable_task
|
|
task(list(segment_ids), document_ref.dataset.dataset_id, document_ref.document_id)
|