fix: normalize legacy chunks for shared recovery

This commit is contained in:
GuoQing Zhang
2026-08-28 12:47:57 +08:00
parent a5b381fe00
commit f6dfff5ea6
3 changed files with 181 additions and 13 deletions
@@ -511,17 +511,34 @@ class KnowledgeDocumentProjectionService:
"shared content chunk loader is unavailable"
)
chunks = await self.shared_content_chunk_loader(content_file)
source_file_id = int(content_file.id)
if not chunks:
tried_file_ids = {source_file_id}
for candidate in entries:
tried_file_ids = {int(content_file.id)}
original_knowledge_id = int(
content_file.original_knowledge_id or 0
)
entry_type_priority = {
KnowledgeFileEntryType.PUBLISH.value: 0,
KnowledgeFileEntryType.MANAGER.value: 1,
KnowledgeFileEntryType.SHARE.value: 2,
}
candidates = sorted(
entries,
key=lambda candidate: (
0
if original_knowledge_id
and int(candidate.knowledge_id) == original_knowledge_id
else 1,
entry_type_priority.get(str(candidate.entry_type), 99),
int(candidate.id),
),
)
for candidate in candidates:
candidate_id = int(candidate.id)
if candidate_id in tried_file_ids:
continue
tried_file_ids.add(candidate_id)
chunks = await self.shared_content_chunk_loader(candidate)
if chunks:
source_file_id = candidate_id
logger.info(
"shared content projection recovered chunks from active entry "
"tenant_id=%s document_id=%s content_file_id=%s "
@@ -151,22 +151,94 @@ async def load_shared_content_chunks_from_legacy(
if not rows:
continue
chunks: list[SharedContentChunk] = []
desired_generation = int(
content_file.desired_content_generation or 0
)
def row_generation(row: dict) -> int | None:
user_metadata = row.get("user_metadata")
if not isinstance(user_metadata, dict):
return None
value = user_metadata.get("content_generation")
try:
return int(value) if value is not None else None
except (TypeError, ValueError):
return None
generations = {
generation
for row in rows
if (generation := row_generation(row)) is not None
}
selected_generation = (
desired_generation
if desired_generation in generations
else max(generations, default=None)
)
if selected_generation is not None:
rows = [
row
for row in rows
if row_generation(row) == selected_generation
]
chunks_by_index: dict[int, SharedContentChunk] = {}
corrupted = False
for offset, row in enumerate(rows):
metadata = {
key: value
for key, value in row.items()
if key not in {"pk", "text", "vector"}
}
chunks.append(
SharedContentChunk(
chunk_index=int(row.get("chunk_index", offset) or offset),
text=str(row.get("text", "")),
vector=row.get("vector"),
metadata=metadata,
)
raw_chunk_index = row.get("chunk_index")
chunk_index = (
offset
if raw_chunk_index is None
else int(raw_chunk_index)
)
return sorted(chunks, key=lambda chunk: chunk.chunk_index)
chunk = SharedContentChunk(
chunk_index=chunk_index,
text=str(row.get("text", "")),
vector=row.get("vector"),
metadata=metadata,
)
existing = chunks_by_index.get(chunk_index)
if existing is None:
chunks_by_index[chunk_index] = chunk
continue
existing_vector = (
tuple(existing.vector)
if existing.vector is not None
else None
)
chunk_vector = (
tuple(chunk.vector) if chunk.vector is not None else None
)
existing_sparse = tuple(
sorted((existing.sparse_vector or {}).items())
)
chunk_sparse = tuple(sorted((chunk.sparse_vector or {}).items()))
if (
existing.text != chunk.text
or existing_vector != chunk_vector
or existing_sparse != chunk_sparse
):
corrupted = True
break
indexes = sorted(chunks_by_index)
if corrupted or indexes != list(range(len(indexes))):
logger.warning(
"skip corrupted legacy chunk source file_id=%s knowledge_id=%s "
"generation=%s rows=%s unique_indexes=%s",
content_file.id,
knowledge.id,
selected_generation,
len(rows),
len(indexes),
)
continue
return [chunks_by_index[index] for index in indexes]
return []
return await asyncio.to_thread(_load)
@@ -295,6 +295,85 @@ async def test_chunk_loader_falls_back_to_original_space_after_publish_move():
assert chunks[0].vector == [0.1, 0.2]
async def test_chunk_loader_selects_current_generation_and_deduplicates():
content_file = KnowledgeFile(
id=100,
tenant_id=TENANT,
knowledge_id=20,
original_knowledge_id=10,
desired_content_generation=1,
file_name="doc.pdf",
)
collection = SimpleNamespace(
schema=SimpleNamespace(
fields=[
SimpleNamespace(name=name)
for name in (
"pk",
"document_id",
"chunk_index",
"text",
"vector",
"user_metadata",
)
]
),
query=lambda **_kwargs: [
{
"document_id": 100,
"chunk_index": 0,
"text": "stale zero",
"vector": [0.0],
"user_metadata": {"content_generation": 0},
},
{
"document_id": 100,
"chunk_index": 0,
"text": "current zero",
"vector": [1.0],
"user_metadata": {"content_generation": 1},
},
{
"document_id": 100,
"chunk_index": 1,
"text": "current one",
"vector": [2.0],
"user_metadata": {"content_generation": 1},
},
{
"document_id": 100,
"chunk_index": 0,
"text": "current zero",
"vector": [1.0],
"user_metadata": {"content_generation": 1},
},
{
"document_id": 100,
"chunk_index": 1,
"text": "current one",
"vector": [2.0],
"user_metadata": {"content_generation": 1},
},
],
)
with (
patch(
"bisheng.knowledge.domain.models.knowledge.KnowledgeDao.aquery_by_id",
new=AsyncMock(return_value=SimpleNamespace(id=10)),
),
patch(
"bisheng.knowledge.domain.knowledge_rag.KnowledgeRag."
"init_knowledge_milvus_vectorstore_sync",
return_value=SimpleNamespace(col=collection),
),
):
chunks = await load_shared_content_chunks_from_legacy(content_file)
assert [chunk.chunk_index for chunk in chunks] == [0, 1]
assert [chunk.text for chunk in chunks] == ["current zero", "current one"]
async def test_content_generation_requeue_resets_exhausted_retry_state(
async_db_session: AsyncSession,
):