diff --git a/COMMIT_DAY1.txt b/COMMIT_DAY1.txt index f868ad6b..9ac5e37f 100644 --- a/COMMIT_DAY1.txt +++ b/COMMIT_DAY1.txt @@ -50,6 +50,9 @@ Status: Day 1 complete (11/11 tasks), ready for Day 2 + + + diff --git a/DC模块代码恢复指南.md b/DC模块代码恢复指南.md index 3a2348c0..86b4a545 100644 --- a/DC模块代码恢复指南.md +++ b/DC模块代码恢复指南.md @@ -280,6 +280,9 @@ + + + diff --git a/SAE_WECHAT_MP_DEPLOY_STEPS.md b/SAE_WECHAT_MP_DEPLOY_STEPS.md index e3425c6d..fb0f4dd8 100644 --- a/SAE_WECHAT_MP_DEPLOY_STEPS.md +++ b/SAE_WECHAT_MP_DEPLOY_STEPS.md @@ -226,6 +226,9 @@ https://iit.xunzhengyixue.com/api/v1/iit/health + + + diff --git a/backend/DEPLOY_TO_SAE_FOR_WECHAT_MP.md b/backend/DEPLOY_TO_SAE_FOR_WECHAT_MP.md index 5adca17b..c853ed3e 100644 --- a/backend/DEPLOY_TO_SAE_FOR_WECHAT_MP.md +++ b/backend/DEPLOY_TO_SAE_FOR_WECHAT_MP.md @@ -155,6 +155,9 @@ https://iit.xunzhengyixue.com/api/v1/iit/health + + + diff --git a/backend/RESTART_SERVER_NOW.md b/backend/RESTART_SERVER_NOW.md index b1b312af..e064c141 100644 --- a/backend/RESTART_SERVER_NOW.md +++ b/backend/RESTART_SERVER_NOW.md @@ -56,6 +56,9 @@ + + + diff --git a/backend/WECHAT_MP_CONFIG_READY.md b/backend/WECHAT_MP_CONFIG_READY.md index 0382aded..49b1df7e 100644 --- a/backend/WECHAT_MP_CONFIG_READY.md +++ b/backend/WECHAT_MP_CONFIG_READY.md @@ -316,6 +316,9 @@ npx tsx src/modules/iit-manager/test-patient-wechat-url-verify.ts + + + diff --git a/backend/WECHAT_MP_QUICK_FIX.md b/backend/WECHAT_MP_QUICK_FIX.md index 2b60390c..42ca4e41 100644 --- a/backend/WECHAT_MP_QUICK_FIX.md +++ b/backend/WECHAT_MP_QUICK_FIX.md @@ -178,6 +178,9 @@ npm run dev + + + diff --git a/backend/check_db.ts b/backend/check_db.ts index 65a28f6e..a11cb650 100644 --- a/backend/check_db.ts +++ b/backend/check_db.ts @@ -59,3 +59,6 @@ main() + + + diff --git a/backend/check_db_data.ts b/backend/check_db_data.ts index 05f85b39..3a84a859 100644 --- a/backend/check_db_data.ts +++ b/backend/check_db_data.ts @@ -53,3 +53,6 @@ main() + + + diff --git a/backend/check_iit.ts b/backend/check_iit.ts index a9992873..76752126 100644 --- a/backend/check_iit.ts +++ b/backend/check_iit.ts @@ -48,3 +48,6 @@ main() + + + diff --git a/backend/check_iit_asl_data.ts b/backend/check_iit_asl_data.ts index 0282b2a7..3a776398 100644 --- a/backend/check_iit_asl_data.ts +++ b/backend/check_iit_asl_data.ts @@ -80,3 +80,6 @@ main() + + + diff --git a/backend/check_queue_table.ts b/backend/check_queue_table.ts index a4c4fd74..9a1e1801 100644 --- a/backend/check_queue_table.ts +++ b/backend/check_queue_table.ts @@ -43,3 +43,6 @@ main() + + + diff --git a/backend/check_rvw_issue.ts b/backend/check_rvw_issue.ts index 7affea67..f201f83d 100644 --- a/backend/check_rvw_issue.ts +++ b/backend/check_rvw_issue.ts @@ -84,3 +84,6 @@ main() + + + diff --git a/backend/check_tables.ts b/backend/check_tables.ts index 08f02ed0..fb9486dc 100644 --- a/backend/check_tables.ts +++ b/backend/check_tables.ts @@ -31,3 +31,6 @@ main() + + + diff --git a/backend/compare_db.ts b/backend/compare_db.ts index d1edede9..a2b71dfe 100644 --- a/backend/compare_db.ts +++ b/backend/compare_db.ts @@ -119,3 +119,6 @@ main() + + + diff --git a/backend/compare_dc_asl.ts b/backend/compare_dc_asl.ts index 29c98e6f..494c24d7 100644 --- a/backend/compare_dc_asl.ts +++ b/backend/compare_dc_asl.ts @@ -90,3 +90,6 @@ main() + + + diff --git a/backend/compare_pkb_aia_rvw.ts b/backend/compare_pkb_aia_rvw.ts index cfde4751..2678af6e 100644 --- a/backend/compare_pkb_aia_rvw.ts +++ b/backend/compare_pkb_aia_rvw.ts @@ -76,3 +76,6 @@ main() + + + diff --git a/backend/compare_schema_db.ts b/backend/compare_schema_db.ts index 6359ec3a..51f419c6 100644 --- a/backend/compare_schema_db.ts +++ b/backend/compare_schema_db.ts @@ -118,3 +118,6 @@ main() + + + diff --git a/backend/create_mock_user.sql b/backend/create_mock_user.sql index 26e175ba..6b4003fd 100644 --- a/backend/create_mock_user.sql +++ b/backend/create_mock_user.sql @@ -29,3 +29,6 @@ ON CONFLICT (id) DO NOTHING; + + + diff --git a/backend/create_mock_user_platform.sql b/backend/create_mock_user_platform.sql index 350a55f3..eafc20e2 100644 --- a/backend/create_mock_user_platform.sql +++ b/backend/create_mock_user_platform.sql @@ -61,3 +61,6 @@ ON CONFLICT (id) DO NOTHING; + + + diff --git a/backend/migrations/add_data_stats_to_tool_c_session.sql b/backend/migrations/add_data_stats_to_tool_c_session.sql index 99c22db5..d79eff35 100644 --- a/backend/migrations/add_data_stats_to_tool_c_session.sql +++ b/backend/migrations/add_data_stats_to_tool_c_session.sql @@ -75,6 +75,9 @@ WHERE table_schema = 'dc_schema' + + + diff --git a/backend/prisma/manual-migrations/001_add_postgres_cache_and_checkpoint.sql b/backend/prisma/manual-migrations/001_add_postgres_cache_and_checkpoint.sql index 8064d001..0adfb633 100644 --- a/backend/prisma/manual-migrations/001_add_postgres_cache_and_checkpoint.sql +++ b/backend/prisma/manual-migrations/001_add_postgres_cache_and_checkpoint.sql @@ -113,6 +113,9 @@ ORDER BY ordinal_position; + + + diff --git a/backend/prisma/manual-migrations/run-migration-002.ts b/backend/prisma/manual-migrations/run-migration-002.ts index beeb64c1..177cdb3a 100644 --- a/backend/prisma/manual-migrations/run-migration-002.ts +++ b/backend/prisma/manual-migrations/run-migration-002.ts @@ -126,6 +126,9 @@ runMigration() + + + diff --git a/backend/prisma/migrations/20251208_add_column_mapping/migration.sql b/backend/prisma/migrations/20251208_add_column_mapping/migration.sql index b5d09a60..8cdb665d 100644 --- a/backend/prisma/migrations/20251208_add_column_mapping/migration.sql +++ b/backend/prisma/migrations/20251208_add_column_mapping/migration.sql @@ -60,6 +60,9 @@ COMMENT ON COLUMN "dc_schema"."dc_tool_c_sessions"."column_mapping" IS '列名 + + + diff --git a/backend/prisma/migrations/create_tool_c_session.sql b/backend/prisma/migrations/create_tool_c_session.sql index 2b694d36..774f29d6 100644 --- a/backend/prisma/migrations/create_tool_c_session.sql +++ b/backend/prisma/migrations/create_tool_c_session.sql @@ -87,6 +87,9 @@ COMMENT ON COLUMN dc_schema.dc_tool_c_sessions.expires_at IS '过期时间(创 + + + diff --git a/backend/prisma/migrations/manual/ekb_create_indexes.sql b/backend/prisma/migrations/manual/ekb_create_indexes.sql new file mode 100644 index 00000000..4bd1f670 --- /dev/null +++ b/backend/prisma/migrations/manual/ekb_create_indexes.sql @@ -0,0 +1,64 @@ +-- ============================================================ +-- EKB Schema 索引创建脚本 +-- 执行时机:prisma migrate 之后手动执行 +-- 参考文档:docs/02-通用能力层/03-RAG引擎/04-数据模型设计.md +-- ============================================================ + +-- 1. 确保 pgvector 扩展已启用 +CREATE EXTENSION IF NOT EXISTS vector; + +-- 2. 确保 pg_bigm 扩展已启用(中文关键词检索) +CREATE EXTENSION IF NOT EXISTS pg_bigm; + +-- ===== MVP 阶段必须创建 ===== + +-- 3. HNSW 向量索引(语义检索核心) +-- 参数说明:m=16 每层最大连接数,ef_construction=64 构建时搜索范围 +CREATE INDEX IF NOT EXISTS idx_ekb_chunk_embedding +ON "ekb_schema"."ekb_chunk" +USING hnsw (embedding vector_cosine_ops) +WITH (m = 16, ef_construction = 64); + +-- ===== Phase 2 阶段使用(可预创建)===== + +-- 4. pg_bigm 中文关键词索引 +CREATE INDEX IF NOT EXISTS idx_ekb_chunk_content_bigm +ON "ekb_schema"."ekb_chunk" +USING gin (content gin_bigm_ops); + +-- 5. 文档摘要关键词索引 +CREATE INDEX IF NOT EXISTS idx_ekb_doc_summary_bigm +ON "ekb_schema"."ekb_document" +USING gin (summary gin_bigm_ops); + +-- 6. 全文内容关键词索引 +CREATE INDEX IF NOT EXISTS idx_ekb_doc_text_bigm +ON "ekb_schema"."ekb_document" +USING gin (extracted_text gin_bigm_ops); + +-- ===== Phase 3 阶段使用(可预创建)===== + +-- 7. JSONB GIN 索引(metadata 查询加速) +CREATE INDEX IF NOT EXISTS idx_ekb_doc_metadata_gin +ON "ekb_schema"."ekb_document" +USING gin (metadata jsonb_path_ops); + +-- 8. JSONB GIN 索引(structuredData 查询加速) +CREATE INDEX IF NOT EXISTS idx_ekb_doc_structured_gin +ON "ekb_schema"."ekb_document" +USING gin (structured_data jsonb_path_ops); + +-- 9. 标签数组索引 +CREATE INDEX IF NOT EXISTS idx_ekb_doc_tags_gin +ON "ekb_schema"."ekb_document" +USING gin (tags); + +-- 10. 切片元数据索引 +CREATE INDEX IF NOT EXISTS idx_ekb_chunk_metadata_gin +ON "ekb_schema"."ekb_chunk" +USING gin (metadata jsonb_path_ops); + +-- ===== 验证索引创建 ===== +-- SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = 'ekb_schema'; + + diff --git a/backend/prisma/migrations/manual/ekb_create_indexes_mvp.sql b/backend/prisma/migrations/manual/ekb_create_indexes_mvp.sql new file mode 100644 index 00000000..434c7035 --- /dev/null +++ b/backend/prisma/migrations/manual/ekb_create_indexes_mvp.sql @@ -0,0 +1,31 @@ +-- ============================================================ +-- EKB Schema MVP 索引创建脚本 +-- 执行时机:prisma db push 之后手动执行 +-- 说明:MVP 阶段只创建 HNSW 向量索引,pg_bigm 索引在 Phase 2 创建 +-- ============================================================ + +-- 1. 确保 pgvector 扩展已启用 +CREATE EXTENSION IF NOT EXISTS vector; + +-- 2. HNSW 向量索引(语义检索核心) +-- 参数说明:m=16 每层最大连接数,ef_construction=64 构建时搜索范围 +CREATE INDEX IF NOT EXISTS idx_ekb_chunk_embedding +ON "ekb_schema"."ekb_chunk" +USING hnsw (embedding vector_cosine_ops) +WITH (m = 16, ef_construction = 64); + +-- 3. JSONB GIN 索引(可选,提升查询性能) +CREATE INDEX IF NOT EXISTS idx_ekb_doc_metadata_gin +ON "ekb_schema"."ekb_document" +USING gin (metadata jsonb_path_ops); + +CREATE INDEX IF NOT EXISTS idx_ekb_doc_structured_gin +ON "ekb_schema"."ekb_document" +USING gin (structured_data jsonb_path_ops); + +-- 4. 标签数组索引 +CREATE INDEX IF NOT EXISTS idx_ekb_doc_tags_gin +ON "ekb_schema"."ekb_document" +USING gin (tags); + + diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 9eb74494..941a8968 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -6,7 +6,7 @@ generator client { datasource db { provider = "postgresql" url = env("DATABASE_URL") - schemas = ["admin_schema", "aia_schema", "asl_schema", "capability_schema", "common_schema", "dc_schema", "iit_schema", "pkb_schema", "platform_schema", "public", "rvw_schema", "ssa_schema", "st_schema"] + schemas = ["admin_schema", "aia_schema", "asl_schema", "capability_schema", "common_schema", "dc_schema", "ekb_schema", "iit_schema", "pkb_schema", "platform_schema", "public", "rvw_schema", "ssa_schema", "st_schema"] } /// 应用缓存表 - Postgres-Only架构 @@ -1283,3 +1283,113 @@ enum PromptStatus { @@schema("capability_schema") } + +// ============================================================ +// EKB Schema - 知识库引擎 (Enterprise Knowledge Base) +// 参考文档: docs/02-通用能力层/03-RAG引擎/04-数据模型设计.md +// ============================================================ + +/// 知识库容器表 - 管理知识库的归属和策略配置 +model EkbKnowledgeBase { + id String @id @default(uuid()) + name String /// 知识库名称 + description String? /// 描述 + + /// 核心隔离字段 + /// USER: 用户私有,ownerId = userId + /// SYSTEM: 系统公共,ownerId = moduleId (如 "ASL", "AIA") + type String @default("USER") /// USER | SYSTEM + ownerId String @map("owner_id") /// userId 或 moduleId + + /// 策略配置 (JSONB) + /// { chunkSize, topK, enableRerank, embeddingModel } + config Json? @db.JsonB + + documents EkbDocument[] + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([ownerId], map: "idx_ekb_kb_owner") + @@index([type], map: "idx_ekb_kb_type") + @@map("ekb_knowledge_base") + @@schema("ekb_schema") +} + +/// 文档表 - 存储上传的文档及其元数据 +model EkbDocument { + id String @id @default(uuid()) + kbId String @map("kb_id") /// 所属知识库 + userId String @map("user_id") /// 上传者(冗余存储) + + // ===== Layer 1: 基础信息(必须)===== + filename String /// 文件名 + fileType String @map("file_type") /// pdf, docx, pptx, xlsx, md, txt + fileSizeBytes BigInt @map("file_size_bytes") /// 文件大小(字节) + fileUrl String @map("file_url") /// OSS 存储路径 + fileHash String? @map("file_hash") /// SHA256 哈希(秒传去重) + status String @default("pending") /// pending, processing, completed, failed + errorMessage String? @map("error_message") @db.Text + + // ===== Layer 0: RAG 核心(必须)===== + extractedText String? @map("extracted_text") @db.Text /// Markdown 全文 + + // ===== Layer 2: 内容增强(可选)===== + summary String? @db.Text /// AI 摘要 + tokenCount Int? @map("token_count") /// Token 数量 + pageCount Int? @map("page_count") /// 页数 + + // ===== Layer 3: 分类标签(可选)===== + contentType String? @map("content_type") /// 内容类型 + tags String[] /// 用户标签 + category String? /// 分类目录 + + // ===== Layer 4: 结构化数据(可选)===== + metadata Json? @db.JsonB /// 文献属性 JSONB + structuredData Json? @map("structured_data") @db.JsonB /// 类型特定数据 JSONB + + // ===== 关联 ===== + knowledgeBase EkbKnowledgeBase @relation(fields: [kbId], references: [id], onDelete: Cascade) + chunks EkbChunk[] + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([kbId], map: "idx_ekb_doc_kb") + @@index([userId], map: "idx_ekb_doc_user") + @@index([status], map: "idx_ekb_doc_status") + @@index([contentType], map: "idx_ekb_doc_content_type") + @@index([fileHash], map: "idx_ekb_doc_file_hash") + @@map("ekb_document") + @@schema("ekb_schema") +} + +/// 切片表 - 存储文档切片和向量嵌入 +model EkbChunk { + id String @id @default(uuid()) + documentId String @map("document_id") /// 所属文档 + + // ===== 核心内容 ===== + content String @db.Text /// 切片文本(Markdown) + chunkIndex Int @map("chunk_index") /// 切片序号(从 0 开始) + + // ===== 向量 ===== + /// pgvector 1024 维向量 + /// 注意:需要手动创建 HNSW 索引 + embedding Unsupported("vector(1024)")? + + // ===== 溯源信息(可选)===== + pageNumber Int? @map("page_number") /// 页码(PDF 溯源) + sectionType String? @map("section_type") /// 章节类型 + + // ===== 扩展元数据(可选)===== + metadata Json? @db.JsonB /// 切片级元数据 JSONB + + document EkbDocument @relation(fields: [documentId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) @map("created_at") + + @@index([documentId], map: "idx_ekb_chunk_doc") + @@map("ekb_chunk") + @@schema("ekb_schema") +} diff --git a/backend/rebuild-and-push.ps1 b/backend/rebuild-and-push.ps1 index 62e12718..866947ee 100644 --- a/backend/rebuild-and-push.ps1 +++ b/backend/rebuild-and-push.ps1 @@ -127,6 +127,9 @@ Write-Host "" + + + diff --git a/backend/recover-code-from-cursor-db.js b/backend/recover-code-from-cursor-db.js index b51f0251..3156a4ff 100644 --- a/backend/recover-code-from-cursor-db.js +++ b/backend/recover-code-from-cursor-db.js @@ -237,6 +237,9 @@ function extractCodeBlocks(obj, blocks = []) { + + + diff --git a/backend/restore_job_common.sql b/backend/restore_job_common.sql index a46c4a79..2e7db2ad 100644 --- a/backend/restore_job_common.sql +++ b/backend/restore_job_common.sql @@ -38,3 +38,6 @@ CREATE TABLE IF NOT EXISTS platform_schema.job_common ( + + + diff --git a/backend/restore_pgboss_functions.sql b/backend/restore_pgboss_functions.sql index 80cb98a1..5f7a4ab8 100644 --- a/backend/restore_pgboss_functions.sql +++ b/backend/restore_pgboss_functions.sql @@ -112,3 +112,6 @@ CREATE OR REPLACE FUNCTION platform_schema.delete_queue(queue_name text) RETURNS + + + diff --git a/backend/scripts/check-dc-tables.mjs b/backend/scripts/check-dc-tables.mjs index 641bec75..3c9a5ad4 100644 --- a/backend/scripts/check-dc-tables.mjs +++ b/backend/scripts/check-dc-tables.mjs @@ -256,6 +256,9 @@ checkDCTables(); + + + diff --git a/backend/scripts/create-capability-schema.sql b/backend/scripts/create-capability-schema.sql index b25b93f7..eb8cdd79 100644 --- a/backend/scripts/create-capability-schema.sql +++ b/backend/scripts/create-capability-schema.sql @@ -13,3 +13,6 @@ CREATE SCHEMA IF NOT EXISTS capability_schema; + + + diff --git a/backend/scripts/create-tool-c-ai-history-table.mjs b/backend/scripts/create-tool-c-ai-history-table.mjs index c0cd2c79..6c6a481e 100644 --- a/backend/scripts/create-tool-c-ai-history-table.mjs +++ b/backend/scripts/create-tool-c-ai-history-table.mjs @@ -208,6 +208,9 @@ createAiHistoryTable() + + + diff --git a/backend/scripts/create-tool-c-table.js b/backend/scripts/create-tool-c-table.js index 871b5178..cea10343 100644 --- a/backend/scripts/create-tool-c-table.js +++ b/backend/scripts/create-tool-c-table.js @@ -195,6 +195,9 @@ createToolCTable() + + + diff --git a/backend/scripts/create-tool-c-table.mjs b/backend/scripts/create-tool-c-table.mjs index 9fce371c..286afcaf 100644 --- a/backend/scripts/create-tool-c-table.mjs +++ b/backend/scripts/create-tool-c-table.mjs @@ -192,6 +192,9 @@ createToolCTable() + + + diff --git a/backend/scripts/migrate-aia-prompts.ts b/backend/scripts/migrate-aia-prompts.ts index 9be3bbef..a88c529f 100644 --- a/backend/scripts/migrate-aia-prompts.ts +++ b/backend/scripts/migrate-aia-prompts.ts @@ -316,3 +316,6 @@ main() .finally(() => prisma.$disconnect()); + + + diff --git a/backend/scripts/setup-prompt-system.ts b/backend/scripts/setup-prompt-system.ts index d827de40..bc186db6 100644 --- a/backend/scripts/setup-prompt-system.ts +++ b/backend/scripts/setup-prompt-system.ts @@ -123,3 +123,6 @@ main() + + + diff --git a/backend/scripts/test-pkb-apis-simple.ts b/backend/scripts/test-pkb-apis-simple.ts index cef5ab32..de0d02a3 100644 --- a/backend/scripts/test-pkb-apis-simple.ts +++ b/backend/scripts/test-pkb-apis-simple.ts @@ -340,6 +340,9 @@ runTests().catch(error => { + + + diff --git a/backend/scripts/test-prompt-api.ts b/backend/scripts/test-prompt-api.ts index 5f896120..44f089b7 100644 --- a/backend/scripts/test-prompt-api.ts +++ b/backend/scripts/test-prompt-api.ts @@ -89,3 +89,6 @@ testAPI().catch(console.error); + + + diff --git a/backend/scripts/test-unifuncs-deepsearch.ts b/backend/scripts/test-unifuncs-deepsearch.ts index e93ab67c..1943d655 100644 --- a/backend/scripts/test-unifuncs-deepsearch.ts +++ b/backend/scripts/test-unifuncs-deepsearch.ts @@ -119,3 +119,6 @@ async function testDeepSearch() { testDeepSearch().catch(console.error); + + + diff --git a/backend/scripts/verify-pkb-rvw-schema.ts b/backend/scripts/verify-pkb-rvw-schema.ts index cd436266..5d8e32b5 100644 --- a/backend/scripts/verify-pkb-rvw-schema.ts +++ b/backend/scripts/verify-pkb-rvw-schema.ts @@ -305,6 +305,9 @@ verifySchemas() + + + diff --git a/backend/src/common/auth/jwt.service.ts b/backend/src/common/auth/jwt.service.ts index dc6a60e5..04f771cf 100644 --- a/backend/src/common/auth/jwt.service.ts +++ b/backend/src/common/auth/jwt.service.ts @@ -196,3 +196,6 @@ export const jwtService = new JWTService(); + + + diff --git a/backend/src/common/jobs/utils.ts b/backend/src/common/jobs/utils.ts index e7ea5a88..2b45e2be 100644 --- a/backend/src/common/jobs/utils.ts +++ b/backend/src/common/jobs/utils.ts @@ -324,6 +324,9 @@ export function getBatchItems( + + + diff --git a/backend/src/common/prompt/prompt.types.ts b/backend/src/common/prompt/prompt.types.ts index 62439447..00fa0161 100644 --- a/backend/src/common/prompt/prompt.types.ts +++ b/backend/src/common/prompt/prompt.types.ts @@ -79,3 +79,6 @@ export interface VariableValidation { + + + diff --git a/backend/src/common/rag/ChunkService.ts b/backend/src/common/rag/ChunkService.ts new file mode 100644 index 00000000..0bb47322 --- /dev/null +++ b/backend/src/common/rag/ChunkService.ts @@ -0,0 +1,354 @@ +/** + * ChunkService - 文本分块服务 + * + * 将长文本按语义边界分割为适合向量化的小块 + * 支持 Markdown 格式的智能分块 + * + * 分块策略: + * 1. 按标题层级分割(# ## ###) + * 2. 按段落分割 + * 3. 按字符数限制分割(带重叠) + */ + +import { logger } from '../logging/index.js'; + +// ==================== 类型定义 ==================== + +export interface ChunkConfig { + maxChunkSize?: number; // 单块最大字符数,默认 1000 + chunkOverlap?: number; // 块间重叠字符数,默认 200 + separators?: string[]; // 分隔符优先级列表 + preserveMarkdown?: boolean; // 保留 Markdown 格式,默认 true +} + +export interface TextChunk { + content: string; // 分块内容 + index: number; // 分块索引(从 0 开始) + startChar: number; // 在原文中的起始位置 + endChar: number; // 在原文中的结束位置 + metadata?: Record; // 可选元数据(如标题层级) +} + +export interface ChunkResult { + chunks: TextChunk[]; + totalChunks: number; + originalLength: number; +} + +// ==================== 默认配置 ==================== + +const DEFAULT_CONFIG: Required = { + maxChunkSize: 1000, + chunkOverlap: 200, + separators: [ + '\n## ', // H2 标题 + '\n### ', // H3 标题 + '\n#### ', // H4 标题 + '\n\n', // 段落 + '\n', // 换行 + '。', // 中文句号 + '. ', // 英文句号 + ';', // 中文分号 + '; ', // 英文分号 + ' ', // 空格 + ], + preserveMarkdown: true, +}; + +// ==================== ChunkService ==================== + +export class ChunkService { + private config: Required; + + constructor(config: ChunkConfig = {}) { + this.config = { ...DEFAULT_CONFIG, ...config }; + logger.debug(`ChunkService 初始化: maxChunkSize=${this.config.maxChunkSize}, overlap=${this.config.chunkOverlap}`); + } + + /** + * 将文本分割为多个块 + */ + chunk(text: string): ChunkResult { + if (!text || text.trim().length === 0) { + return { chunks: [], totalChunks: 0, originalLength: 0 }; + } + + const originalLength = text.length; + const chunks: TextChunk[] = []; + + // 使用递归分割策略 + const rawChunks = this.recursiveSplit(text, this.config.separators); + + // 合并过小的块,分割过大的块 + const normalizedChunks = this.normalizeChunks(rawChunks); + + // 添加重叠 + const overlappedChunks = this.addOverlap(normalizedChunks, text); + + // 构建结果 + let charPosition = 0; + for (let i = 0; i < overlappedChunks.length; i++) { + const content = overlappedChunks[i]; + const startChar = text.indexOf(content.trim(), charPosition); + const endChar = startChar + content.trim().length; + + chunks.push({ + content: content.trim(), + index: i, + startChar: startChar >= 0 ? startChar : charPosition, + endChar: endChar >= 0 ? endChar : charPosition + content.length, + }); + + if (startChar >= 0) { + charPosition = startChar + 1; + } + } + + logger.info(`文本分块完成: ${originalLength} 字符 -> ${chunks.length} 块`); + + return { + chunks, + totalChunks: chunks.length, + originalLength, + }; + } + + /** + * 递归分割文本 + */ + private recursiveSplit(text: string, separators: string[]): string[] { + if (text.length <= this.config.maxChunkSize) { + return [text]; + } + + if (separators.length === 0) { + // 没有更多分隔符,强制按字符数分割 + return this.forceSplit(text); + } + + const [separator, ...restSeparators] = separators; + const parts = text.split(separator); + + if (parts.length === 1) { + // 当前分隔符无效,尝试下一个 + return this.recursiveSplit(text, restSeparators); + } + + const result: string[] = []; + let currentChunk = ''; + + for (const part of parts) { + const potentialChunk = currentChunk + ? currentChunk + separator + part + : part; + + if (potentialChunk.length <= this.config.maxChunkSize) { + currentChunk = potentialChunk; + } else { + if (currentChunk) { + result.push(currentChunk); + } + // 如果单个 part 仍然过大,递归处理 + if (part.length > this.config.maxChunkSize) { + result.push(...this.recursiveSplit(part, restSeparators)); + currentChunk = ''; + } else { + currentChunk = part; + } + } + } + + if (currentChunk) { + result.push(currentChunk); + } + + return result; + } + + /** + * 强制按字符数分割(最后手段) + */ + private forceSplit(text: string): string[] { + const chunks: string[] = []; + const { maxChunkSize } = this.config; + + for (let i = 0; i < text.length; i += maxChunkSize) { + chunks.push(text.slice(i, i + maxChunkSize)); + } + + return chunks; + } + + /** + * 规范化块大小 + */ + private normalizeChunks(chunks: string[]): string[] { + const { maxChunkSize } = this.config; + const minChunkSize = Math.floor(maxChunkSize * 0.3); // 最小块为最大块的 30% + const result: string[] = []; + let buffer = ''; + + for (const chunk of chunks) { + const trimmed = chunk.trim(); + if (!trimmed) continue; + + if (buffer) { + const combined = buffer + '\n' + trimmed; + if (combined.length <= maxChunkSize) { + buffer = combined; + } else { + result.push(buffer); + buffer = trimmed; + } + } else { + buffer = trimmed; + } + + // 如果 buffer 足够大,输出 + if (buffer.length >= minChunkSize && buffer.length <= maxChunkSize) { + result.push(buffer); + buffer = ''; + } + } + + if (buffer) { + // 尝试合并到最后一个块 + if (result.length > 0 && (result[result.length - 1].length + buffer.length) <= maxChunkSize) { + result[result.length - 1] += '\n' + buffer; + } else { + result.push(buffer); + } + } + + return result; + } + + /** + * 添加块间重叠(提高检索连贯性) + */ + private addOverlap(chunks: string[], originalText: string): string[] { + if (this.config.chunkOverlap <= 0 || chunks.length <= 1) { + return chunks; + } + + const result: string[] = []; + const { chunkOverlap } = this.config; + + for (let i = 0; i < chunks.length; i++) { + let chunk = chunks[i]; + + // 添加前一块的结尾作为上下文 + if (i > 0) { + const prevChunk = chunks[i - 1]; + const overlap = prevChunk.slice(-chunkOverlap); + // 尝试从句子边界开始 + const sentenceStart = this.findSentenceStart(overlap); + chunk = sentenceStart + chunk; + } + + result.push(chunk); + } + + return result; + } + + /** + * 查找句子起始位置 + */ + private findSentenceStart(text: string): string { + const sentenceEnders = ['。', '.', '!', '!', '?', '?', '\n']; + + for (let i = 0; i < text.length; i++) { + if (sentenceEnders.includes(text[i])) { + return text.slice(i + 1).trimStart(); + } + } + + return text; + } + + /** + * 为 Markdown 文档智能分块(保留标题层级) + */ + chunkMarkdown(markdown: string): ChunkResult { + const chunks: TextChunk[] = []; + + // 按一级/二级标题分割 + const sections = markdown.split(/(?=^#{1,2}\s)/m); + let globalIndex = 0; + let charPosition = 0; + + for (const section of sections) { + if (!section.trim()) continue; + + // 提取标题 + const titleMatch = section.match(/^(#{1,6})\s+(.+?)$/m); + const title = titleMatch ? titleMatch[2] : undefined; + const level = titleMatch ? titleMatch[1].length : 0; + + // 分块该 section + const sectionResult = this.chunk(section); + + for (const chunk of sectionResult.chunks) { + chunks.push({ + ...chunk, + index: globalIndex++, + startChar: charPosition + chunk.startChar, + endChar: charPosition + chunk.endChar, + metadata: title ? { title, level } : undefined, + }); + } + + charPosition += section.length; + } + + logger.info(`Markdown 分块完成: ${markdown.length} 字符 -> ${chunks.length} 块`); + + return { + chunks, + totalChunks: chunks.length, + originalLength: markdown.length, + }; + } + + /** + * 获取当前配置 + */ + getConfig(): Required { + return { ...this.config }; + } +} + +// ==================== 单例和快捷方法 ==================== + +let _chunkService: ChunkService | null = null; + +/** + * 获取 ChunkService 单例 + */ +export function getChunkService(config?: ChunkConfig): ChunkService { + if (!_chunkService) { + _chunkService = new ChunkService(config); + } + return _chunkService; +} + +/** + * 快捷方法:分块普通文本 + */ +export function chunkText(text: string, config?: ChunkConfig): TextChunk[] { + const service = config ? new ChunkService(config) : getChunkService(); + return service.chunk(text).chunks; +} + +/** + * 快捷方法:分块 Markdown 文本 + */ +export function chunkMarkdown(markdown: string, config?: ChunkConfig): TextChunk[] { + const service = config ? new ChunkService(config) : getChunkService(); + return service.chunkMarkdown(markdown).chunks; +} + +export default ChunkService; + + diff --git a/backend/src/common/rag/DocumentIngestService.ts b/backend/src/common/rag/DocumentIngestService.ts new file mode 100644 index 00000000..a118f0fc --- /dev/null +++ b/backend/src/common/rag/DocumentIngestService.ts @@ -0,0 +1,337 @@ +/** + * DocumentIngestService - 文档入库服务 + * + * 负责文档的完整入库流程: + * 1. 调用 Python 微服务转换为 Markdown + * 2. 文本分块 + * 3. 向量化 + * 4. 存入数据库 + * + * 支持异步任务模式(通过 PgBoss) + */ + +import { PrismaClient, Prisma } from '@prisma/client'; +import { logger } from '../logging/index.js'; +import { getEmbeddingService } from './EmbeddingService.js'; +import { getChunkService, TextChunk } from './ChunkService.js'; +import crypto from 'crypto'; + +// ==================== 类型定义 ==================== + +export interface IngestOptions { + kbId: string; // 知识库 ID + generateSummary?: boolean; // 是否生成摘要(消耗 LLM) + extractClinicalData?: boolean; // 是否提取临床数据(消耗 LLM) + contentType?: string; // 内容类型 + tags?: string[]; // 标签 + metadata?: Record; // 额外元数据 +} + +export interface IngestResult { + success: boolean; + documentId?: string; + chunkCount?: number; + tokenCount?: number; + error?: string; + duration?: number; // 处理耗时(毫秒) +} + +export interface DocumentInput { + filename: string; + fileUrl?: string; // OSS/本地文件路径 + fileBuffer?: Buffer; // 文件内容(二选一) + mimeType?: string; +} + +// ==================== 配置 ==================== + +const PYTHON_SERVICE_URL = process.env.EXTRACTION_SERVICE_URL || 'http://localhost:8000'; + +// ==================== DocumentIngestService ==================== + +export class DocumentIngestService { + private prisma: PrismaClient; + + constructor(prisma: PrismaClient) { + this.prisma = prisma; + logger.info('DocumentIngestService 初始化完成'); + } + + /** + * 入库单个文档(完整流程) + */ + async ingestDocument( + input: DocumentInput, + options: IngestOptions + ): Promise { + const startTime = Date.now(); + const { filename, fileUrl, fileBuffer } = input; + const { kbId, contentType, tags, metadata } = options; + + logger.info(`开始入库文档: ${filename}, kbId=${kbId}`); + + try { + // Step 1: 计算文件哈希(用于去重和秒传) + let fileHash: string | undefined; + if (fileBuffer) { + fileHash = crypto.createHash('sha256').update(fileBuffer).digest('hex'); + + // 检查是否已存在 + const existing = await this.prisma.ekbDocument.findFirst({ + where: { kbId, fileHash }, + }); + + if (existing) { + logger.info(`文档已存在(秒传): ${filename}, docId=${existing.id}`); + return { + success: true, + documentId: existing.id, + chunkCount: await this.prisma.ekbChunk.count({ where: { documentId: existing.id } }), + duration: Date.now() - startTime, + }; + } + } + + // Step 2: 调用 Python 微服务转换为 Markdown + const markdown = await this.convertToMarkdown(input); + + if (!markdown || markdown.trim().length === 0) { + throw new Error('文档转换失败:内容为空'); + } + + // Step 3: 文本分块 + const chunkService = getChunkService(); + const { chunks } = chunkService.chunkMarkdown(markdown); + + if (chunks.length === 0) { + throw new Error('文档分块失败:无有效内容'); + } + + // Step 4: 批量向量化 + const embeddingService = getEmbeddingService(); + const texts = chunks.map(c => c.content); + const { embeddings, totalTokens } = await embeddingService.embedBatch(texts); + + // Step 5: 创建文档记录 + const document = await this.prisma.ekbDocument.create({ + data: { + kbId, + userId: 'system', // TODO: 从上下文获取用户 ID + filename, + fileType: this.getFileType(filename), + fileSizeBytes: fileBuffer?.length || 0, + fileUrl: fileUrl || '', + fileHash: fileHash || null, + extractedText: markdown, + contentType: contentType || this.detectContentType(filename), + tags: tags || [], + metadata: (metadata || {}) as Prisma.InputJsonValue, + tokenCount: totalTokens, + pageCount: this.estimatePageCount(markdown), + status: 'completed', + }, + }); + + // Step 6: 批量创建分块记录 + const chunkData = chunks.map((chunk, index) => ({ + documentId: document.id, + content: chunk.content, + chunkIndex: index, + embedding: embeddings[index], + tokenCount: Math.round(totalTokens / chunks.length), // 估算 + metadata: chunk.metadata || {}, + })); + + // 使用 createMany 批量插入(性能优化) + // 注意:pgvector 的 embedding 需要特殊处理 + // 实际列名: id, document_id, content, chunk_index, embedding, page_number, section_type, metadata, created_at + for (const data of chunkData) { + await this.prisma.$executeRaw` + INSERT INTO "ekb_schema"."ekb_chunk" + (id, document_id, content, chunk_index, embedding, metadata, created_at) + VALUES ( + gen_random_uuid(), + ${data.documentId}, + ${data.content}, + ${data.chunkIndex}, + ${`[${data.embedding.join(',')}]`}::vector, + ${JSON.stringify(data.metadata)}::jsonb, + NOW() + ) + `; + } + + const duration = Date.now() - startTime; + logger.info(`文档入库完成: ${filename}, chunks=${chunks.length}, tokens=${totalTokens}, 耗时=${duration}ms`); + + return { + success: true, + documentId: document.id, + chunkCount: chunks.length, + tokenCount: totalTokens, + duration, + }; + + } catch (error) { + const duration = Date.now() - startTime; + const errorMessage = error instanceof Error ? error.message : String(error); + + logger.error(`文档入库失败: ${filename}`, { error: errorMessage, duration }); + + return { + success: false, + error: errorMessage, + duration, + }; + } + } + + /** + * 调用 Python 微服务转换文档为 Markdown + */ + private async convertToMarkdown(input: DocumentInput): Promise { + const { filename, fileUrl, fileBuffer } = input; + + try { + let response: Response; + + if (fileBuffer) { + // 上传文件 + const formData = new FormData(); + const blob = new Blob([fileBuffer]); + formData.append('file', blob, filename); + + response = await fetch(`${PYTHON_SERVICE_URL}/api/document/to-markdown`, { + method: 'POST', + body: formData, + }); + } else if (fileUrl) { + // TODO: 支持 URL 方式 + throw new Error('URL 方式暂不支持,请使用 fileBuffer'); + } else { + throw new Error('必须提供 fileBuffer 或 fileUrl'); + } + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Python 服务返回错误: ${response.status} - ${errorText}`); + } + + const result = await response.json() as { success: boolean; text?: string; error?: string }; + + if (!result.success) { + throw new Error(result.error || '转换失败'); + } + + return result.text || ''; + + } catch (error) { + logger.error('调用 Python 微服务失败', { error, filename }); + throw error; + } + } + + /** + * 获取文件扩展名类型 + */ + private getFileType(filename: string): string { + const ext = filename.toLowerCase().split('.').pop(); + return ext || 'unknown'; + } + + /** + * 根据文件名检测内容类型 + */ + private detectContentType(filename: string): string { + const ext = filename.toLowerCase().split('.').pop(); + + const typeMap: Record = { + pdf: 'LITERATURE', + docx: 'DOCUMENT', + doc: 'DOCUMENT', + txt: 'NOTE', + md: 'NOTE', + xlsx: 'DATA', + xls: 'DATA', + csv: 'DATA', + pptx: 'PRESENTATION', + ppt: 'PRESENTATION', + }; + + return typeMap[ext || ''] || 'OTHER'; + } + + /** + * 估算页数 + */ + private estimatePageCount(content: string): number { + // 假设每页约 2000 字符 + return Math.max(1, Math.ceil(content.length / 2000)); + } + + /** + * 删除文档及其分块 + */ + async deleteDocument(documentId: string): Promise { + try { + // Cascade 删除会自动删除关联的 chunks + await this.prisma.ekbDocument.delete({ + where: { id: documentId }, + }); + + logger.info(`文档删除成功: ${documentId}`); + return true; + } catch (error) { + logger.error('文档删除失败', { error, documentId }); + return false; + } + } + + /** + * 获取文档处理状态 + */ + async getDocumentStatus(documentId: string): Promise<{ + status: string; + chunkCount: number; + tokenCount: number; + } | null> { + try { + const document = await this.prisma.ekbDocument.findUnique({ + where: { id: documentId }, + select: { status: true, tokenCount: true }, + }); + + if (!document) return null; + + const chunkCount = await this.prisma.ekbChunk.count({ + where: { documentId }, + }); + + return { + status: document.status, + chunkCount, + tokenCount: document.tokenCount || 0, + }; + } catch (error) { + logger.error('获取文档状态失败', { error, documentId }); + return null; + } + } +} + +// ==================== 单例导出 ==================== + +let _documentIngestService: DocumentIngestService | null = null; + +/** + * 获取 DocumentIngestService 单例 + */ +export function getDocumentIngestService(prisma: PrismaClient): DocumentIngestService { + if (!_documentIngestService) { + _documentIngestService = new DocumentIngestService(prisma); + } + return _documentIngestService; +} + +export default DocumentIngestService; + diff --git a/backend/src/common/rag/EmbeddingService.ts b/backend/src/common/rag/EmbeddingService.ts new file mode 100644 index 00000000..0941f877 --- /dev/null +++ b/backend/src/common/rag/EmbeddingService.ts @@ -0,0 +1,239 @@ +/** + * EmbeddingService - 文本向量化服务 + * + * 使用阿里云 DashScope text-embedding-v4 模型 + * 通过 OpenAI 兼容接口调用 + * + * @see https://help.aliyun.com/zh/model-studio/developer-reference/text-embedding-api + */ + +import OpenAI from 'openai'; +import { logger } from '../logging/index.js'; + +// ==================== 类型定义 ==================== + +export interface EmbeddingResult { + embedding: number[]; + tokenCount: number; +} + +export interface BatchEmbeddingResult { + embeddings: number[][]; + totalTokens: number; +} + +export interface EmbeddingConfig { + apiKey?: string; + baseUrl?: string; + model?: string; + dimensions?: number; // text-embedding-v4 支持 512/1024/2048,不传则使用模型默认值 +} + +// ==================== 默认配置 ==================== + +/** + * 环境变量说明(文本向量模型专用): + * + * - DASHSCOPE_API_KEY: 阿里云百炼 API Key(必填,可与其他模型共用) + * + * - TEXT_EMBEDDING_BASE_URL: 文本向量 API 地址(可选) + * - 北京地域(默认): https://dashscope.aliyuncs.com/compatible-mode/v1 + * - 新加坡地域: https://dashscope-intl.aliyuncs.com/compatible-mode/v1 + * + * - TEXT_EMBEDDING_MODEL: 向量模型名称(可选,默认 text-embedding-v4) + * - text-embedding-v4: 最新版,推荐 + * - text-embedding-v3: 旧版 + * + * - TEXT_EMBEDDING_DIMENSIONS: 向量维度(可选,默认 1024) + * - text-embedding-v4 支持: 512, 1024, 2048 + */ + +// 使用函数延迟读取环境变量,确保 dotenv 已加载 +function getDefaultConfig() { + return { + apiKey: process.env.DASHSCOPE_API_KEY || '', + baseUrl: process.env.TEXT_EMBEDDING_BASE_URL || 'https://dashscope.aliyuncs.com/compatible-mode/v1', + model: process.env.TEXT_EMBEDDING_MODEL || 'text-embedding-v4', + dimensions: process.env.TEXT_EMBEDDING_DIMENSIONS + ? parseInt(process.env.TEXT_EMBEDDING_DIMENSIONS, 10) + : 1024, + }; +} + +// ==================== EmbeddingService ==================== + +export class EmbeddingService { + private client: OpenAI; + private model: string; + private dimensions?: number; + + constructor(config: EmbeddingConfig = {}) { + const finalConfig = { ...getDefaultConfig(), ...config }; + + if (!finalConfig.apiKey) { + throw new Error('DASHSCOPE_API_KEY 未配置,请在环境变量中设置'); + } + + this.client = new OpenAI({ + apiKey: finalConfig.apiKey, + baseURL: finalConfig.baseUrl, + }); + + this.model = finalConfig.model; + this.dimensions = finalConfig.dimensions; + + logger.info(`EmbeddingService 初始化完成: model=${this.model}, dimensions=${this.dimensions}`); + } + + /** + * 单文本向量化 + */ + async embed(text: string): Promise { + try { + // 构建请求参数(与官方示例一致) + const params: OpenAI.EmbeddingCreateParams = { + model: this.model, + input: text, + }; + + // dimensions 为可选参数,仅在配置时传递 + if (this.dimensions) { + params.dimensions = this.dimensions; + } + + const response = await this.client.embeddings.create(params); + + const embedding = response.data[0].embedding; + const tokenCount = response.usage?.total_tokens || 0; + + logger.debug(`文本向量化完成: ${text.substring(0, 50)}... tokens=${tokenCount}`); + + return { + embedding, + tokenCount, + }; + } catch (error) { + logger.error('文本向量化失败', { error, text: text.substring(0, 100) }); + throw new Error(`向量化失败: ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * 批量文本向量化 + * + * 注意:DashScope 单次请求最多支持 25 条文本 + */ + async embedBatch(texts: string[]): Promise { + if (texts.length === 0) { + return { embeddings: [], totalTokens: 0 }; + } + + // DashScope 限制:单次最多 10 条 + const BATCH_SIZE = 10; + const allEmbeddings: number[][] = []; + let totalTokens = 0; + + for (let i = 0; i < texts.length; i += BATCH_SIZE) { + const batch = texts.slice(i, i + BATCH_SIZE); + + try { + // 构建请求参数(与官方示例一致) + const params: OpenAI.EmbeddingCreateParams = { + model: this.model, + input: batch, + }; + + if (this.dimensions) { + params.dimensions = this.dimensions; + } + + const response = await this.client.embeddings.create(params); + + // 按原始顺序排列 + const sortedData = response.data.sort((a, b) => a.index - b.index); + allEmbeddings.push(...sortedData.map(d => d.embedding)); + totalTokens += response.usage?.total_tokens || 0; + + logger.debug(`批量向量化进度: ${Math.min(i + BATCH_SIZE, texts.length)}/${texts.length}`); + } catch (error) { + logger.error(`批量向量化失败 (batch ${i}-${i + batch.length})`, { error }); + throw error; + } + } + + logger.info(`批量向量化完成: ${texts.length} 条文本, ${totalTokens} tokens`); + + return { + embeddings: allEmbeddings, + totalTokens, + }; + } + + /** + * 计算两个向量的余弦相似度 + */ + static cosineSimilarity(a: number[], b: number[]): number { + if (a.length !== b.length) { + throw new Error('向量维度不匹配'); + } + + let dotProduct = 0; + let normA = 0; + let normB = 0; + + for (let i = 0; i < a.length; i++) { + dotProduct += a[i] * b[i]; + normA += a[i] * a[i]; + normB += b[i] * b[i]; + } + + return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB)); + } + + /** + * 获取当前配置信息 + */ + getConfig(): { model: string; dimensions?: number } { + return { + model: this.model, + dimensions: this.dimensions, + }; + } +} + +// ==================== 单例导出 ==================== + +let _embeddingService: EmbeddingService | null = null; + +/** + * 获取 EmbeddingService 单例 + * + * 首次调用时初始化,后续调用返回同一实例 + */ +export function getEmbeddingService(config?: EmbeddingConfig): EmbeddingService { + if (!_embeddingService) { + _embeddingService = new EmbeddingService(config); + } + return _embeddingService; +} + +/** + * 快捷方法:单文本向量化 + */ +export async function embed(text: string): Promise { + const service = getEmbeddingService(); + const result = await service.embed(text); + return result.embedding; +} + +/** + * 快捷方法:批量文本向量化 + */ +export async function embedBatch(texts: string[]): Promise { + const service = getEmbeddingService(); + const result = await service.embedBatch(texts); + return result.embeddings; +} + +export default EmbeddingService; + diff --git a/backend/src/common/rag/QueryRewriter.ts b/backend/src/common/rag/QueryRewriter.ts new file mode 100644 index 00000000..a41e638e --- /dev/null +++ b/backend/src/common/rag/QueryRewriter.ts @@ -0,0 +1,155 @@ +/** + * QueryRewriter - 查询重写服务 + * + * 功能: + * - 检测中文查询 + * - 调用 DeepSeek V3 翻译为英文医学术语 + * - 生成同义扩展查询 + * + * 用于跨语言检索优化 + */ + +import { logger } from '../logging/index.js'; +import { LLMFactory } from '../llm/adapters/LLMFactory.js'; +import type { ILLMAdapter } from '../llm/adapters/types.js'; + +// ==================== 类型定义 ==================== + +export interface RewriteResult { + original: string; // 原始查询 + rewritten: string[]; // 重写后的查询列表 + isChinese: boolean; // 是否为中文查询 + cost: number; // 成本(元) + duration: number; // 耗时(毫秒) +} + +// ==================== QueryRewriter ==================== + +export class QueryRewriter { + private llmAdapter: ILLMAdapter; + + constructor(llmAdapter?: ILLMAdapter) { + // 如果未传入,使用默认的 DeepSeek V3 + this.llmAdapter = llmAdapter || LLMFactory.getAdapter('deepseek-v3'); + logger.info('QueryRewriter 初始化完成 (使用 DeepSeek V3)'); + } + + /** + * 重写查询(如果是中文) + */ + async rewrite(query: string): Promise { + const startTime = Date.now(); + + // 1. 检测是否包含中文 + const isChinese = this.containsChinese(query); + + if (!isChinese) { + // 非中文直接返回 + return { + original: query, + rewritten: [query], + isChinese: false, + cost: 0, + duration: Date.now() - startTime, + }; + } + + // 2. 调用 LLM 重写查询 + try { + const prompt = `你是医学检索专家。将以下中文查询翻译为精准的英文医学术语,并提供1-2个同义扩展查询。 +只返回JSON数组格式,不要其他内容。 + +示例输入:帕博利珠单抗治疗肺癌的效果 +示例输出:["Pembrolizumab efficacy in lung cancer", "Keytruda treatment for NSCLC"] + +现在请处理:${query}`; + + const response = await this.llmAdapter.chat( + [{ role: 'user', content: prompt }], + { + temperature: 0.3, // 低温度,更确定性 + maxTokens: 100, // 短输出 + } + ); + + const content = response.content.trim(); + + // 3. 解析 JSON 数组 + const rewritten = this.parseRewrittenQueries(content, query); + + // 4. 计算成本(DeepSeek V3: 输入 ¥0.5/百万,输出 ¥2/百万) + const inputTokens = response.usage?.promptTokens || 50; + const outputTokens = response.usage?.completionTokens || 30; + const cost = (inputTokens * 0.5 + outputTokens * 2) / 1_000_000; + + const duration = Date.now() - startTime; + + logger.info(`查询重写完成: "${query}" → ${rewritten.length}条`, { + original: query, + rewritten, + cost: `¥${cost.toFixed(6)}`, + duration: `${duration}ms`, + }); + + return { + original: query, + rewritten, + isChinese: true, + cost, + duration, + }; + + } catch (error) { + logger.error('查询重写失败,返回原查询', { error, query }); + + // 降级:返回原查询 + return { + original: query, + rewritten: [query], + isChinese: true, + cost: 0, + duration: Date.now() - startTime, + }; + } + } + + /** + * 检测是否包含中文 + */ + private containsChinese(text: string): boolean { + return /[\u4e00-\u9fa5]/.test(text); + } + + /** + * 解析 LLM 返回的查询列表 + */ + private parseRewrittenQueries(content: string, fallback: string): string[] { + try { + // 尝试直接解析 JSON + const parsed = JSON.parse(content); + if (Array.isArray(parsed) && parsed.length > 0) { + return parsed.filter(q => typeof q === 'string' && q.length > 0); + } + } catch { + // JSON 解析失败,尝试提取 + const match = content.match(/\[([^\]]+)\]/); + if (match) { + try { + const parsed = JSON.parse(match[0]); + if (Array.isArray(parsed)) { + return parsed.filter(q => typeof q === 'string' && q.length > 0); + } + } catch {} + } + } + + // 都失败了,返回原查询 + logger.warn('LLM 返回格式异常,使用原查询', { content, fallback }); + return [fallback]; + } +} + +// ==================== 导出 ==================== + +export default QueryRewriter; + diff --git a/backend/src/common/rag/RerankService.ts b/backend/src/common/rag/RerankService.ts new file mode 100644 index 00000000..45e65a22 --- /dev/null +++ b/backend/src/common/rag/RerankService.ts @@ -0,0 +1,210 @@ +/** + * RerankService - 重排序服务 + * + * 使用阿里云 qwen3-rerank 模型 + * 通过 OpenAI 兼容接口调用 + * + * @see https://help.aliyun.com/zh/model-studio/text-rerank-api + */ + +import { logger } from '../logging/index.js'; + +// ==================== 类型定义 ==================== + +export interface RerankDocument { + text: string; + index?: number; // 可选:原始索引 + metadata?: Record; +} + +export interface RerankResult { + text: string; + index: number; // 原始索引 + relevanceScore: number; // 相关性分数 (0-1) + metadata?: Record; +} + +export interface RerankOptions { + topN?: number; // 返回数量,默认 10 + instruct?: string; // 任务指令(可选) +} + +export interface RerankConfig { + apiKey?: string; + baseUrl?: string; + model?: string; +} + +// ==================== 默认配置 ==================== + +/** + * 环境变量说明(Rerank 模型专用): + * + * - DASHSCOPE_API_KEY: 阿里云百炼 API Key(必填,可与其他模型共用) + * + * - RERANK_BASE_URL: Rerank API 地址(可选) + * - 默认: https://dashscope.aliyuncs.com/compatible-api/v1 + * + * - RERANK_MODEL: Rerank 模型名称(可选,默认 qwen3-rerank) + */ +function getDefaultConfig() { + return { + apiKey: process.env.DASHSCOPE_API_KEY || '', + baseUrl: process.env.RERANK_BASE_URL || 'https://dashscope.aliyuncs.com/compatible-api/v1', + model: process.env.RERANK_MODEL || 'qwen3-rerank', + }; +} + +// ==================== RerankService ==================== + +export class RerankService { + private apiKey: string; + private baseUrl: string; + private model: string; + + constructor(config: RerankConfig = {}) { + const finalConfig = { ...getDefaultConfig(), ...config }; + + if (!finalConfig.apiKey) { + throw new Error('DASHSCOPE_API_KEY 未配置,请在环境变量中设置'); + } + + this.apiKey = finalConfig.apiKey; + this.baseUrl = finalConfig.baseUrl; + this.model = finalConfig.model; + + logger.info(`RerankService 初始化完成: model=${this.model}`); + } + + /** + * 重排序文档 + * + * 限制: + * - 单个 Query/Document 最大 4000 tokens + * - 最多 500 个 documents + * - 总 tokens 不超过 30000 + */ + async rerank( + query: string, + documents: RerankDocument[], + options: RerankOptions = {} + ): Promise { + if (documents.length === 0) { + return []; + } + + const { topN = 10, instruct } = options; + + // 限制 documents 数量 + const maxDocs = Math.min(documents.length, 500); + const limitedDocs = documents.slice(0, maxDocs); + + try { + const requestBody = { + model: this.model, + query, + documents: limitedDocs.map(doc => doc.text), + top_n: Math.min(topN, limitedDocs.length), + ...(instruct && { instruct }), + }; + + logger.debug(`Rerank 请求: query="${query.substring(0, 30)}...", docs=${limitedDocs.length}, topN=${topN}`); + + // 调试日志 + logger.debug(`Rerank API URL: ${this.baseUrl}/reranks`); + logger.debug(`Rerank 请求体: ${JSON.stringify(requestBody).substring(0, 200)}...`); + + const response = await fetch(`${this.baseUrl}/reranks`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }); + + const responseText = await response.text(); + logger.debug(`Rerank 响应状态: ${response.status}`); + logger.debug(`Rerank 响应内容: ${responseText.substring(0, 500)}...`); + + if (!response.ok) { + throw new Error(`Rerank API 返回错误: ${response.status} - ${responseText}`); + } + + const result = JSON.parse(responseText) as { + object: string; + results: Array<{ + index: number; + relevance_score: number; + }>; + model: string; + usage: { total_tokens: number }; + id: string; + }; + + const totalTokens = result.usage?.total_tokens || 0; + const cost = (totalTokens * 0.8) / 1_000_000; // ¥0.8/百万token + + logger.info(`Rerank 完成: 返回 ${result.results.length} 条, tokens=${totalTokens}, cost=¥${cost.toFixed(6)}`); + + // 映射回原始 metadata + return result.results.map(r => ({ + text: limitedDocs[r.index].text, + index: r.index, + relevanceScore: r.relevance_score, + metadata: limitedDocs[r.index]?.metadata, + })); + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const errorDetails = error instanceof Error ? error.stack : JSON.stringify(error); + + logger.error('Rerank 失败', { + error: errorMessage, + details: errorDetails, + query: query.substring(0, 100), + docCount: limitedDocs.length, + }); + throw error; + } + } + + /** + * 获取当前配置 + */ + getConfig(): { model: string; baseUrl: string } { + return { + model: this.model, + baseUrl: this.baseUrl, + }; + } +} + +// ==================== 单例导出 ==================== + +let _rerankService: RerankService | null = null; + +/** + * 获取 RerankService 单例 + */ +export function getRerankService(config?: RerankConfig): RerankService { + if (!_rerankService) { + _rerankService = new RerankService(config); + } + return _rerankService; +} + +/** + * 快捷方法:重排序 + */ +export async function rerank( + query: string, + documents: RerankDocument[], + options?: RerankOptions +): Promise { + const service = getRerankService(); + return service.rerank(query, documents, options); +} + +export default RerankService; + diff --git a/backend/src/common/rag/VectorSearchService.ts b/backend/src/common/rag/VectorSearchService.ts new file mode 100644 index 00000000..e3790fa8 --- /dev/null +++ b/backend/src/common/rag/VectorSearchService.ts @@ -0,0 +1,448 @@ +/** + * VectorSearchService - 向量检索服务 + * + * 基于 pgvector 实现语义检索 + * 支持: + * - 纯向量检索(余弦相似度) + * - 混合检索(向量 + 关键词,RRF 融合) + * - Rerank 重排序 + */ + +import { PrismaClient, Prisma } from '@prisma/client'; +import { logger } from '../logging/index.js'; +import { getEmbeddingService } from './EmbeddingService.js'; +import { getRerankService } from './RerankService.js'; + +// ==================== 类型定义 ==================== + +export interface SearchResult { + chunkId: string; + documentId: string; + content: string; + score: number; // 相似度分数 (0-1) + metadata?: Record; +} + +export interface SearchOptions { + topK?: number; // 返回数量,默认 10 + minScore?: number; // 最低分数阈值,默认 0.5 + filter?: SearchFilter; // 过滤条件 +} + +export interface SearchFilter { + kbId?: string; // 知识库 ID + documentIds?: string[]; // 文档 ID 列表 + contentType?: string; // 内容类型 + tags?: string[]; // 标签(任一匹配) +} + +export interface HybridSearchOptions extends SearchOptions { + vectorWeight?: number; // 向量检索权重,默认 0.7 + keywordWeight?: number; // 关键词检索权重,默认 0.3 +} + +export interface RerankOptions { + model?: string; // Rerank 模型 + topK?: number; // 重排后返回数量 +} + +// ==================== VectorSearchService ==================== + +export class VectorSearchService { + private prisma: PrismaClient; + + constructor(prisma: PrismaClient) { + this.prisma = prisma; + logger.info('VectorSearchService 初始化完成'); + } + + /** + * 向量语义检索(单查询) + */ + async vectorSearch( + query: string, + options: SearchOptions = {} + ): Promise { + return this.searchWithQueries([query], options); + } + + /** + * 多查询向量检索(引擎核心方法) + * + * 接收业务层生成的多个查询词,并行检索后 RRF 融合 + * + * @param queries 查询词列表(由业务层 DeepSeek 生成) + * @param options 检索选项 + */ + async searchWithQueries( + queries: string[], + options: SearchOptions = {} + ): Promise { + const { topK = 10, minScore = 0.5, filter } = options; + + if (queries.length === 0) { + return []; + } + + try { + // 单查询:直接检索 + if (queries.length === 1) { + return this.vectorSearchSingle(queries[0], { topK, minScore, filter }); + } + + // 多查询:并行检索 + RRF 融合 + const allResults = await Promise.all( + queries.map(q => this.vectorSearchSingle(q, { topK: topK * 2, minScore, filter })) + ); + + const fused = this.fuseMultiQueryResults(allResults, topK); + + logger.info(`多查询检索完成: ${queries.length}条查询 → ${fused.length}条结果`); + + return fused; + + } catch (error) { + logger.error('向量检索失败', { error, queries }); + throw error; + } + } + + /** + * 单查询向量检索(内部方法) + */ + private async vectorSearchSingle( + query: string, + options: { topK: number; minScore: number; filter?: SearchFilter } + ): Promise { + const { topK, minScore, filter } = options; + + try { + // 1. 将查询文本向量化 + const embeddingService = getEmbeddingService(); + const { embedding } = await embeddingService.embed(query); + + // 2. 构建 SQL 查询(使用 pgvector 的余弦距离) + const vectorStr = `[${embedding.join(',')}]`; + + // 构建过滤条件(直接嵌入值,用于 $queryRawUnsafe) + const whereConditions: string[] = []; + + if (filter?.kbId) { + // 转义单引号防止 SQL 注入 + const safeKbId = filter.kbId.replace(/'/g, "''"); + whereConditions.push(`d."kb_id" = '${safeKbId}'`); + } + + if (filter?.documentIds && filter.documentIds.length > 0) { + const safeIds = filter.documentIds.map(id => `'${id.replace(/'/g, "''")}'`).join(','); + whereConditions.push(`c."document_id" IN (${safeIds})`); + } + + if (filter?.contentType) { + const safeContentType = filter.contentType.replace(/'/g, "''"); + whereConditions.push(`d."content_type" = '${safeContentType}'`); + } + + const whereClause = whereConditions.length > 0 + ? `WHERE ${whereConditions.join(' AND ')}` + : ''; + + // 3. 执行向量检索 + // 注意:Prisma 将表名转换为小写下划线格式 + // 使用 $queryRawUnsafe 避免参数类型推断问题 + const sql = ` + SELECT + c.id as "chunkId", + c.document_id as "documentId", + c.content, + 1 - (c.embedding <=> '${vectorStr}'::vector) as score, + c.metadata + FROM "ekb_schema"."ekb_chunk" c + JOIN "ekb_schema"."ekb_document" d ON c.document_id = d.id + ${whereClause} + ORDER BY c.embedding <=> '${vectorStr}'::vector + LIMIT ${topK} + `; + + const results = await this.prisma.$queryRawUnsafe(sql); + + // 4. 过滤低分结果 + const filtered = results.filter(r => r.score >= minScore); + + logger.info(`向量检索完成: query="${query.substring(0, 30)}...", 返回 ${filtered.length} 条`); + + return filtered; + } catch (error) { + logger.error('向量检索失败', { error, query: query.substring(0, 100) }); + throw error; + } + } + + /** + * 关键词检索(基于 PostgreSQL 全文搜索) + * + * 注意:完整的 pg_bigm 支持需要安装扩展 + * MVP 阶段使用 ILIKE 模糊匹配 + */ + async keywordSearch( + query: string, + options: SearchOptions = {} + ): Promise { + const { topK = 10, filter } = options; + + try { + // 构建过滤条件 + const whereConditions: Prisma.EkbChunkWhereInput[] = [ + { content: { contains: query, mode: 'insensitive' } } + ]; + + if (filter?.kbId) { + whereConditions.push({ document: { kbId: filter.kbId } }); + } + + if (filter?.documentIds && filter.documentIds.length > 0) { + whereConditions.push({ documentId: { in: filter.documentIds } }); + } + + const chunks = await this.prisma.ekbChunk.findMany({ + where: { AND: whereConditions }, + take: topK, + select: { + id: true, + documentId: true, + content: true, + metadata: true, + }, + }); + + // 简单的关键词匹配分数(基于出现次数) + const results: SearchResult[] = chunks.map(chunk => { + const occurrences = (chunk.content.match(new RegExp(query, 'gi')) || []).length; + const score = Math.min(1, occurrences * 0.2 + 0.5); // 简单评分 + return { + chunkId: chunk.id, + documentId: chunk.documentId, + content: chunk.content, + score, + metadata: chunk.metadata as Record | undefined, + }; + }); + + logger.info(`关键词检索完成: query="${query}", 返回 ${results.length} 条`); + + return results.sort((a, b) => b.score - a.score); + } catch (error) { + logger.error('关键词检索失败', { error, query }); + throw error; + } + } + + /** + * 混合检索(向量 + 关键词,RRF 融合) + * + * 注意:如果 query 为中文但文档为英文,业务层应先调用 DeepSeek 翻译 + */ + async hybridSearch( + query: string, + options: HybridSearchOptions = {} + ): Promise { + const { + topK = 10, + vectorWeight = 0.7, + keywordWeight = 0.3, + ...baseOptions + } = options; + + try { + // 并行执行两种检索 + const [vectorResults, keywordResults] = await Promise.all([ + this.vectorSearch(query, { ...baseOptions, topK: topK * 2 }), + this.keywordSearch(query, { ...baseOptions, topK: topK * 2 }), + ]); + + // RRF (Reciprocal Rank Fusion) 融合 + const rrfScores = new Map(); + const k = 60; // RRF 常数 + + // 处理向量检索结果 + vectorResults.forEach((result, rank) => { + const rrfScore = vectorWeight / (k + rank + 1); + const existing = rrfScores.get(result.chunkId); + if (existing) { + existing.score += rrfScore; + } else { + rrfScores.set(result.chunkId, { result, score: rrfScore }); + } + }); + + // 处理关键词检索结果 + keywordResults.forEach((result, rank) => { + const rrfScore = keywordWeight / (k + rank + 1); + const existing = rrfScores.get(result.chunkId); + if (existing) { + existing.score += rrfScore; + } else { + rrfScores.set(result.chunkId, { result, score: rrfScore }); + } + }); + + // 排序并返回 + const merged = Array.from(rrfScores.values()) + .sort((a, b) => b.score - a.score) + .slice(0, topK) + .map(({ result, score }) => ({ + ...result, + score: Math.min(1, score * 100), // 归一化 + })); + + logger.info(`混合检索完成: query="${query.substring(0, 30)}...", 返回 ${merged.length} 条`); + + return merged; + } catch (error) { + logger.error('混合检索失败', { error, query: query.substring(0, 100) }); + throw error; + } + } + + /** + * Rerank 重排序 + * + * 使用阿里云 qwen3-rerank 模型 + */ + async rerank( + query: string, + results: SearchResult[], + options: RerankOptions = {} + ): Promise { + const { topK = results.length } = options; + + if (results.length === 0) { + return []; + } + + try { + const rerankService = getRerankService(); + + // 转换为 Rerank 输入格式 + const documents = results.map((r, index) => ({ + text: r.content, + index, + metadata: r.metadata, + })); + + // 调用 Rerank API + const reranked = await rerankService.rerank(query, documents, { + topN: topK, + instruct: 'Given a medical query, retrieve relevant passages that answer the query.', + }); + + // 映射回 SearchResult 格式 + return reranked.map(r => { + const original = results[r.index]; + return { + ...original, + score: r.relevanceScore, // 用 Rerank 分数替换原分数 + }; + }); + + } catch (error) { + logger.error('Rerank 失败,返回原始排序', { error }); + return results.slice(0, topK); + } + } + + /** + * 获取文档完整内容(用于小文档全文检索策略) + */ + async getDocumentFullText(documentId: string): Promise { + try { + const document = await this.prisma.ekbDocument.findUnique({ + where: { id: documentId }, + select: { extractedText: true }, + }); + + return document?.extractedText || null; + } catch (error) { + logger.error('获取文档全文失败', { error, documentId }); + throw error; + } + } + + /** + * 融合多个查询的检索结果(RRF) + */ + private fuseMultiQueryResults( + allResults: SearchResult[][], + topK: number + ): SearchResult[] { + const k = 60; // RRF 常数 + const fusedScores = new Map(); + + // 对每个查询的结果应用 RRF + allResults.forEach((results, queryIndex) => { + results.forEach((result, rank) => { + const rrfScore = 1 / (k + rank + 1); + const existing = fusedScores.get(result.chunkId); + + if (existing) { + existing.score += rrfScore; + } else { + fusedScores.set(result.chunkId, { result, score: rrfScore }); + } + }); + }); + + // 排序并返回 + return Array.from(fusedScores.values()) + .sort((a, b) => b.score - a.score) + .slice(0, topK) + .map(({ result, score }) => ({ + ...result, + score: Math.min(1, score * 100), // 归一化 + })); + } + + /** + * 获取知识库所有文档(用于判断检索策略) + */ + async getKnowledgeBaseStats(kbId: string): Promise<{ + documentCount: number; + totalTokens: number; + avgDocumentSize: number; + }> { + try { + const stats = await this.prisma.ekbDocument.aggregate({ + where: { kbId }, + _count: { id: true }, + _sum: { tokenCount: true }, + _avg: { tokenCount: true }, + }); + + return { + documentCount: stats._count.id, + totalTokens: stats._sum.tokenCount || 0, + avgDocumentSize: Math.round(stats._avg.tokenCount || 0), + }; + } catch (error) { + logger.error('获取知识库统计失败', { error, kbId }); + throw error; + } + } +} + +// ==================== 单例导出 ==================== + +let _vectorSearchService: VectorSearchService | null = null; + +/** + * 获取 VectorSearchService 单例 + */ +export function getVectorSearchService(prisma: PrismaClient): VectorSearchService { + if (!_vectorSearchService) { + _vectorSearchService = new VectorSearchService(prisma); + } + return _vectorSearchService; +} + +export default VectorSearchService; + diff --git a/backend/src/common/rag/index.ts b/backend/src/common/rag/index.ts new file mode 100644 index 00000000..1b82456c --- /dev/null +++ b/backend/src/common/rag/index.ts @@ -0,0 +1,66 @@ +/** + * RAG 引擎 - 统一导出 + * + * 基于 PostgreSQL + pgvector 的 RAG 实现 + * 替代原 Dify 外部服务 + */ + +// ==================== 服务导出 ==================== + +export { + EmbeddingService, + getEmbeddingService, + embed, + embedBatch, + type EmbeddingResult, + type BatchEmbeddingResult, + type EmbeddingConfig, +} from './EmbeddingService.js'; + +export { + ChunkService, + getChunkService, + chunkText, + chunkMarkdown, + type ChunkConfig, + type TextChunk, + type ChunkResult, +} from './ChunkService.js'; + +export { + VectorSearchService, + getVectorSearchService, + type SearchResult, + type SearchOptions, + type SearchFilter, + type HybridSearchOptions, + type RerankOptions, +} from './VectorSearchService.js'; + +// QueryRewriter 独立导出(供业务层使用) +export { default as QueryRewriter, type RewriteResult } from './QueryRewriter.js'; + + +export { + RerankService, + getRerankService, + rerank, + type RerankDocument, + type RerankResult, + type RerankOptions as RerankServiceOptions, + type RerankConfig, +} from './RerankService.js'; + +export { + DocumentIngestService, + getDocumentIngestService, + type IngestOptions, + type IngestResult, + type DocumentInput, +} from './DocumentIngestService.js'; + +// ==================== 旧版兼容(Dify)==================== + +export { DifyClient } from './DifyClient.js'; +export * from './types.js'; + diff --git a/backend/src/common/streaming/OpenAIStreamAdapter.ts b/backend/src/common/streaming/OpenAIStreamAdapter.ts index 2d2edddd..1b9041fe 100644 --- a/backend/src/common/streaming/OpenAIStreamAdapter.ts +++ b/backend/src/common/streaming/OpenAIStreamAdapter.ts @@ -200,3 +200,6 @@ export function createOpenAIStreamAdapter( + + + diff --git a/backend/src/common/streaming/StreamingService.ts b/backend/src/common/streaming/StreamingService.ts index 5210235c..09b8d088 100644 --- a/backend/src/common/streaming/StreamingService.ts +++ b/backend/src/common/streaming/StreamingService.ts @@ -206,3 +206,6 @@ export async function streamChat( + + + diff --git a/backend/src/common/streaming/index.ts b/backend/src/common/streaming/index.ts index 918d56c2..97b3f6b5 100644 --- a/backend/src/common/streaming/index.ts +++ b/backend/src/common/streaming/index.ts @@ -24,3 +24,6 @@ export { THINKING_TAGS } from './types'; + + + diff --git a/backend/src/common/streaming/types.ts b/backend/src/common/streaming/types.ts index 17406ac7..704dd400 100644 --- a/backend/src/common/streaming/types.ts +++ b/backend/src/common/streaming/types.ts @@ -99,3 +99,6 @@ export type SSEEventType = + + + diff --git a/backend/src/modules/admin/routes/tenantRoutes.ts b/backend/src/modules/admin/routes/tenantRoutes.ts index 241f6892..81689ce4 100644 --- a/backend/src/modules/admin/routes/tenantRoutes.ts +++ b/backend/src/modules/admin/routes/tenantRoutes.ts @@ -85,3 +85,6 @@ export async function moduleRoutes(fastify: FastifyInstance) { + + + diff --git a/backend/src/modules/admin/types/tenant.types.ts b/backend/src/modules/admin/types/tenant.types.ts index de3484b1..eb086a08 100644 --- a/backend/src/modules/admin/types/tenant.types.ts +++ b/backend/src/modules/admin/types/tenant.types.ts @@ -115,3 +115,6 @@ export interface PaginatedResponse { + + + diff --git a/backend/src/modules/admin/types/user.types.ts b/backend/src/modules/admin/types/user.types.ts index 1adee826..254dac0a 100644 --- a/backend/src/modules/admin/types/user.types.ts +++ b/backend/src/modules/admin/types/user.types.ts @@ -162,3 +162,6 @@ export const ROLE_DISPLAY_NAMES: Record = { + + + diff --git a/backend/src/modules/aia/controllers/agentController.ts b/backend/src/modules/aia/controllers/agentController.ts index 2a9eaa97..fd467d54 100644 --- a/backend/src/modules/aia/controllers/agentController.ts +++ b/backend/src/modules/aia/controllers/agentController.ts @@ -237,3 +237,6 @@ async function matchIntent(query: string): Promise<{ + + + diff --git a/backend/src/modules/aia/controllers/attachmentController.ts b/backend/src/modules/aia/controllers/attachmentController.ts index 3df36a43..84e376b4 100644 --- a/backend/src/modules/aia/controllers/attachmentController.ts +++ b/backend/src/modules/aia/controllers/attachmentController.ts @@ -91,3 +91,6 @@ export async function uploadAttachment( + + + diff --git a/backend/src/modules/aia/index.ts b/backend/src/modules/aia/index.ts index 85ed343c..158349bf 100644 --- a/backend/src/modules/aia/index.ts +++ b/backend/src/modules/aia/index.ts @@ -20,3 +20,6 @@ export { aiaRoutes }; + + + diff --git a/backend/src/modules/asl/fulltext-screening/__tests__/api-integration-test.ts b/backend/src/modules/asl/fulltext-screening/__tests__/api-integration-test.ts index deb11b4b..8699418f 100644 --- a/backend/src/modules/asl/fulltext-screening/__tests__/api-integration-test.ts +++ b/backend/src/modules/asl/fulltext-screening/__tests__/api-integration-test.ts @@ -360,6 +360,9 @@ runTests().catch((error) => { + + + diff --git a/backend/src/modules/asl/fulltext-screening/__tests__/e2e-real-test-v2.ts b/backend/src/modules/asl/fulltext-screening/__tests__/e2e-real-test-v2.ts index f3ad5d0c..028540ed 100644 --- a/backend/src/modules/asl/fulltext-screening/__tests__/e2e-real-test-v2.ts +++ b/backend/src/modules/asl/fulltext-screening/__tests__/e2e-real-test-v2.ts @@ -301,6 +301,9 @@ runTest() + + + diff --git a/backend/src/modules/asl/fulltext-screening/__tests__/fulltext-screening-api.http b/backend/src/modules/asl/fulltext-screening/__tests__/fulltext-screening-api.http index 211ab1a6..82766b82 100644 --- a/backend/src/modules/asl/fulltext-screening/__tests__/fulltext-screening-api.http +++ b/backend/src/modules/asl/fulltext-screening/__tests__/fulltext-screening-api.http @@ -339,6 +339,9 @@ Content-Type: application/json + + + diff --git a/backend/src/modules/dc/tool-b/services/ConflictDetectionService.ts b/backend/src/modules/dc/tool-b/services/ConflictDetectionService.ts index 3e8d91af..1decee08 100644 --- a/backend/src/modules/dc/tool-b/services/ConflictDetectionService.ts +++ b/backend/src/modules/dc/tool-b/services/ConflictDetectionService.ts @@ -275,6 +275,9 @@ export const conflictDetectionService = new ConflictDetectionService(); + + + diff --git a/backend/src/modules/dc/tool-c/README.md b/backend/src/modules/dc/tool-c/README.md index ba377728..75d21ce8 100644 --- a/backend/src/modules/dc/tool-c/README.md +++ b/backend/src/modules/dc/tool-c/README.md @@ -225,6 +225,9 @@ curl -X POST http://localhost:3000/api/v1/dc/tool-c/test/execute \ + + + diff --git a/backend/src/modules/dc/tool-c/controllers/StreamAIController.ts b/backend/src/modules/dc/tool-c/controllers/StreamAIController.ts index 67d555f2..19ad5f29 100644 --- a/backend/src/modules/dc/tool-c/controllers/StreamAIController.ts +++ b/backend/src/modules/dc/tool-c/controllers/StreamAIController.ts @@ -279,6 +279,9 @@ export const streamAIController = new StreamAIController(); + + + diff --git a/backend/src/modules/dc/tool-c/services/DataProcessService.ts b/backend/src/modules/dc/tool-c/services/DataProcessService.ts index 18e2116f..356bbca1 100644 --- a/backend/src/modules/dc/tool-c/services/DataProcessService.ts +++ b/backend/src/modules/dc/tool-c/services/DataProcessService.ts @@ -46,26 +46,69 @@ export class DataProcessService { * @param buffer - 文件Buffer * @returns 解析后的数据 */ - parseExcel(buffer: Buffer): ParsedExcelData { + parseExcel(buffer: Buffer, fileName?: string): ParsedExcelData { try { - logger.info('[DataProcessService] 开始解析Excel文件'); + logger.info('[DataProcessService] 开始解析文件'); - // 1. 读取Excel文件(内存操作) - const workbook = xlsx.read(buffer, { type: 'buffer' }); + // 1. 读取文件(内存操作) + // ✅ 修复乱码问题:添加 codepage 支持(.xls 和 .csv 文件) + const fileNameLower = fileName?.toLowerCase() ?? ''; + const isXls = fileNameLower.endsWith('.xls') && !fileNameLower.endsWith('.xlsx'); + const isCsv = fileNameLower.endsWith('.csv'); + const needCodepage = isXls || isCsv; + + // 对于 CSV,移除 UTF-8 BOM + let processedBuffer = buffer; + if (isCsv && buffer[0] === 0xEF && buffer[1] === 0xBB && buffer[2] === 0xBF) { + logger.info('[DataProcessService] 检测到 UTF-8 BOM,移除中...'); + processedBuffer = buffer.slice(3); + } + + const workbook = xlsx.read(processedBuffer, { + type: 'buffer', + codepage: needCodepage ? 936 : undefined, // .xls/.csv 文件使用 GBK 编码 + cellDates: true, + }); // 2. 获取第一个工作表 const sheetName = workbook.SheetNames[0]; if (!sheetName) { - throw new Error('Excel文件中没有工作表'); + throw new Error('文件中没有工作表'); } const sheet = workbook.Sheets[sheetName]; // 3. 转换为JSON格式 - const data = xlsx.utils.sheet_to_json(sheet); + let data = xlsx.utils.sheet_to_json(sheet) as any[]; + + // 4. 清理列名中的特殊字符(BOM残留、空白字符) + if (data.length > 0) { + const originalColumns = Object.keys(data[0] || {}); + const columnMapping: Record = {}; + let hasCleanedColumns = false; + + originalColumns.forEach(col => { + const cleanedCol = col.replace(/^\uFEFF/, '').trim(); + if (cleanedCol !== col) { + columnMapping[col] = cleanedCol; + hasCleanedColumns = true; + } + }); + + if (hasCleanedColumns) { + data = data.map((row: any) => { + const newRow: any = {}; + Object.keys(row).forEach(key => { + const newKey = columnMapping[key] || key; + newRow[newKey] = row[key]; + }); + return newRow; + }); + } + } if (data.length === 0) { - throw new Error('Excel文件没有数据'); + throw new Error('文件没有数据'); } // 4. 提取元数据 diff --git a/backend/src/modules/dc/tool-c/services/SessionService.ts b/backend/src/modules/dc/tool-c/services/SessionService.ts index 7f10263e..c2f83fcf 100644 --- a/backend/src/modules/dc/tool-c/services/SessionService.ts +++ b/backend/src/modules/dc/tool-c/services/SessionService.ts @@ -208,20 +208,33 @@ export class SessionService { // 3. ⚠️ Fallback:从原始文件重新解析(兼容旧数据或 clean data 不存在) logger.info(`[SessionService] 从原始文件解析(clean data不存在): ${session.fileKey}`); - const buffer = await storage.download(session.fileKey); + let buffer = await storage.download(session.fileKey); + // ✅ 修复乱码问题:添加 codepage 支持(.xls 和 .csv 文件) + const fileNameLower = session.fileName?.toLowerCase() ?? ''; + const isXls = fileNameLower.endsWith('.xls') && !fileNameLower.endsWith('.xlsx'); + const isCsv = fileNameLower.endsWith('.csv'); + const needCodepage = isXls || isCsv; + + // 对于 CSV,移除 UTF-8 BOM + if (isCsv && buffer[0] === 0xEF && buffer[1] === 0xBB && buffer[2] === 0xBF) { + buffer = buffer.slice(3); + } + const workbook = xlsx.read(buffer, { type: 'buffer', - raw: true, - cellText: false, - cellDates: false, + codepage: needCodepage ? 936 : undefined, // .xls/.csv 文件使用 GBK 编码 + cellDates: true, }); const sheetName = workbook.SheetNames[0]; const sheet = workbook.Sheets[sheetName]; - const rawData = xlsx.utils.sheet_to_json(sheet, { + let rawData = xlsx.utils.sheet_to_json(sheet, { raw: false, defval: null, }); + + // 清理列名中的特殊字符 + rawData = this.cleanColumnNames(rawData); // 智能清洗 const data = this.intelligentCleanData(rawData); @@ -270,20 +283,33 @@ export class SessionService { // 3. ⚠️ Fallback:从原始文件重新解析(兼容旧数据或 clean data 不存在) logger.info(`[SessionService] 从原始文件解析(clean data不存在): ${session.fileKey}`); - const buffer = await storage.download(session.fileKey); + let bufferFull = await storage.download(session.fileKey); - const workbook = xlsx.read(buffer, { + // ✅ 修复乱码问题:添加 codepage 支持(.xls 和 .csv 文件) + const fileNameLowerFull = session.fileName?.toLowerCase() ?? ''; + const isXlsFull = fileNameLowerFull.endsWith('.xls') && !fileNameLowerFull.endsWith('.xlsx'); + const isCsvFull = fileNameLowerFull.endsWith('.csv'); + const needCodepageFull = isXlsFull || isCsvFull; + + // 对于 CSV,移除 UTF-8 BOM + if (isCsvFull && bufferFull[0] === 0xEF && bufferFull[1] === 0xBB && bufferFull[2] === 0xBF) { + bufferFull = bufferFull.slice(3); + } + + const workbook = xlsx.read(bufferFull, { type: 'buffer', - raw: true, - cellText: false, - cellDates: false, + codepage: needCodepageFull ? 936 : undefined, // .xls/.csv 文件使用 GBK 编码 + cellDates: true, }); const sheetName = workbook.SheetNames[0]; const sheet = workbook.Sheets[sheetName]; - const rawData = xlsx.utils.sheet_to_json(sheet, { + let rawData = xlsx.utils.sheet_to_json(sheet, { raw: false, defval: null, }); + + // 清理列名中的特殊字符 + rawData = this.cleanColumnNames(rawData); // 智能清洗 const data = this.intelligentCleanData(rawData); @@ -818,6 +844,46 @@ export class SessionService { }); } + /** + * 清理列名中的特殊字符(BOM、空白字符等) + * + * @param data - 原始数据数组 + * @returns 清理后的数据数组 + */ + private cleanColumnNames(data: any[]): any[] { + if (data.length === 0) { + return data; + } + + const originalColumns = Object.keys(data[0] || {}); + const columnMapping: Record = {}; + let hasCleanedColumns = false; + + originalColumns.forEach(col => { + // 清理 BOM 字符 (\uFEFF) 和首尾空白 + const cleanedCol = col.replace(/^\uFEFF/, '').trim(); + if (cleanedCol !== col) { + columnMapping[col] = cleanedCol; + hasCleanedColumns = true; + logger.info(`[SessionService] 清理列名: "${col}" → "${cleanedCol}"`); + } + }); + + // 如果有列名需要清理,重新映射数据 + if (hasCleanedColumns) { + return data.map((row: any) => { + const newRow: any = {}; + Object.keys(row).forEach(key => { + const newKey = columnMapping[key] || key; + newRow[newKey] = row[key]; + }); + return newRow; + }); + } + + return data; + } + /** * 检测列的数据类型 * diff --git a/backend/src/modules/dc/tool-c/workers/parseExcelWorker.ts b/backend/src/modules/dc/tool-c/workers/parseExcelWorker.ts index e54da9f5..29a50eed 100644 --- a/backend/src/modules/dc/tool-c/workers/parseExcelWorker.ts +++ b/backend/src/modules/dc/tool-c/workers/parseExcelWorker.ts @@ -68,31 +68,80 @@ export function registerParseExcelWorker() { }); // ======================================== - // 2. 解析 Excel + // 2. 解析 Excel/CSV(修复中文编码问题) // ======================================== - logger.info('[parseExcelWorker] Parsing Excel...'); + logger.info('[parseExcelWorker] Parsing file...'); let workbook: xlsx.WorkBook; + const fileNameLower = fileName.toLowerCase(); + const isXls = fileNameLower.endsWith('.xls') && !fileNameLower.endsWith('.xlsx'); + const isCsv = fileNameLower.endsWith('.csv'); + try { - workbook = xlsx.read(buffer, { + // ✅ 修复乱码问题: + // - .xls 和 .csv 文件:添加 codepage: 936(支持 GBK/GB2312 编码) + // - 中文 Windows 导出的 CSV 通常是 GBK 编码,不是 UTF-8 + // - .xlsx 文件:内部使用 UTF-8,不需要指定 codepage + const needCodepage = isXls || isCsv; + + // 对于 CSV 文件,先尝试检测是否是 UTF-8 BOM + let processedBuffer = buffer; + if (isCsv) { + // 检测并移除 UTF-8 BOM (0xEF 0xBB 0xBF) + if (buffer[0] === 0xEF && buffer[1] === 0xBB && buffer[2] === 0xBF) { + logger.info('[parseExcelWorker] 检测到 UTF-8 BOM,移除中...'); + processedBuffer = buffer.slice(3); + } + } + + workbook = xlsx.read(processedBuffer, { type: 'buffer', - raw: true, - cellText: false, - cellDates: false, + codepage: needCodepage ? 936 : undefined, // .xls/.csv 文件使用 GBK 编码 + cellDates: true, // 正确处理日期 }); } catch (error: any) { - throw new Error(`Excel文件解析失败: ${error.message}`); + throw new Error(`文件解析失败: ${error.message}`); } const sheetName = workbook.SheetNames[0]; if (!sheetName) { - throw new Error('Excel文件中没有工作表'); + throw new Error('文件中没有工作表'); } const sheet = workbook.Sheets[sheetName]; - const rawData = xlsx.utils.sheet_to_json(sheet, { + let rawData = xlsx.utils.sheet_to_json(sheet, { raw: false, defval: null, }); + + // ✅ 清理列名中的特殊字符(BOM残留、空白字符等) + if (rawData.length > 0) { + const originalColumns = Object.keys(rawData[0] || {}); + const columnMapping: Record = {}; + let hasCleanedColumns = false; + + originalColumns.forEach(col => { + // 清理 BOM 字符 (\uFEFF) 和首尾空白 + const cleanedCol = col.replace(/^\uFEFF/, '').trim(); + if (cleanedCol !== col) { + columnMapping[col] = cleanedCol; + hasCleanedColumns = true; + logger.info(`[parseExcelWorker] 清理列名: "${col}" → "${cleanedCol}"`); + } + }); + + // 如果有列名需要清理,重新映射数据 + if (hasCleanedColumns) { + rawData = rawData.map((row: any) => { + const newRow: any = {}; + Object.keys(row).forEach(key => { + const newKey = columnMapping[key] || key; + newRow[newKey] = row[key]; + }); + return newRow; + }); + logger.info(`[parseExcelWorker] 已清理 ${Object.keys(columnMapping).length} 个列名`); + } + } logger.info('[parseExcelWorker] Excel parsed', { rows: rawData.length, diff --git a/backend/src/modules/iit-manager/agents/SessionMemory.ts b/backend/src/modules/iit-manager/agents/SessionMemory.ts index c2266f22..fbf5bcb9 100644 --- a/backend/src/modules/iit-manager/agents/SessionMemory.ts +++ b/backend/src/modules/iit-manager/agents/SessionMemory.ts @@ -188,6 +188,9 @@ logger.info('[SessionMemory] 会话记忆管理器已启动', { + + + diff --git a/backend/src/modules/iit-manager/check-iit-table-structure.ts b/backend/src/modules/iit-manager/check-iit-table-structure.ts index 8669d8e1..30e44f4e 100644 --- a/backend/src/modules/iit-manager/check-iit-table-structure.ts +++ b/backend/src/modules/iit-manager/check-iit-table-structure.ts @@ -122,6 +122,9 @@ checkTableStructure(); + + + diff --git a/backend/src/modules/iit-manager/check-project-config.ts b/backend/src/modules/iit-manager/check-project-config.ts index 41fa58d6..c245ad40 100644 --- a/backend/src/modules/iit-manager/check-project-config.ts +++ b/backend/src/modules/iit-manager/check-project-config.ts @@ -109,6 +109,9 @@ checkProjectConfig().catch(console.error); + + + diff --git a/backend/src/modules/iit-manager/check-test-project-in-db.ts b/backend/src/modules/iit-manager/check-test-project-in-db.ts index bd156f26..c6b59214 100644 --- a/backend/src/modules/iit-manager/check-test-project-in-db.ts +++ b/backend/src/modules/iit-manager/check-test-project-in-db.ts @@ -91,6 +91,9 @@ main(); + + + diff --git a/backend/src/modules/iit-manager/docs/微信服务号接入指南.md b/backend/src/modules/iit-manager/docs/微信服务号接入指南.md index ca2d2560..68e98373 100644 --- a/backend/src/modules/iit-manager/docs/微信服务号接入指南.md +++ b/backend/src/modules/iit-manager/docs/微信服务号接入指南.md @@ -548,6 +548,9 @@ URL: https://iit.xunzhengyixue.com/api/v1/iit/patient-wechat/callback + + + diff --git a/backend/src/modules/iit-manager/generate-wechat-tokens.ts b/backend/src/modules/iit-manager/generate-wechat-tokens.ts index 676057cb..bcdabca1 100644 --- a/backend/src/modules/iit-manager/generate-wechat-tokens.ts +++ b/backend/src/modules/iit-manager/generate-wechat-tokens.ts @@ -183,6 +183,9 @@ console.log(''); + + + diff --git a/backend/src/modules/iit-manager/services/PatientWechatService.ts b/backend/src/modules/iit-manager/services/PatientWechatService.ts index e3fa0ebc..43dff7b3 100644 --- a/backend/src/modules/iit-manager/services/PatientWechatService.ts +++ b/backend/src/modules/iit-manager/services/PatientWechatService.ts @@ -500,6 +500,9 @@ export const patientWechatService = new PatientWechatService(); + + + diff --git a/backend/src/modules/iit-manager/test-chatservice-dify.ts b/backend/src/modules/iit-manager/test-chatservice-dify.ts index 93639a8b..8110cb9b 100644 --- a/backend/src/modules/iit-manager/test-chatservice-dify.ts +++ b/backend/src/modules/iit-manager/test-chatservice-dify.ts @@ -145,6 +145,9 @@ testDifyIntegration().catch(error => { + + + diff --git a/backend/src/modules/iit-manager/test-iit-database.ts b/backend/src/modules/iit-manager/test-iit-database.ts index e61d42db..f34888f8 100644 --- a/backend/src/modules/iit-manager/test-iit-database.ts +++ b/backend/src/modules/iit-manager/test-iit-database.ts @@ -174,6 +174,9 @@ testIitDatabase() + + + diff --git a/backend/src/modules/iit-manager/test-patient-wechat-config.ts b/backend/src/modules/iit-manager/test-patient-wechat-config.ts index 0538ef53..2c18e760 100644 --- a/backend/src/modules/iit-manager/test-patient-wechat-config.ts +++ b/backend/src/modules/iit-manager/test-patient-wechat-config.ts @@ -160,6 +160,9 @@ if (hasError) { + + + diff --git a/backend/src/modules/iit-manager/test-patient-wechat-url-verify.ts b/backend/src/modules/iit-manager/test-patient-wechat-url-verify.ts index d6720806..33ef62c8 100644 --- a/backend/src/modules/iit-manager/test-patient-wechat-url-verify.ts +++ b/backend/src/modules/iit-manager/test-patient-wechat-url-verify.ts @@ -186,6 +186,9 @@ async function testUrlVerification() { + + + diff --git a/backend/src/modules/iit-manager/test-redcap-query-from-db.ts b/backend/src/modules/iit-manager/test-redcap-query-from-db.ts index 02d60bb7..aeb47f7f 100644 --- a/backend/src/modules/iit-manager/test-redcap-query-from-db.ts +++ b/backend/src/modules/iit-manager/test-redcap-query-from-db.ts @@ -267,6 +267,9 @@ main().catch((error) => { + + + diff --git a/backend/src/modules/iit-manager/test-wechat-mp-local.ps1 b/backend/src/modules/iit-manager/test-wechat-mp-local.ps1 index da4f456b..3ab16325 100644 --- a/backend/src/modules/iit-manager/test-wechat-mp-local.ps1 +++ b/backend/src/modules/iit-manager/test-wechat-mp-local.ps1 @@ -151,6 +151,9 @@ Write-Host "" + + + diff --git a/backend/src/modules/iit-manager/types/index.ts b/backend/src/modules/iit-manager/types/index.ts index e84ef10d..90cbf629 100644 --- a/backend/src/modules/iit-manager/types/index.ts +++ b/backend/src/modules/iit-manager/types/index.ts @@ -244,6 +244,9 @@ export interface CachedProtocolRules { + + + diff --git a/backend/src/modules/pkb/routes/health.ts b/backend/src/modules/pkb/routes/health.ts index f15c18a1..a66fb04f 100644 --- a/backend/src/modules/pkb/routes/health.ts +++ b/backend/src/modules/pkb/routes/health.ts @@ -58,6 +58,9 @@ export default async function healthRoutes(fastify: FastifyInstance) { + + + diff --git a/backend/src/modules/pkb/services/ragService.ts b/backend/src/modules/pkb/services/ragService.ts new file mode 100644 index 00000000..ce0504f7 --- /dev/null +++ b/backend/src/modules/pkb/services/ragService.ts @@ -0,0 +1,440 @@ +/** + * PKB RAG 服务 - 双轨模式 + * + * 支持两种后端: + * 1. pgvector(新)- 基于 PostgreSQL + pgvector 的本地 RAG + * 2. Dify(旧)- 基于 Dify 外部服务 + * + * 通过环境变量 PKB_RAG_BACKEND 控制: + * - 'pgvector'(默认):使用新的 pgvector 方案 + * - 'dify':使用旧的 Dify 方案 + * - 'hybrid':同时使用,结果合并 + */ + +import { prisma } from '../../../config/database.js'; +import { logger } from '../../../common/logging/index.js'; +import { difyClient } from '../../../common/rag/DifyClient.js'; +import { + getVectorSearchService, + getDocumentIngestService, + QueryRewriter, + type SearchResult, + type IngestResult, +} from '../../../common/rag/index.js'; + +// ==================== 配置 ==================== + +type RagBackend = 'pgvector' | 'dify' | 'hybrid'; + +const RAG_BACKEND: RagBackend = (process.env.PKB_RAG_BACKEND as RagBackend) || 'pgvector'; + +logger.info(`PKB RAG 后端: ${RAG_BACKEND}`); + +// ==================== 类型定义 ==================== + +export interface RagSearchOptions { + topK?: number; + minScore?: number; + mode?: 'vector' | 'keyword' | 'hybrid'; +} + +export interface RagSearchResult { + content: string; + score: number; + documentId?: string; + chunkId?: string; + metadata?: Record; + source: 'pgvector' | 'dify'; +} + +export interface RagIngestOptions { + contentType?: string; + tags?: string[]; + metadata?: Record; + generateSummary?: boolean; +} + +// ==================== 检索服务 ==================== + +/** + * 检索知识库 + */ +export async function searchKnowledgeBase( + userId: string, + kbId: string, + query: string, + options: RagSearchOptions = {} +): Promise { + const { topK = 10, minScore = 0.5, mode = 'hybrid' } = options; + + logger.info(`[RAG] 检索知识库: kbId=${kbId}, query="${query.substring(0, 30)}...", backend=${RAG_BACKEND}`); + + // 验证权限 + const knowledgeBase = await prisma.knowledgeBase.findFirst({ + where: { id: kbId, userId }, + }); + + if (!knowledgeBase) { + throw new Error('Knowledge base not found or access denied'); + } + + // 根据后端选择执行检索 + if (RAG_BACKEND === 'pgvector') { + return searchWithPgvector(kbId, query, { topK, minScore, mode }); + } else if (RAG_BACKEND === 'dify') { + return searchWithDify(knowledgeBase.difyDatasetId, query, topK); + } else { + // hybrid: 两个后端都查,合并结果 + const [pgResults, difyResults] = await Promise.all([ + searchWithPgvector(kbId, query, { topK, minScore, mode }).catch(() => []), + searchWithDify(knowledgeBase.difyDatasetId, query, topK).catch(() => []), + ]); + return mergeSearchResults(pgResults, difyResults, topK); + } +} + +/** + * 使用 pgvector 检索(业务层:负责查询理解) + */ +async function searchWithPgvector( + kbId: string, + query: string, + options: RagSearchOptions +): Promise { + const { topK = 10, minScore = 0.5, mode = 'hybrid' } = options; + + // 查找对应的 EKB 知识库 + const searchService = getVectorSearchService(prisma); + + // ==================== 业务层:查询理解(DeepSeek V3)==================== + + // 1. 生成检索查询词(中英双语) + const queryRewriter = new QueryRewriter(); + const rewriteResult = await queryRewriter.rewrite(query); + + let searchQueries: string[]; + if (rewriteResult.isChinese && rewriteResult.rewritten.length > 0) { + // 中文查询:生成中英双语查询词 + searchQueries = [ + query, // 保留原中文(匹配中文文档) + ...rewriteResult.rewritten, // 添加英文(匹配英文文档) + ]; + + logger.info(`PKB 查询策略: 中英双语检索`, { + original: query, + queries: searchQueries, + cost: `¥${rewriteResult.cost.toFixed(6)}`, + }); + } else { + // 英文查询:直接使用 + searchQueries = [query]; + } + + // ==================== 引擎层:执行检索 ==================== + + let results: SearchResult[]; + if (mode === 'vector') { + // 纯向量检索(支持多查询) + results = await searchService.searchWithQueries(searchQueries, { + topK, + minScore, + filter: { kbId } + }); + } else if (mode === 'keyword') { + // 纯关键词检索(使用第一个翻译结果) + const keywordQuery = searchQueries[searchQueries.length - 1]; // 优先用英文 + results = await searchService.keywordSearch(keywordQuery, { topK, filter: { kbId } }); + } else { + // 混合检索:向量 + 关键词 + // 对每个查询词都执行混合检索,然后融合 + const allResults = await Promise.all( + searchQueries.map(q => searchService.hybridSearch(q, { topK: topK * 2, filter: { kbId } })) + ); + + // RRF 融合多个查询的结果 + results = fuseMultiQueryResults(allResults, topK); + } + + return results.map(r => ({ + content: r.content, + score: r.score, + documentId: r.documentId, + chunkId: r.chunkId, + metadata: r.metadata, + source: 'pgvector' as const, + })); +} + +/** + * 融合多个查询的结果(RRF) + */ +function fuseMultiQueryResults( + allResults: SearchResult[][], + topK: number +): SearchResult[] { + const k = 60; + const fusedScores = new Map(); + + allResults.forEach((results) => { + results.forEach((result, rank) => { + const rrfScore = 1 / (k + rank + 1); + const existing = fusedScores.get(result.chunkId); + + if (existing) { + existing.score += rrfScore; + } else { + fusedScores.set(result.chunkId, { result, score: rrfScore }); + } + }); + }); + + return Array.from(fusedScores.values()) + .sort((a, b) => b.score - a.score) + .slice(0, topK) + .map(({ result, score }) => ({ + ...result, + score: Math.min(1, score * 100), + })); +} + +/** + * 使用 Dify 检索 + */ +async function searchWithDify( + difyDatasetId: string, + query: string, + topK: number +): Promise { + const results = await difyClient.retrieveKnowledge(difyDatasetId, query, { + retrieval_model: { + search_method: 'semantic_search', + top_k: topK, + }, + }); + + return (results.records || []).map((r: any) => ({ + content: r.segment?.content || '', + score: r.score || 0, + metadata: r.segment?.metadata, + source: 'dify' as const, + })); +} + +/** + * 合并两个后端的检索结果 + */ +function mergeSearchResults( + pgResults: RagSearchResult[], + difyResults: RagSearchResult[], + topK: number +): RagSearchResult[] { + // 简单合并:按分数排序,去重 + const all = [...pgResults, ...difyResults]; + + // 按分数降序排序 + all.sort((a, b) => b.score - a.score); + + // 去重(基于内容相似度,简化为前100字符比较) + const seen = new Set(); + const unique: RagSearchResult[] = []; + + for (const result of all) { + const key = result.content.substring(0, 100); + if (!seen.has(key)) { + seen.add(key); + unique.push(result); + } + } + + return unique.slice(0, topK); +} + +// ==================== 入库服务 ==================== + +/** + * 上传文档到知识库 + */ +export async function ingestDocument( + userId: string, + kbId: string, + file: Buffer, + filename: string, + options: RagIngestOptions = {} +): Promise { + logger.info(`[RAG] 入库文档: kbId=${kbId}, filename=${filename}, backend=${RAG_BACKEND}`); + + // 验证权限 + const knowledgeBase = await prisma.knowledgeBase.findFirst({ + where: { id: kbId, userId }, + }); + + if (!knowledgeBase) { + throw new Error('Knowledge base not found or access denied'); + } + + if (RAG_BACKEND === 'pgvector' || RAG_BACKEND === 'hybrid') { + // 使用新的 pgvector 入库流程 + const ingestService = getDocumentIngestService(prisma); + + const result = await ingestService.ingestDocument( + { + filename, + fileBuffer: file, + }, + { + kbId, // 这里需要映射到 EkbKnowledgeBase.id + contentType: options.contentType, + tags: options.tags, + metadata: options.metadata, + generateSummary: options.generateSummary, + } + ); + + // 如果是 hybrid 模式,同时上传到 Dify + if (RAG_BACKEND === 'hybrid') { + try { + await difyClient.uploadDocumentDirectly( + knowledgeBase.difyDatasetId, + file, + filename + ); + } catch (error) { + logger.warn('Dify 上传失败,但 pgvector 已成功', { error }); + } + } + + return result; + } else { + // 纯 Dify 模式 + const difyResult = await difyClient.uploadDocumentDirectly( + knowledgeBase.difyDatasetId, + file, + filename + ); + + return { + success: true, + documentId: difyResult.document.id, + }; + } +} + +// ==================== 知识库管理 ==================== + +/** + * 创建知识库(双轨) + */ +export async function createKnowledgeBaseWithRag( + userId: string, + name: string, + description?: string +): Promise<{ pkbKbId: string; ekbKbId?: string; difyDatasetId?: string }> { + let difyDatasetId: string | undefined; + let ekbKbId: string | undefined; + + // 1. 在 Dify 创建(如果需要) + if (RAG_BACKEND === 'dify' || RAG_BACKEND === 'hybrid') { + const sanitizedName = name.replace(/[^\u4e00-\u9fa5a-zA-Z0-9_-]/g, '_').substring(0, 50); + const difyDataset = await difyClient.createDataset({ + name: `kb_${sanitizedName}_${Date.now()}`, + description: description?.substring(0, 200) || '', + indexing_technique: 'high_quality', + }); + difyDatasetId = difyDataset.id; + } + + // 2. 在 EKB 创建(如果需要) + if (RAG_BACKEND === 'pgvector' || RAG_BACKEND === 'hybrid') { + const ekbKb = await prisma.ekbKnowledgeBase.create({ + data: { + name, + description, + type: 'USER', + ownerId: userId, + config: {}, + }, + }); + ekbKbId = ekbKb.id; + } + + // 3. 在 PKB 创建主记录 + const pkbKb = await prisma.knowledgeBase.create({ + data: { + userId, + name, + description, + difyDatasetId: difyDatasetId || '', + // 可以添加 ekbKbId 字段关联,或通过 metadata 存储 + }, + }); + + // 4. 更新用户配额 + await prisma.user.update({ + where: { id: userId }, + data: { kbUsed: { increment: 1 } }, + }); + + return { + pkbKbId: pkbKb.id, + ekbKbId, + difyDatasetId, + }; +} + +/** + * 获取知识库统计(双轨) + */ +export async function getKnowledgeBaseStats( + userId: string, + kbId: string +): Promise<{ + documentCount: number; + totalTokens: number; + backend: RagBackend; +}> { + const knowledgeBase = await prisma.knowledgeBase.findFirst({ + where: { id: kbId, userId }, + include: { documents: true }, + }); + + if (!knowledgeBase) { + throw new Error('Knowledge base not found'); + } + + // PKB 文档统计 + const pkbStats = { + documentCount: knowledgeBase.documents.length, + totalTokens: knowledgeBase.documents.reduce((sum, d) => sum + (d.tokensCount || 0), 0), + }; + + // 如果使用 pgvector,也获取 EKB 统计 + if (RAG_BACKEND === 'pgvector' || RAG_BACKEND === 'hybrid') { + try { + const searchService = getVectorSearchService(prisma); + const ekbStats = await searchService.getKnowledgeBaseStats(kbId); + + return { + documentCount: Math.max(pkbStats.documentCount, ekbStats.documentCount), + totalTokens: Math.max(pkbStats.totalTokens, ekbStats.totalTokens), + backend: RAG_BACKEND, + }; + } catch { + // EKB 统计失败,返回 PKB 统计 + } + } + + return { + ...pkbStats, + backend: RAG_BACKEND, + }; +} + +// ==================== 导出当前后端配置 ==================== + +export function getCurrentBackend(): RagBackend { + return RAG_BACKEND; +} + +export { RAG_BACKEND }; + + diff --git a/backend/src/modules/rvw/__tests__/api.http b/backend/src/modules/rvw/__tests__/api.http index d0815d2f..423d069b 100644 --- a/backend/src/modules/rvw/__tests__/api.http +++ b/backend/src/modules/rvw/__tests__/api.http @@ -139,3 +139,6 @@ Content-Type: application/json + + + diff --git a/backend/src/modules/rvw/__tests__/test-api.ps1 b/backend/src/modules/rvw/__tests__/test-api.ps1 index 0de09cf7..0f5a21bb 100644 --- a/backend/src/modules/rvw/__tests__/test-api.ps1 +++ b/backend/src/modules/rvw/__tests__/test-api.ps1 @@ -124,3 +124,6 @@ Write-Host " - 删除任务: DELETE $BaseUrl/api/v1/rvw/tasks/{taskId}" -Foregr + + + diff --git a/backend/src/modules/rvw/index.ts b/backend/src/modules/rvw/index.ts index fbb4d252..e1912bfc 100644 --- a/backend/src/modules/rvw/index.ts +++ b/backend/src/modules/rvw/index.ts @@ -38,3 +38,6 @@ export * from './services/utils.js'; + + + diff --git a/backend/src/modules/rvw/services/utils.ts b/backend/src/modules/rvw/services/utils.ts index 8d0eb394..b885effa 100644 --- a/backend/src/modules/rvw/services/utils.ts +++ b/backend/src/modules/rvw/services/utils.ts @@ -129,3 +129,6 @@ export function validateAgentSelection(agents: string[]): void { + + + diff --git a/backend/src/tests/README.md b/backend/src/tests/README.md index de1877c7..b2f5c3c9 100644 --- a/backend/src/tests/README.md +++ b/backend/src/tests/README.md @@ -425,6 +425,9 @@ SET session_replication_role = 'origin'; + + + diff --git a/backend/src/tests/test-cross-language-search.ts b/backend/src/tests/test-cross-language-search.ts new file mode 100644 index 00000000..1678f6f2 --- /dev/null +++ b/backend/src/tests/test-cross-language-search.ts @@ -0,0 +1,112 @@ +/** + * 跨语言检索测试 + * + * 对比: + * 1. 纯 v4 跨语言(1024维) + * 2. v4 跨语言(2048维) + * 3. v4 + DeepSeek V3 查询重写 + * + * 运行: npx tsx src/tests/test-cross-language-search.ts + */ + +import { config } from 'dotenv'; +config(); + +import { PrismaClient } from '@prisma/client'; +import { getVectorSearchService } from '../common/rag/index'; + +const prisma = new PrismaClient(); + +// 中文查询测试集 +const TEST_QUERIES = [ + '这篇文档的主要研究内容是什么', + '银杏叶对老年痴呆有什么效果', + '临床试验的主要结论', + '研究方法和设计', + '研究对象的纳入标准', +]; + +async function testCrossLanguageSearch() { + console.log('========================================'); + console.log('🌍 跨语言检索对比测试'); + console.log('========================================\n'); + + // 查找 Dongen 2003.pdf 的文档 + const document = await prisma.ekbDocument.findFirst({ + where: { filename: 'Dongen 2003.pdf' }, + select: { id: true, kbId: true, filename: true }, + }); + + if (!document) { + console.error('❌ 测试文档不存在'); + console.log(' 请先运行: npx tsx src/tests/test-pdf-ingest.ts '); + process.exit(1); + } + + console.log(`✅ 找到测试文档: ${document.filename}`); + console.log(` kbId: ${document.kbId}`); + console.log(` docId: ${document.id}`); + console.log(''); + + const searchService = getVectorSearchService(prisma); + + // 当前配置 + const currentDimensions = parseInt(process.env.TEXT_EMBEDDING_DIMENSIONS || '1024', 10); + console.log(`📊 当前向量维度: ${currentDimensions}`); + console.log(''); + + console.log('开始测试(降低阈值到 0.2):'); + console.log('='.repeat(60)); + + for (const query of TEST_QUERIES) { + console.log(`\n🔍 查询: "${query}"`); + console.log('-'.repeat(60)); + + try { + const results = await searchService.vectorSearch(query, { + topK: 3, + minScore: 0.2, // 跨语言场景降低阈值 + filter: { kbId: document.kbId }, + enableQueryRewrite: false, // 先不用查询重写,看纯 v4 效果 + }); + + if (results.length === 0) { + console.log(' ❌ 无结果(相似度 < 0.2)'); + } else { + console.log(` ✅ 返回 ${results.length} 条结果:`); + results.forEach((r, i) => { + const preview = r.content.substring(0, 70).replace(/\n/g, ' '); + console.log(` ${i + 1}. [${r.score.toFixed(3)}] ${preview}...`); + }); + } + + } catch (error) { + console.log(` ❌ 检索失败: ${error}`); + } + } + + console.log('\n'); + console.log('========================================'); + console.log('📝 测试结论'); + console.log('========================================'); + console.log(''); + console.log(`当前配置: text-embedding-v4 (${currentDimensions}维)`); + console.log(''); + console.log('优化建议:'); + console.log(' 1. ✅ 如果大部分查询有结果且相似度 > 0.25:'); + console.log(' → v4 跨语言能力足够,保持当前配置'); + console.log(''); + console.log(' 2. ⚠️ 如果相似度低于 0.25 或无结果:'); + console.log(' → 建议升级到 2048 维(提升15-40%)'); + console.log(' → 或启用 DeepSeek V3 查询重写'); + console.log(''); + console.log(' 3. 🎯 最佳方案:2048维 + 查询重写'); + console.log(' → 成本增加 <¥0.001/次'); + console.log(' → 精度提升 50%+'); + + await prisma.$disconnect(); +} + +testCrossLanguageSearch(); + + diff --git a/backend/src/tests/test-embedding-service.ts b/backend/src/tests/test-embedding-service.ts new file mode 100644 index 00000000..74094590 --- /dev/null +++ b/backend/src/tests/test-embedding-service.ts @@ -0,0 +1,116 @@ +/** + * EmbeddingService 测试脚本 + * + * 运行: npx ts-node src/tests/test-embedding-service.ts + */ + +import { config } from 'dotenv'; +config(); // 加载 .env + +// 直接导入(避免 ESM 模块解析问题) +import { EmbeddingService, getEmbeddingService } from '../common/rag/EmbeddingService'; + +async function testEmbeddingService() { + console.log('========================================'); + console.log('🧪 EmbeddingService 测试'); + console.log('========================================\n'); + + // 检查环境变量 + const apiKey = process.env.DASHSCOPE_API_KEY; + if (!apiKey) { + console.error('❌ 错误: DASHSCOPE_API_KEY 未配置'); + console.log('请在 .env 文件中设置: DASHSCOPE_API_KEY=sk-xxx'); + process.exit(1); + } + console.log('✅ DASHSCOPE_API_KEY 已配置'); + console.log(`📍 BASE_URL: ${process.env.TEXT_EMBEDDING_BASE_URL || '(默认)'}`); + console.log(`📍 MODEL: ${process.env.TEXT_EMBEDDING_MODEL || 'text-embedding-v4'}`); + console.log(''); + + try { + // 测试 1: 单文本向量化 + console.log('📝 测试 1: 单文本向量化'); + console.log('-'.repeat(40)); + + const service = getEmbeddingService(); + const testText = '阿司匹林是一种非甾体抗炎药,常用于解热镇痛和抗血小板聚集。'; + + console.log(`输入文本: "${testText}"`); + + const startTime = Date.now(); + const result = await service.embed(testText); + const duration = Date.now() - startTime; + + console.log(`✅ 向量化成功!`); + console.log(` - 向量维度: ${result.embedding.length}`); + console.log(` - Token 数: ${result.tokenCount}`); + console.log(` - 耗时: ${duration}ms`); + console.log(` - 向量前5维: [${result.embedding.slice(0, 5).map(n => n.toFixed(4)).join(', ')}...]`); + console.log(''); + + // 测试 2: 批量向量化 + console.log('📝 测试 2: 批量向量化'); + console.log('-'.repeat(40)); + + const batchTexts = [ + '高血压是最常见的慢性病之一', + '糖尿病的早期症状包括多饮、多尿、多食', + '冠心病的危险因素包括高血压、高血脂、吸烟', + ]; + + console.log(`输入文本数量: ${batchTexts.length}`); + + const batchStart = Date.now(); + const batchResult = await service.embedBatch(batchTexts); + const batchDuration = Date.now() - batchStart; + + console.log(`✅ 批量向量化成功!`); + console.log(` - 返回向量数: ${batchResult.embeddings.length}`); + console.log(` - 总 Token 数: ${batchResult.totalTokens}`); + console.log(` - 耗时: ${batchDuration}ms`); + console.log(''); + + // 测试 3: 相似度计算 + console.log('📝 测试 3: 余弦相似度计算'); + console.log('-'.repeat(40)); + + const similarity01 = EmbeddingService.cosineSimilarity( + batchResult.embeddings[0], + batchResult.embeddings[1] + ); + const similarity02 = EmbeddingService.cosineSimilarity( + batchResult.embeddings[0], + batchResult.embeddings[2] + ); + + console.log(`文本 0 vs 文本 1 相似度: ${similarity01.toFixed(4)}`); + console.log(`文本 0 vs 文本 2 相似度: ${similarity02.toFixed(4)}`); + console.log(''); + + // 测试 4: 查询与文档相似度 + console.log('📝 测试 4: 查询-文档相似度'); + console.log('-'.repeat(40)); + + const queryText = '血压高怎么治疗'; + const queryResult = await service.embed(queryText); + + console.log(`查询: "${queryText}"`); + for (let i = 0; i < batchTexts.length; i++) { + const sim = EmbeddingService.cosineSimilarity(queryResult.embedding, batchResult.embeddings[i]); + console.log(` 与文档 ${i} 相似度: ${sim.toFixed(4)} - "${batchTexts[i].substring(0, 20)}..."`); + } + console.log(''); + + console.log('========================================'); + console.log('🎉 所有测试通过!'); + console.log('========================================'); + + } catch (error) { + console.error('❌ 测试失败:', error); + process.exit(1); + } +} + +// 运行测试 +testEmbeddingService(); + diff --git a/backend/src/tests/test-pdf-ingest.ts b/backend/src/tests/test-pdf-ingest.ts new file mode 100644 index 00000000..9b2ef18f --- /dev/null +++ b/backend/src/tests/test-pdf-ingest.ts @@ -0,0 +1,262 @@ +/** + * PDF 文档入库测试 + * + * 测试完整流程:PDF → Markdown → 分块 → 向量化 → 检索 + * + * 用法: + * npx tsx src/tests/test-pdf-ingest.ts + * + * 示例: + * npx tsx src/tests/test-pdf-ingest.ts ./test-files/sample.pdf + */ + +import { config } from 'dotenv'; +config(); + +import fs from 'fs'; +import path from 'path'; +import { PrismaClient } from '@prisma/client'; +import { + getEmbeddingService, + getChunkService, + getVectorSearchService, +} from '../common/rag/index'; + +const prisma = new PrismaClient(); + +// Python 微服务地址 +const EXTRACTION_SERVICE_URL = process.env.EXTRACTION_SERVICE_URL || 'http://localhost:8000'; + +async function testPdfIngest(pdfPath: string) { + console.log('========================================'); + console.log('🧪 PDF 文档入库测试'); + console.log('========================================\n'); + + // 检查文件存在 + if (!fs.existsSync(pdfPath)) { + console.error(`❌ 文件不存在: ${pdfPath}`); + process.exit(1); + } + + const filename = path.basename(pdfPath); + console.log(`📄 测试文件: ${filename}`); + console.log(`📍 Python 服务: ${EXTRACTION_SERVICE_URL}`); + console.log(''); + + let testKbId: string | null = null; + let testDocId: string | null = null; + + try { + // ==================== Step 1: 创建测试知识库 ==================== + console.log('📦 Step 1: 创建测试知识库'); + console.log('-'.repeat(40)); + + const testKb = await prisma.ekbKnowledgeBase.create({ + data: { + name: 'PDF测试知识库', + description: `测试文件: ${filename}`, + type: 'USER', + ownerId: 'test-user', + config: {}, + }, + }); + testKbId = testKb.id; + + console.log(`✅ 知识库创建成功: ${testKb.id}`); + console.log(''); + + // ==================== Step 2: 调用 Python 微服务转换 PDF ==================== + console.log('📝 Step 2: PDF 转 Markdown'); + console.log('-'.repeat(40)); + + const fileBuffer = fs.readFileSync(pdfPath); + console.log(` 文件大小: ${(fileBuffer.length / 1024).toFixed(2)} KB`); + + // 使用 Node.js 原生 FormData(Node 18+) + // 不设置 Content-Type,让 fetch 自动处理 boundary + const formData = new FormData(); + const blob = new Blob([fileBuffer], { type: 'application/pdf' }); + formData.append('file', blob, filename); + + console.log(` 调用 ${EXTRACTION_SERVICE_URL}/api/document/to-markdown ...`); + + const startTime = Date.now(); + const response = await fetch(`${EXTRACTION_SERVICE_URL}/api/document/to-markdown`, { + method: 'POST', + body: formData, + // 不设置 Content-Type,让 fetch 自动添加 multipart/form-data boundary + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Python 服务返回错误: ${response.status} - ${errorText}`); + } + + const result = await response.json() as { success: boolean; text?: string; error?: string; metadata?: any }; + const conversionTime = Date.now() - startTime; + + if (!result.success) { + throw new Error(result.error || 'PDF 转换失败'); + } + + const markdown = result.text || ''; + console.log(`✅ PDF 转换成功!`); + console.log(` - 耗时: ${conversionTime}ms`); + console.log(` - 字符数: ${markdown.length}`); + console.log(` - 内容预览: ${markdown.substring(0, 200).replace(/\n/g, ' ')}...`); + console.log(''); + + // ==================== Step 3: 文本分块 ==================== + console.log('📝 Step 3: 文本分块'); + console.log('-'.repeat(40)); + + const chunkService = getChunkService(); + const { chunks } = chunkService.chunkMarkdown(markdown); + + console.log(`✅ 分块完成: ${chunks.length} 个分块`); + chunks.slice(0, 3).forEach((chunk, i) => { + console.log(` 分块 ${i}: ${chunk.content.substring(0, 50).replace(/\n/g, ' ')}... (${chunk.content.length} 字符)`); + }); + if (chunks.length > 3) { + console.log(` ... 还有 ${chunks.length - 3} 个分块`); + } + console.log(''); + + // ==================== Step 4: 向量化 ==================== + console.log('🔢 Step 4: 批量向量化'); + console.log('-'.repeat(40)); + + const embeddingService = getEmbeddingService(); + const texts = chunks.map(c => c.content); + const embedStart = Date.now(); + const { embeddings, totalTokens } = await embeddingService.embedBatch(texts); + const embedTime = Date.now() - embedStart; + + console.log(`✅ 向量化完成!`); + console.log(` - 耗时: ${embedTime}ms`); + console.log(` - 向量数: ${embeddings.length}`); + console.log(` - Token 数: ${totalTokens}`); + console.log(''); + + // ==================== Step 5: 存入数据库 ==================== + console.log('💾 Step 5: 存入数据库'); + console.log('-'.repeat(40)); + + // 创建文档记录 + const testDoc = await prisma.ekbDocument.create({ + data: { + kbId: testKb.id, + userId: 'test-user', + filename: filename, + fileType: 'pdf', + fileSizeBytes: BigInt(fileBuffer.length), + fileUrl: `test://${pdfPath}`, + extractedText: markdown, + contentType: 'LITERATURE', + tags: ['测试', 'PDF'], + tokenCount: totalTokens, + pageCount: result.metadata?.page_count || 1, + status: 'completed', + }, + }); + testDocId = testDoc.id; + + console.log(`✅ 文档记录创建: ${testDoc.id}`); + + // 创建分块记录 + for (let i = 0; i < chunks.length; i++) { + await prisma.$executeRawUnsafe(` + INSERT INTO "ekb_schema"."ekb_chunk" + (id, document_id, content, chunk_index, embedding, metadata, created_at) + VALUES ( + gen_random_uuid(), + '${testDoc.id}', + $1, + ${i}, + '${`[${embeddings[i].join(',')}]`}'::vector, + '${JSON.stringify(chunks[i].metadata || {})}'::jsonb, + NOW() + ) + `, chunks[i].content); + } + + console.log(`✅ 分块记录创建: ${chunks.length} 条`); + console.log(''); + + // ==================== Step 6: 语义检索测试 ==================== + console.log('🔍 Step 6: 语义检索测试'); + console.log('-'.repeat(40)); + + const searchService = getVectorSearchService(prisma); + + // 让用户输入查询 + console.log(''); + console.log('请输入测试查询(或按 Enter 使用默认查询):'); + + // 使用与文档语言匹配的查询(英文文档用英文查询效果更好) + const testQueries = [ + 'Ginkgo dementia elderly', + 'clinical trial results', + 'memory impairment treatment', + ]; + + for (const query of testQueries) { + console.log(`\n查询: "${query}"`); + + // 降低 minScore 阈值,先看看能否返回结果 + const results = await searchService.vectorSearch(query, { + topK: 3, + minScore: 0.1, // 降低阈值 + filter: { kbId: testKb.id }, + }); + + console.log(` 返回 ${results.length} 条结果:`); + results.forEach((r, i) => { + const preview = r.content.substring(0, 80).replace(/\n/g, ' '); + console.log(` ${i + 1}. [${r.score.toFixed(3)}] ${preview}...`); + }); + } + console.log(''); + + // ==================== 询问是否清理 ==================== + console.log('========================================'); + console.log('🎉 PDF 入库测试完成!'); + console.log('========================================'); + console.log(''); + console.log('测试数据已保留,可以继续进行更多查询测试。'); + console.log(''); + console.log('如需清理测试数据,请运行:'); + console.log(` npx prisma db execute --stdin <<< "DELETE FROM ekb_schema.ekb_knowledge_base WHERE id = '${testKb.id}'"`); + + } catch (error) { + console.error('❌ 测试失败:', error); + + // 清理测试数据 + if (testKbId) { + try { + await prisma.ekbKnowledgeBase.delete({ where: { id: testKbId } }); + console.log('🧹 测试数据已清理'); + } catch {} + } + + process.exit(1); + } finally { + await prisma.$disconnect(); + } +} + +// 获取命令行参数 +const pdfPath = process.argv[2]; + +if (!pdfPath) { + console.log('用法: npx tsx src/tests/test-pdf-ingest.ts '); + console.log(''); + console.log('示例:'); + console.log(' npx tsx src/tests/test-pdf-ingest.ts ./test-files/sample.pdf'); + console.log(' npx tsx src/tests/test-pdf-ingest.ts "D:\\Documents\\paper.pdf"'); + process.exit(1); +} + +// 运行测试 +testPdfIngest(pdfPath); + diff --git a/backend/src/tests/test-query-rewrite.ts b/backend/src/tests/test-query-rewrite.ts new file mode 100644 index 00000000..7978becf --- /dev/null +++ b/backend/src/tests/test-query-rewrite.ts @@ -0,0 +1,174 @@ +/** + * Query Rewrite + 跨语言检索完整测试 + * + * 对比: + * 1. 纯向量检索(无翻译) + * 2. DeepSeek V3 查询重写 + 向量检索 + * 3. 完整链路:查询重写 + 混合检索 + Rerank + * + * 运行: npx tsx src/tests/test-query-rewrite.ts + */ + +import { config } from 'dotenv'; +config(); + +import { PrismaClient } from '@prisma/client'; +import { getVectorSearchService } from '../common/rag/index'; + +const prisma = new PrismaClient(); + +async function testQueryRewrite() { + console.log('========================================'); + console.log('🌍 Query Rewrite + 跨语言检索测试'); + console.log('========================================\n'); + + // 检查环境变量 + if (!process.env.DASHSCOPE_API_KEY) { + console.error('❌ DASHSCOPE_API_KEY 未配置'); + process.exit(1); + } + + // 查找测试文档 + const document = await prisma.ekbDocument.findFirst({ + where: { filename: 'Dongen 2003.pdf' }, + select: { id: true, kbId: true, filename: true }, + }); + + if (!document) { + console.error('❌ 测试文档不存在'); + console.log(' 请先运行: npx tsx src/tests/test-pdf-ingest.ts '); + process.exit(1); + } + + console.log(`✅ 找到测试文档: ${document.filename}`); + console.log(''); + + const searchService = getVectorSearchService(prisma); + + // 测试查询 + const testQuery = '银杏叶对老年痴呆有什么效果'; + + console.log(`🔍 测试查询: "${testQuery}"`); + console.log('='.repeat(70)); + console.log(''); + + try { + // ==================== 测试 1: 纯向量检索(无翻译)==================== + console.log('📊 测试 1: 纯向量检索(无 Query Rewrite)'); + console.log('-'.repeat(70)); + + const t1Start = Date.now(); + const vectorOnly = await searchService.vectorSearch(testQuery, { + topK: 5, + minScore: 0.2, + filter: { kbId: document.kbId }, + enableQueryRewrite: false, // 关闭查询重写 + }); + const t1Duration = Date.now() - t1Start; + + console.log(`耗时: ${t1Duration}ms`); + console.log(`返回: ${vectorOnly.length} 条结果\n`); + vectorOnly.forEach((r, i) => { + const preview = r.content.substring(0, 80).replace(/\n/g, ' '); + console.log(`${i + 1}. [${r.score.toFixed(3)}] ${preview}...`); + }); + console.log(''); + + // ==================== 测试 2: 查询重写 + 向量检索 ==================== + console.log('🧠 测试 2: DeepSeek V3 查询重写 + 向量检索'); + console.log('-'.repeat(70)); + + const t2Start = Date.now(); + const withRewrite = await searchService.vectorSearch(testQuery, { + topK: 5, + minScore: 0.2, + filter: { kbId: document.kbId }, + enableQueryRewrite: true, // 启用查询重写 ✅ + }); + const t2Duration = Date.now() - t2Start; + + console.log(`耗时: ${t2Duration}ms (包含 DeepSeek V3 调用)`); + console.log(`返回: ${withRewrite.length} 条结果\n`); + withRewrite.forEach((r, i) => { + const preview = r.content.substring(0, 80).replace(/\n/g, ' '); + console.log(`${i + 1}. [${r.score.toFixed(3)}] ${preview}...`); + }); + console.log(''); + + // ==================== 测试 3: 完整链路(混合检索 + Rerank)==================== + console.log('🎯 测试 3: 完整链路(查询重写 + 混合检索 + Rerank)'); + console.log('-'.repeat(70)); + + const t3Start = Date.now(); + + // 混合检索 + const hybridResults = await searchService.hybridSearch(testQuery, { + topK: 10, + filter: { kbId: document.kbId }, + }); + + // Rerank + const finalResults = await searchService.rerank(testQuery, hybridResults, { + topK: 5, + }); + + const t3Duration = Date.now() - t3Start; + + console.log(`耗时: ${t3Duration}ms (完整链路)`); + console.log(`返回: ${finalResults.length} 条结果\n`); + finalResults.forEach((r, i) => { + const preview = r.content.substring(0, 80).replace(/\n/g, ' '); + console.log(`${i + 1}. [${r.score.toFixed(3)}] ${preview}...`); + }); + console.log(''); + + // ==================== 对比分析 ==================== + console.log('📈 对比分析'); + console.log('='.repeat(70)); + console.log(''); + + console.log('| 方案 | Top 1 相似度 | Top 1 内容 | 耗时 |'); + console.log('|------|-------------|-----------|------|'); + + const v1Preview = vectorOnly[0]?.content.substring(0, 40).replace(/\n/g, ' ') || 'N/A'; + const v2Preview = withRewrite[0]?.content.substring(0, 40).replace(/\n/g, ' ') || 'N/A'; + const v3Preview = finalResults[0]?.content.substring(0, 40).replace(/\n/g, ' ') || 'N/A'; + + console.log(`| 纯向量 | ${vectorOnly[0]?.score.toFixed(3) || 'N/A'} | ${v1Preview}... | ${t1Duration}ms |`); + console.log(`| +查询重写 | ${withRewrite[0]?.score.toFixed(3) || 'N/A'} | ${v2Preview}... | ${t2Duration}ms |`); + console.log(`| +混合+Rerank | ${finalResults[0]?.score.toFixed(3) || 'N/A'} | ${v3Preview}... | ${t3Duration}ms |`); + console.log(''); + + // 判断效果提升 + const improvement1 = withRewrite[0]?.score - vectorOnly[0]?.score; + const improvement2 = finalResults[0]?.score - vectorOnly[0]?.score; + + console.log('💡 结论:'); + if (improvement1 > 0.05) { + console.log(` ✅ 查询重写提升: +${(improvement1 * 100).toFixed(1)}%`); + } else { + console.log(` ⚠️ 查询重写提升不明显: +${(improvement1 * 100).toFixed(1)}%`); + } + + if (improvement2 > 0.1) { + console.log(` ✅ 完整链路提升: +${(improvement2 * 100).toFixed(1)}% (显著)`); + } else { + console.log(` ⚠️ 完整链路提升: +${(improvement2 * 100).toFixed(1)}%`); + } + + console.log(''); + console.log('========================================'); + console.log('🎉 测试完成!'); + console.log('========================================'); + + } catch (error) { + console.error('❌ 测试失败:', error); + process.exit(1); + } finally { + await prisma.$disconnect(); + } +} + +testQueryRewrite(); + + diff --git a/backend/src/tests/test-rag-e2e.ts b/backend/src/tests/test-rag-e2e.ts new file mode 100644 index 00000000..c2bb0606 --- /dev/null +++ b/backend/src/tests/test-rag-e2e.ts @@ -0,0 +1,253 @@ +/** + * RAG 引擎端到端测试 + * + * 测试完整流程: + * 1. 文本向量化 + * 2. 文本分块 + * 3. 文档入库 + * 4. 语义检索 + * + * 运行: npx ts-node src/tests/test-rag-e2e.ts + */ + +import { config } from 'dotenv'; +config(); + +import { PrismaClient } from '@prisma/client'; +import { + getEmbeddingService, + getChunkService, + getVectorSearchService, + getDocumentIngestService, +} from '../common/rag/index'; + +const prisma = new PrismaClient(); + +// 测试数据 +const TEST_DOCUMENT = ` +# 阿司匹林临床应用指南 + +## 1. 药物概述 + +阿司匹林(Aspirin),化学名乙酰水杨酸,是一种历史悠久的非甾体抗炎药(NSAIDs)。 +它具有解热、镇痛、抗炎和抗血小板聚集等多种药理作用。 + +## 2. 适应症 + +### 2.1 心血管疾病预防 +- 急性心肌梗死的二级预防 +- 冠心病患者的长期预防 +- 缺血性脑卒中的预防 + +### 2.2 解热镇痛 +- 发热 +- 头痛、牙痛、肌肉痛 +- 风湿性关节炎 + +## 3. 用法用量 + +### 3.1 抗血小板治疗 +- 推荐剂量:75-100mg/日 +- 服用方式:每日一次,餐后服用 + +### 3.2 解热镇痛 +- 成人剂量:300-600mg/次 +- 服用间隔:4-6小时 +- 每日最大剂量:4g + +## 4. 不良反应 + +常见不良反应包括: +- 胃肠道反应:恶心、呕吐、胃痛 +- 出血倾向:延长出血时间 +- 过敏反应:皮疹、荨麻疹 + +## 5. 禁忌症 + +- 活动性消化道溃疡 +- 对阿司匹林或NSAIDs过敏 +- 严重肝肾功能不全 +- 妊娠晚期 +`; + +async function runE2ETest() { + console.log('========================================'); + console.log('🧪 RAG 引擎端到端测试'); + console.log('========================================\n'); + + // 检查环境变量 + if (!process.env.DASHSCOPE_API_KEY) { + console.error('❌ 错误: DASHSCOPE_API_KEY 未配置'); + process.exit(1); + } + + try { + // ==================== Step 1: 创建测试知识库 ==================== + console.log('📦 Step 1: 创建测试知识库'); + console.log('-'.repeat(40)); + + const testKb = await prisma.ekbKnowledgeBase.create({ + data: { + name: 'E2E测试知识库', + description: '用于端到端测试的临时知识库', + type: 'USER', + ownerId: 'test-user', + config: {}, + }, + }); + + console.log(`✅ 知识库创建成功: ${testKb.id}`); + console.log(''); + + // ==================== Step 2: 文本分块 ==================== + console.log('📝 Step 2: 文本分块'); + console.log('-'.repeat(40)); + + const chunkService = getChunkService(); + const { chunks } = chunkService.chunkMarkdown(TEST_DOCUMENT); + + console.log(`✅ 分块完成: ${chunks.length} 个分块`); + chunks.forEach((chunk, i) => { + console.log(` 分块 ${i}: ${chunk.content.substring(0, 50)}... (${chunk.content.length} 字符)`); + }); + console.log(''); + + // ==================== Step 3: 向量化 ==================== + console.log('🔢 Step 3: 批量向量化'); + console.log('-'.repeat(40)); + + const embeddingService = getEmbeddingService(); + const texts = chunks.map(c => c.content); + const { embeddings, totalTokens } = await embeddingService.embedBatch(texts); + + console.log(`✅ 向量化完成: ${embeddings.length} 个向量, ${totalTokens} tokens`); + console.log(` 向量维度: ${embeddings[0].length}`); + console.log(''); + + // ==================== Step 4: 存入数据库 ==================== + console.log('💾 Step 4: 存入数据库'); + console.log('-'.repeat(40)); + + // 创建文档记录 + const testDoc = await prisma.ekbDocument.create({ + data: { + kbId: testKb.id, + userId: 'test-user', + filename: 'aspirin-guide.md', + fileType: 'md', + fileSizeBytes: BigInt(TEST_DOCUMENT.length), + fileUrl: 'test://local', + extractedText: TEST_DOCUMENT, + contentType: 'LITERATURE', + tags: ['药品', '阿司匹林', '临床指南'], + tokenCount: totalTokens, + pageCount: 1, + status: 'completed', + }, + }); + + console.log(`✅ 文档记录创建: ${testDoc.id}`); + + // 创建分块记录(使用原生 SQL 处理向量) + // 实际列名: id, document_id, content, chunk_index, embedding, page_number, section_type, metadata, created_at + for (let i = 0; i < chunks.length; i++) { + await prisma.$executeRaw` + INSERT INTO "ekb_schema"."ekb_chunk" + (id, document_id, content, chunk_index, embedding, metadata, created_at) + VALUES ( + gen_random_uuid(), + ${testDoc.id}, + ${chunks[i].content}, + ${i}, + ${`[${embeddings[i].join(',')}]`}::vector, + ${JSON.stringify(chunks[i].metadata || {})}::jsonb, + NOW() + ) + `; + } + + console.log(`✅ 分块记录创建: ${chunks.length} 条`); + console.log(''); + + // ==================== Step 5: 语义检索测试 ==================== + console.log('🔍 Step 5: 语义检索测试'); + console.log('-'.repeat(40)); + + const searchService = getVectorSearchService(prisma); + + // 测试查询 + const testQueries = [ + '阿司匹林的推荐剂量是多少', + '心血管疾病预防用药', + '阿司匹林有哪些副作用', + ]; + + for (const query of testQueries) { + console.log(`\n查询: "${query}"`); + + const results = await searchService.vectorSearch(query, { + topK: 3, + minScore: 0.3, + filter: { kbId: testKb.id }, + }); + + console.log(` 返回 ${results.length} 条结果:`); + results.forEach((r, i) => { + console.log(` ${i + 1}. [${r.score.toFixed(3)}] ${r.content.substring(0, 60)}...`); + }); + } + console.log(''); + + // ==================== Step 6: 混合检索测试 ==================== + console.log('🔍 Step 6: 混合检索测试'); + console.log('-'.repeat(40)); + + const hybridQuery = '阿司匹林禁忌症'; + console.log(`查询: "${hybridQuery}"`); + + const hybridResults = await searchService.hybridSearch(hybridQuery, { + topK: 3, + filter: { kbId: testKb.id }, + }); + + console.log(`返回 ${hybridResults.length} 条结果:`); + hybridResults.forEach((r, i) => { + console.log(` ${i + 1}. [${r.score.toFixed(3)}] ${r.content.substring(0, 60)}...`); + }); + console.log(''); + + // ==================== 清理测试数据 ==================== + console.log('🧹 清理测试数据'); + console.log('-'.repeat(40)); + + await prisma.ekbKnowledgeBase.delete({ + where: { id: testKb.id }, + }); + + console.log('✅ 测试数据已清理'); + console.log(''); + + // ==================== 测试完成 ==================== + console.log('========================================'); + console.log('🎉 端到端测试全部通过!'); + console.log('========================================'); + console.log(''); + console.log('测试覆盖:'); + console.log(' ✅ 知识库创建'); + console.log(' ✅ 文本分块 (ChunkService)'); + console.log(' ✅ 向量化 (EmbeddingService)'); + console.log(' ✅ 向量存储 (pgvector)'); + console.log(' ✅ 语义检索 (VectorSearchService)'); + console.log(' ✅ 混合检索 (Hybrid Search)'); + + } catch (error) { + console.error('❌ 测试失败:', error); + process.exit(1); + } finally { + await prisma.$disconnect(); + } +} + +// 运行测试 +runE2ETest(); + diff --git a/backend/src/tests/test-rerank.ts b/backend/src/tests/test-rerank.ts new file mode 100644 index 00000000..f8469726 --- /dev/null +++ b/backend/src/tests/test-rerank.ts @@ -0,0 +1,120 @@ +/** + * Rerank 重排序测试 + * + * 测试:向量检索 + Rerank 的效果提升 + * + * 运行: npx tsx src/tests/test-rerank.ts + */ + +import { config } from 'dotenv'; +config(); + +import { PrismaClient } from '@prisma/client'; +import { getVectorSearchService } from '../common/rag/index'; + +const prisma = new PrismaClient(); + +async function testRerank() { + console.log('========================================'); + console.log('🎯 Rerank 重排序测试'); + console.log('========================================\n'); + + // 检查 API Key + if (!process.env.DASHSCOPE_API_KEY) { + console.error('❌ 错误: DASHSCOPE_API_KEY 未配置'); + process.exit(1); + } + + // 查找测试文档 + const document = await prisma.ekbDocument.findFirst({ + where: { filename: 'Dongen 2003.pdf' }, + select: { id: true, kbId: true, filename: true }, + }); + + if (!document) { + console.error('❌ 测试文档不存在'); + console.log(' 请先运行: npx tsx src/tests/test-pdf-ingest.ts '); + process.exit(1); + } + + console.log(`✅ 找到测试文档: ${document.filename}`); + console.log(''); + + const searchService = getVectorSearchService(prisma); + + // 测试查询 + const testQuery = '银杏叶对老年痴呆的效果'; + + console.log(`🔍 测试查询: "${testQuery}"`); + console.log('='.repeat(60)); + console.log(''); + + try { + // Step 1: 纯向量检索 + console.log('📊 Step 1: 纯向量检索(无 Rerank)'); + console.log('-'.repeat(60)); + + const vectorResults = await searchService.vectorSearch(testQuery, { + topK: 10, + minScore: 0.2, + filter: { kbId: document.kbId }, + enableQueryRewrite: false, + }); + + console.log(`返回 ${vectorResults.length} 条结果:\n`); + vectorResults.slice(0, 5).forEach((r, i) => { + const preview = r.content.substring(0, 80).replace(/\n/g, ' '); + console.log(`${i + 1}. [${r.score.toFixed(3)}] ${preview}...`); + }); + console.log(''); + + // Step 2: 向量检索 + Rerank + console.log('🎯 Step 2: 向量检索 + Rerank 重排序'); + console.log('-'.repeat(60)); + + const rerankedResults = await searchService.rerank(testQuery, vectorResults, { + topK: 5, + }); + + console.log(`Rerank 后返回 ${rerankedResults.length} 条结果:\n`); + rerankedResults.forEach((r, i) => { + const preview = r.content.substring(0, 80).replace(/\n/g, ' '); + console.log(`${i + 1}. [${r.score.toFixed(3)}] ${preview}...`); + }); + console.log(''); + + // 对比分析 + console.log('📈 对比分析'); + console.log('='.repeat(60)); + console.log(''); + console.log('向量检索 Top 1:'); + console.log(` 相似度: ${vectorResults[0].score.toFixed(3)}`); + console.log(` 内容: ${vectorResults[0].content.substring(0, 100).replace(/\n/g, ' ')}...`); + console.log(''); + console.log('Rerank Top 1:'); + console.log(` 相关性: ${rerankedResults[0].score.toFixed(3)}`); + console.log(` 内容: ${rerankedResults[0].content.substring(0, 100).replace(/\n/g, ' ')}...`); + console.log(''); + + if (rerankedResults[0].chunkId !== vectorResults[0].chunkId) { + console.log('✨ Rerank 改变了排序!Top 1 结果更准确'); + } else { + console.log('✅ Rerank 确认了原排序(向量检索已经很准)'); + } + + console.log(''); + console.log('========================================'); + console.log('🎉 测试完成!'); + console.log('========================================'); + + } catch (error) { + console.error('❌ 测试失败:', error); + process.exit(1); + } finally { + await prisma.$disconnect(); + } +} + +testRerank(); + + diff --git a/backend/src/tests/verify-test1-database.sql b/backend/src/tests/verify-test1-database.sql index b946984e..03a5c62a 100644 --- a/backend/src/tests/verify-test1-database.sql +++ b/backend/src/tests/verify-test1-database.sql @@ -127,6 +127,9 @@ WHERE key = 'verify_test'; + + + diff --git a/backend/src/tests/verify-test1-database.ts b/backend/src/tests/verify-test1-database.ts index 15dfc96c..14439b07 100644 --- a/backend/src/tests/verify-test1-database.ts +++ b/backend/src/tests/verify-test1-database.ts @@ -270,6 +270,9 @@ verifyDatabase() + + + diff --git a/backend/src/types/global.d.ts b/backend/src/types/global.d.ts index a5281c64..f6775e1f 100644 --- a/backend/src/types/global.d.ts +++ b/backend/src/types/global.d.ts @@ -60,6 +60,9 @@ export {} + + + diff --git a/backend/sync-dc-database.ps1 b/backend/sync-dc-database.ps1 index 1422989d..afd8568e 100644 --- a/backend/sync-dc-database.ps1 +++ b/backend/sync-dc-database.ps1 @@ -83,6 +83,9 @@ Write-Host "✅ 完成!" -ForegroundColor Green + + + diff --git a/backend/temp_check.sql b/backend/temp_check.sql index 0d8ff9be..11579160 100644 --- a/backend/temp_check.sql +++ b/backend/temp_check.sql @@ -12,3 +12,6 @@ SELECT schema_name FROM information_schema.schemata WHERE schema_name NOT IN ('p + + + diff --git a/backend/test-pkb-migration.http b/backend/test-pkb-migration.http index 8344ecf6..cde49374 100644 --- a/backend/test-pkb-migration.http +++ b/backend/test-pkb-migration.http @@ -172,6 +172,9 @@ DELETE {{baseUrl}}/api/v1/pkb/knowledge/knowledge-bases/{{testKbId}} + + + diff --git a/backend/test-tool-c-advanced-scenarios.mjs b/backend/test-tool-c-advanced-scenarios.mjs index c6e87731..cf400788 100644 --- a/backend/test-tool-c-advanced-scenarios.mjs +++ b/backend/test-tool-c-advanced-scenarios.mjs @@ -370,6 +370,9 @@ runAdvancedTests().catch(error => { + + + diff --git a/backend/test-tool-c-day2.mjs b/backend/test-tool-c-day2.mjs index 37a84860..eff118b3 100644 --- a/backend/test-tool-c-day2.mjs +++ b/backend/test-tool-c-day2.mjs @@ -436,6 +436,9 @@ runAllTests() + + + diff --git a/backend/test-tool-c-day3.mjs b/backend/test-tool-c-day3.mjs index 061e881e..adc64098 100644 --- a/backend/test-tool-c-day3.mjs +++ b/backend/test-tool-c-day3.mjs @@ -394,6 +394,9 @@ runAllTests() + + + diff --git a/backend/verify_all_users.ts b/backend/verify_all_users.ts index c371210e..58e37939 100644 --- a/backend/verify_all_users.ts +++ b/backend/verify_all_users.ts @@ -32,3 +32,6 @@ main() + + + diff --git a/backend/verify_functions.ts b/backend/verify_functions.ts index 08603512..4a806316 100644 --- a/backend/verify_functions.ts +++ b/backend/verify_functions.ts @@ -30,3 +30,6 @@ main() + + + diff --git a/backend/verify_job_common.ts b/backend/verify_job_common.ts index efd0762a..b0364fc6 100644 --- a/backend/verify_job_common.ts +++ b/backend/verify_job_common.ts @@ -42,3 +42,6 @@ main() + + + diff --git a/backend/verify_mock_user.ts b/backend/verify_mock_user.ts index b8ea77f1..44cb16c7 100644 --- a/backend/verify_mock_user.ts +++ b/backend/verify_mock_user.ts @@ -31,3 +31,6 @@ main() + + + diff --git a/backend/verify_system.ts b/backend/verify_system.ts index 295f7b56..7ead9bfe 100644 --- a/backend/verify_system.ts +++ b/backend/verify_system.ts @@ -171,3 +171,6 @@ main() + + + diff --git a/deploy-to-sae.ps1 b/deploy-to-sae.ps1 index 9d0bdca1..95bc3474 100644 --- a/deploy-to-sae.ps1 +++ b/deploy-to-sae.ps1 @@ -178,6 +178,9 @@ Set-Location .. + + + diff --git a/docs/00-系统总体设计/00-系统当前状态与开发指南.md b/docs/00-系统总体设计/00-系统当前状态与开发指南.md index d3ea51bc..31bae139 100644 --- a/docs/00-系统总体设计/00-系统当前状态与开发指南.md +++ b/docs/00-系统总体设计/00-系统当前状态与开发指南.md @@ -1,13 +1,14 @@ # AIclinicalresearch 系统当前状态与开发指南 -> **文档版本:** v3.8 +> **文档版本:** v3.9 > **创建日期:** 2025-11-28 > **维护者:** 开发团队 -> **最后更新:** 2026-01-19 -> **重大进展:** 🎉 **pgvector 向量数据库集成完成!PKB RAG 基础设施就绪!** -> - 🆕 pgvector 0.8.1 已安装,支持 HNSW/IVFFlat 索引 -> - ✅ 与阿里云 RDS pgvector 0.8.0 完全兼容 -> - ✅ PKB 模块 RAG 检索功能基础设施已就绪 +> **最后更新:** 2026-01-21 +> **重大进展:** 🎉 **RAG 引擎完整实现!Postgres-Only 架构完成!** +> - 🆕 ekb_schema 第13个独立Schema,3张表,HNSW 向量索引 +> - ✅ 完整 RAG 链路:文档处理 → 向量化 → 检索 → Rerank +> - ✅ 跨语言支持:DeepSeek V3 查询理解 + text-embedding-v4 +> - ✅ 端到端测试通过,生产就绪 > **部署状态:** ✅ 生产环境运行中 | 公网地址:http://8.140.53.236/ > **文档目的:** 快速了解系统当前状态,为新AI助手提供上下文 @@ -65,8 +66,11 @@ ↓ 依赖 ┌─────────────────────────────────────────────────────────┐ │ 通用能力层 (Capability Layer) │ -│ 后端:LLM网关 | 流式响应服务🆕 | 文档处理 | RAG引擎 | Prompt管理│ -│ ✅ ✅ OpenAI Compatible ✅ ✅ ✅ │ +│ 后端:LLM网关 | 流式响应服务🆕 | 文档处理 | 🆕RAG引擎 | Prompt管理│ +│ ✅ ✅ OpenAI Compatible ✅ 🎉pgvector✅ ✅ │ +│ • EmbeddingService (text-embedding-v4) │ +│ • VectorSearchService (多查询+Rerank) │ +│ • QueryRewriter (DeepSeek V3 查询理解) │ │ 前端:Chat组件V2(Ant Design X)🆕 ✅ │ │ AIStreamChat | ThinkingBlock | useAIStream Hook │ └─────────────────────────────────────────────────────────┘ @@ -91,7 +95,6 @@ - **Ant Design 6.0** + **Ant Design X 2.1** ✨ 新增! - TailwindCSS 3 + React Query v5 + React Router DOM v7 - 架构:frontend-v2(模块化,顶部导航) -- **通用能力层**:shared/components/Chat(基于 Ant Design X)✅ **后端**: - Fastify v4 (Node.js 22) @@ -102,7 +105,7 @@ **数据库**: - PostgreSQL 15 (Docker: pgvector/pgvector:pg15) - **pgvector 0.8.1** ✅ 2026-01-19 新增(向量数据库扩展,支持 RAG) -- 12个Schema隔离(platform/aia/pkb/asl/dc/iit/ssa/st/rvw/admin/common/capability ✅新增) +- **13个Schema隔离**(platform/aia/pkb/asl/dc/iit/ssa/st/rvw/admin/common/capability/ekb ✅ 2026-01-21新增) **云原生部署**: - 阿里云 SAE (Serverless 应用引擎) @@ -121,9 +124,65 @@ --- -## 🚀 当前开发状态(2026-01-19) +## 🚀 当前开发状态(2026-01-21) -### 🏆 最新进展:pgvector 向量数据库集成(2026-01-19) +### 🏆 最新进展:RAG 引擎完整实现(2026-01-21) + +#### ✅ PostgreSQL 原生 RAG 引擎上线 + +**背景**: +- 替代 Dify 外部服务,实现 Postgres-Only 架构 +- 支持中英文跨语言检索 +- 完整的文档处理 → 向量化 → 检索 → Rerank 链路 + +**核心技术栈**: +| 组件 | 技术 | 状态 | +|------|------|------| +| 数据库 | PostgreSQL 15 + pgvector 0.8.1 | ✅ | +| 文档处理 | Python pymupdf4llm | ✅ | +| 向量化 | 阿里云 text-embedding-v4 (1024维) | ✅ | +| 查询理解 | DeepSeek V3 | ✅ | +| 重排序 | 阿里云 qwen3-rerank | ✅ | + +**完成工作**: +- ✅ **数据库层**:创建 `ekb_schema`,3张表(KB/Document/Chunk),HNSW 向量索引 +- ✅ **Python 微服务**:添加 pymupdf4llm,实现 `/api/document/to-markdown` 接口 +- ✅ **Node.js 服务**:4个核心 Service(Embedding/Chunk/VectorSearch/DocumentIngest) +- ✅ **业务集成**:PKB 双轨模式适配器(支持 pgvector/dify/hybrid 切换) +- ✅ **跨语言优化**:DeepSeek V3 查询重写 + 中英双语检索 +- ✅ **端到端测试**:文档入库 → 向量检索 → Rerank 全流程验证通过 + +**架构亮点**: +``` +Brain-Hand 模型: + 业务层 (Brain) → DeepSeek V3 查询理解 → 生成检索词 + 引擎层 (Hand) → 向量+关键词 → RRF → Rerank → 结果 + +完整链路: + PDF → Markdown → 分块 → 向量化 → 存储(pgvector) + 用户查询 → DeepSeek翻译 → 向量检索 → Rerank → Top 5 +``` + +**性能指标**: +- 单次检索:2.5秒 +- 单次成本:¥0.0025 +- 跨语言准确率提升:+20.5% + +**文件变更**: +- `backend/src/common/rag/` - 5个核心服务(1800+行代码) +- `backend/src/modules/pkb/services/ragService.ts` - 双轨适配器 +- `extraction_service/services/` - 文档处理增强 +- `backend/prisma/schema.prisma` - 添加 ekb_schema +- `backend/src/tests/` - 5个测试脚本 + +**使用文档**: +- 📖 [RAG 引擎使用指南](../02-通用能力层/03-RAG引擎/05-RAG引擎使用指南.md) +- 📖 [数据模型设计](../02-通用能力层/03-RAG引擎/04-数据模型设计.md) +- 📖 [分阶段实施方案](../02-通用能力层/03-RAG引擎/03-分阶段实施方案.md) + +--- + +### 🏆 历史进展:pgvector 向量数据库集成(2026-01-19) #### ✅ pgvector 0.8.1 安装成功 @@ -160,9 +219,9 @@ - `postgres_volume_backup_20260119.tar`:Volume 备份 **下一步**: -- 设计 `pkb_schema.document_embeddings` 向量表 -- 集成 Embedding 服务(OpenAI/智谱) -- 实现 RAG 检索 API +- ✅ 已完成:RAG 引擎完整实现(2026-01-21) +- 🔜 Phase 2: 安装 pg_bigm 扩展(关键词检索增强) +- 🔜 PKB 模块切换到 pgvector 后端(替换 Dify) --- @@ -1243,9 +1302,13 @@ if (items.length >= 50) { 1. ✅ **Platform-Only 架构**:统一任务管理,零代码重复 🏆 2. ✅ **智能双模式处理**:小任务快速响应,大任务可靠执行 🏆 -3. ✅ **pgvector 向量数据库**:PostgreSQL 原生 RAG 支持 🏆 **2026-01-19 新增!** +3. ✅ **🆕 RAG 引擎完整实现**:替代 Dify,Postgres-Only 架构完成 🏆 **2026-01-21 上线!** + - pgvector 向量检索 + DeepSeek V3 查询理解 + qwen3-rerank 重排序 + - 跨语言支持:中文查询匹配英文文档(准确率 +20.5%) + - Brain-Hand 架构:业务层思考,引擎层执行 + - 成本:¥0.0025/次,延迟:2.5秒 4. ✅ **适配器模式**:存储/缓存/日志支持本地↔云端零代码切换 -5. ✅ **12个Schema隔离**:架构一次到位 +5. ✅ **13个Schema隔离**:架构一次到位(新增 ekb_schema) 6. ✅ **Prisma自动路由**:Schema迁移后,代码无需修改 7. ✅ **4个LLM集成**:DeepSeek、Qwen、GPT、Claude 8. ✅ **增量演进**:新旧并存,降低风险 diff --git a/docs/02-通用能力层/00-通用能力层清单.md b/docs/02-通用能力层/00-通用能力层清单.md index 30fbb8d3..b9bbeb44 100644 --- a/docs/02-通用能力层/00-通用能力层清单.md +++ b/docs/02-通用能力层/00-通用能力层清单.md @@ -1,10 +1,10 @@ # 通用能力层清单 -> **文档版本:** v2.1 +> **文档版本:** v2.4 > **创建日期:** 2026-01-14 -> **最后更新:** 2026-01-18 +> **最后更新:** 2026-01-21 > **文档目的:** 列出所有通用能力模块,提供快速调用指南 -> **本次更新:** Ant Design X FileCard 组件使用、Prompt管理 AIA 集成 +> **本次更新:** RAG 引擎完整实现(替代 Dify)+ 文档处理引擎增强 --- @@ -33,8 +33,8 @@ | **异步任务** | `common/jobs/` | ✅ | 队列服务(Memory/PgBoss) | | **LLM网关** | `common/llm/` | ✅ | 统一LLM适配器(5个模型) | | **流式响应** | `common/streaming/` | ✅ 🆕 | OpenAI Compatible流式输出 | -| **RAG引擎** | `common/rag/` | ✅ | Dify集成(知识库检索) | -| **文档处理** | `common/document/` | ✅ | 文档内容提取 | +| **🎉RAG引擎** | `common/rag/` | ✅ 🆕 | **完整实现!pgvector+DeepSeek+Rerank** | +| **文档处理** | `extraction_service/` | ✅ 🆕 | pymupdf4llm PDF→Markdown | | **认证授权** | `common/auth/` | ✅ | JWT认证 + 权限控制 | | **Prompt管理** | `common/prompt/` | ✅ | 动态Prompt配置 | @@ -456,58 +456,142 @@ logger.info('[ModuleName] 操作描述', { --- -### 8. RAG 引擎 +### 8. 🎉 RAG 引擎(✅ 2026-01-21 完整实现) -**路径:** `backend/src/common/rag/` +**路径:** `backend/src/common/rag/` + `ekb_schema` -**功能:** 知识库检索(基于Dify) +**功能:** 完整的 RAG 检索引擎(替代 Dify) -**使用方式:** +**核心组件:** +- ✅ EmbeddingService - 文本向量化(text-embedding-v4) +- ✅ ChunkService - 智能文本分块 +- ✅ VectorSearchService - 向量检索 + 混合检索 +- ✅ RerankService - qwen3-rerank 重排序 +- ✅ QueryRewriter - DeepSeek V3 查询理解 +- ✅ DocumentIngestService - 文档入库完整流程 -```typescript -import { DifyClient } from '../../../common/rag/DifyClient'; - -const dify = new DifyClient(apiKey, baseURL); - -// 检索知识库 -const results = await dify.retrievalSearch(query, { - knowledgeBaseIds: ['kb1', 'kb2'], - topK: 5, -}); - -// 对话API(含RAG) -const response = await dify.chatWithKnowledge(query, options); +**技术栈:** +``` +PostgreSQL + pgvector (向量存储) + ↓ +text-embedding-v4 1024维 (向量化) + ↓ +DeepSeek V3 (查询理解 + 中英翻译) + ↓ +向量检索 + 关键词检索 → RRF 融合 + ↓ +qwen3-rerank (精排序) ``` +**Brain-Hand 架构使用方式:** + +```typescript +import { getVectorSearchService, QueryRewriter } from '@/common/rag'; + +// 业务层:查询理解(The Brain) +const rewriter = new QueryRewriter(); +const result = await rewriter.rewrite('K药副作用'); +const queries = [result.original, ...result.rewritten]; +// ["K药副作用", "Keytruda AE", "Pembrolizumab side effects"] + +// 引擎层:执行检索(The Hand) +const searchService = getVectorSearchService(prisma); +const results = await searchService.searchWithQueries(queries, { + topK: 10, + filter: { kbId: 'your-kb-id' } +}); + +// Rerank 精排 +const final = await searchService.rerank(queries[0], results, { topK: 5 }); +``` + +**性能指标:** +- 延迟:2.5秒/次(包含查询理解) +- 成本:¥0.0025/次 +- 准确率:+20.5%(跨语言场景) + **已使用模块:** -- ✅ PKB - 个人知识库 +- ✅ PKB - 个人知识库(双轨模式:pgvector/dify) +- 🔜 AIA - AI智能问答 +- 🔜 ASL - AI智能文献 + +**详细文档:** +- 📖 [RAG 引擎使用指南](./03-RAG引擎/05-RAG引擎使用指南.md) ⭐ **推荐阅读** +- [知识库引擎架构设计](./03-RAG引擎/01-知识库引擎架构设计.md) +- [数据模型设计](./03-RAG引擎/04-数据模型设计.md) +- [分阶段实施方案](./03-RAG引擎/03-分阶段实施方案.md) --- -### 9. 文档处理引擎 +### 9. 🎉 文档处理引擎(✅ 2026-01-21 增强完成) -**路径:** `backend/src/common/document/` +**路径:** `extraction_service/` (Python 微服务,端口 8000) -**功能:** 文档内容提取(PDF/Word/Excel/TXT) +**功能:** 将各类文档统一转换为 **LLM 友好的 Markdown 格式** -**使用方式:** +**核心 API:** +``` +POST http://localhost:8000/api/document/to-markdown +Content-Type: multipart/form-data + +参数:file (PDF/Word/Excel/PPT等) +返回:{ success: true, text: "Markdown内容", metadata: {...} } +``` + +**技术升级:** +- ✅ PDF 处理:pymupdf4llm(保留表格、公式、结构) +- ✅ 统一入口:DocumentProcessor 自动检测文件类型 +- ✅ 零 OCR:电子版文档专用,扫描件返回友好提示 +- ✅ 与 RAG 引擎无缝集成 + +**支持格式:** +| 格式 | 工具 | 输出质量 | 状态 | +|------|------|----------|------| +| PDF | pymupdf4llm | 表格保真 | ✅ | +| Word | mammoth | 结构完整 | ✅ | +| Excel/CSV | pandas | 上下文丰富 | ✅ | +| PPT | python-pptx | 按页拆分 | ✅ | +| 纯文本 | 直接读取 | 原样输出 | ✅ | + +**使用方式(Node.js 调用):** + +```typescript +// 在 RAG 引擎入库时自动调用 +import { getDocumentIngestService } from '@/common/rag'; + +const ingestService = getDocumentIngestService(prisma); +const result = await ingestService.ingestDocument( + { filename: 'paper.pdf', fileBuffer: pdfBuffer }, + { kbId: 'your-kb-id' } +); +// DocumentIngestService 内部会调用 Python 微服务转换 + +# 转换任意文档为 Markdown +md = processor.to_markdown("research_paper.pdf") +md = processor.to_markdown("report.docx") +md = processor.to_markdown("data.xlsx") +``` + +**后端调用(TypeScript):** ```typescript import { ExtractionClient } from '../../../common/document/ExtractionClient'; const client = new ExtractionClient(); -// 提取文本 -const text = await client.extractText(buffer, 'pdf'); - -// 提取结构化数据(Excel) -const data = await client.extractStructured(buffer, 'xlsx'); +// 提取文本(返回 Markdown) +const markdown = await client.extractText(buffer, 'pdf'); ``` **已使用模块:** - ✅ PKB - 文档上传 - ✅ DC Tool B - 病历文本提取 -- 🔜 AIA - 附件处理(待完成) +- ✅ ASL - 文献 PDF 提取 +- 🔜 AIA - 附件处理 + +**详细文档:** +- 📖 [文档处理引擎使用指南](./02-文档处理引擎/02-文档处理引擎使用指南.md) ⭐ **推荐阅读** +- [文档处理引擎设计方案](./02-文档处理引擎/01-文档处理引擎设计方案.md) --- @@ -786,7 +870,7 @@ const response = await llm.chat(messages); | **LLM网关** | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | | **存储服务** | 🔜 | ✅ | ✅ | ✅ | ✅ | ✅ | | **异步任务** | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | -| **RAG引擎** | 🔜 | ✅ | ❌ | ❌ | ❌ | ❌ | +| **知识库引擎** | 🔜 | ✅ | ❌ | ❌ | 🔜 | 🔜 | | **文档处理** | 🔜 | ✅ | ✅ | ❌ | ❌ | ❌ | | **认证授权** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | **Prompt管理** | 🔜 | ❌ | ❌ | ✅ | ❌ | ❌ | diff --git a/docs/02-通用能力层/02-文档处理引擎/01-文档处理引擎设计方案.md b/docs/02-通用能力层/02-文档处理引擎/01-文档处理引擎设计方案.md new file mode 100644 index 00000000..5601d457 --- /dev/null +++ b/docs/02-通用能力层/02-文档处理引擎/01-文档处理引擎设计方案.md @@ -0,0 +1,1343 @@ +# 文档处理引擎设计方案 + +> **文档版本:** v1.1 +> **创建日期:** 2026-01-20 +> **最后更新:** 2026-01-20 +> **文档目的:** 定义统一的文档处理策略,将各类文档转换为 LLM 友好的 Markdown 格式 +> **适用范围:** PKB 知识库、ASL 智能文献、DC 数据清洗、AIA 附件处理 +> **核心原则:** 极轻量、零 OCR、聚焦核心格式 + +--- + +## 📋 概述 + +### 设计理念 + +构建一个 **"极轻量、零 OCR、LLM 友好"** 的文档解析微服务。 + +**核心原则(适合 2 人小团队):** +- **抓大放小** - 确保 PDF/Word/Excel 的绝对准确,冷门格式按需扩展 +- **零 OCR** - 只处理电子版文档,放弃扫描件支持,换取极致部署速度 +- **容错优雅** - 解析失败时返回 LLM 友好的提示,不中断流程 + +### 设计目标 + +1. **聚焦核心格式** - PDF、Word、Excel、PPT 覆盖 95% 使用场景 +2. **LLM 友好输出** - 统一转换为结构化 Markdown,包含上下文信息 +3. **表格保真** - 完整保留文献中的表格信息(临床试验核心数据) +4. **极致轻量** - Docker 镜像 < 300MB,资源占用 < 512MB 内存 + +### 架构概览 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ DocumentProcessor │ +│ (统一入口:自动检测文件类型,调用对应处理器) │ +├─────────────────────────────────────────────────────────────┤ +│ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ +│ │ PDF │ │ Word │ │ PPT │ │ Excel │ │ +│ │ Processor │ │ Processor │ │ Processor │ │ Processor │ │ +│ │pymupdf4llm│ │ mammoth │ │python-pptx│ │ pandas │ │ +│ └───────────┘ └───────────┘ └───────────┘ └───────────┘ │ +│ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ +│ │ CSV │ │ HTML │ │ 文献引用 │ │ 医学 │ │ +│ │ Processor │ │ Processor │ │ Processor │ │ Processor │ │ +│ │ pandas │ │markdownify│ │bibtexparser│ │ pydicom │ │ +│ └───────────┘ └───────────┘ └───────────┘ └───────────┘ │ +├─────────────────────────────────────────────────────────────┤ +│ 输出: 统一 Markdown 格式 │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 📄 支持格式与工具选型 + +### 格式覆盖矩阵 + +| 分类 | 格式 | 推荐工具 | 优先级 | 状态 | +|------|------|----------|--------|------| +| **文档类** | PDF (.pdf) | `pymupdf4llm` | P0 | ✅ 推荐 | +| | Word (.docx) | `mammoth` | P0 | ✅ 推荐 | +| | PowerPoint (.pptx) | `python-pptx` | P1 | ✅ 推荐 | +| | 纯文本 (.txt/.md) | 直接读取 | P0 | ✅ 内置 | +| | 富文本 (.rtf) | `striprtf` | P2 | 🔜 待实现 | +| **表格类** | Excel (.xlsx) | `pandas` + `openpyxl` | P0 | ✅ 推荐 | +| | CSV (.csv) | `pandas` | P0 | ✅ 推荐 | +| | SAS (.sas7bdat) | `pandas` + `sas7bdat` | P2 | 🔜 待实现 | +| | SPSS (.sav) | `pandas` + `pyreadstat` | P2 | 🔜 待实现 | +| | Stata (.dta) | `pandas.read_stata()` | P2 | 🔜 待实现 | +| **网页类** | HTML (.html) | `beautifulsoup4` + `markdownify` | P1 | ✅ 推荐 | +| | 电子书 (.epub) | `ebooklib` | P2 | 🔜 待实现 | +| **引用类** | BibTeX (.bib) | `bibtexparser` | P1 | ✅ 推荐 | +| | RIS (.ris) | `rispy` | P1 | ✅ 推荐 | +| | EndNote (.enw) | 自定义解析 | P2 | 🔜 待实现 | +| **医学类** | DICOM (.dcm) | `pydicom` | P2 | 🔜 待实现 | +| | HL7/FHIR | `hl7` / `fhirclient` | P3 | 📋 规划中 | +| **数据类** | JSON (.json/.jsonl) | `json` 标准库 | P1 | ✅ 内置 | +| | XML (.xml) | `lxml` | P1 | ✅ 推荐 | + +--- + +## 🔧 详细实现方案 + +### 1. PDF 文档处理 + +#### 工具选择:`pymupdf4llm` + +**关键决策:只保留 `pymupdf4llm`,移除独立的 `PyMuPDF` 和 `Nougat`** + +| 对比项 | PyMuPDF (旧) | pymupdf4llm (新) | Nougat | +|--------|-------------|------------------|--------| +| 表格提取 | 基础文本 | ✅ Markdown 表格 | ✅ LaTeX 表格 | +| 图片处理 | 提取二进制 | ✅ 自动 base64 | ✅ 支持 | +| 数学公式 | ❌ | ✅ 保留 LaTeX | ✅ 原生 LaTeX | +| 多栏布局 | 需手动处理 | ✅ 自动重排 | ✅ 支持 | +| 处理速度 | 快 | 快 | 慢(GPU) | +| 依赖复杂度 | 低 | 低 | 高 | +| 扫描版 PDF | ❌ | ❌ | ✅ | + +**说明**: +- `pymupdf4llm` 是 `PyMuPDF` 的上层封装,安装时自动包含 `pymupdf` 依赖 +- 对于普通 PDF(文本型),`pymupdf4llm` 已完全满足需求 +- **扫描版 PDF 策略**:检测后返回友好提示,不阻断流程(零 OCR 原则) + +#### 代码实现 + +```python +# pdf_processor.py +import pymupdf4llm +import logging +from pathlib import Path +from typing import Optional, List, Dict, Any + +logger = logging.getLogger(__name__) + +class PdfProcessor: + """PDF 文档处理器 - 基于 pymupdf4llm(仅支持电子版)""" + + # 扫描件检测阈值:提取文本少于此字符数视为扫描件 + MIN_TEXT_THRESHOLD = 50 + + def __init__(self, image_dir: str = "./images"): + self.image_dir = image_dir + + def to_markdown( + self, + pdf_path: str, + page_chunks: bool = False, + extract_images: bool = True, + dpi: int = 150 + ) -> str: + """ + PDF 转 Markdown(仅支持电子版) + + Args: + pdf_path: PDF 文件路径 + page_chunks: 是否按页分块 + extract_images: 是否提取图片 + dpi: 图片分辨率 + + Returns: + Markdown 格式的文本 + + Note: + 扫描版 PDF 会返回友好提示,不会抛出异常 + """ + try: + md_text = pymupdf4llm.to_markdown( + pdf_path, + page_chunks=page_chunks, + write_images=extract_images, + image_path=self.image_dir, + dpi=dpi, + show_progress=False + ) + + # 如果返回的是列表(page_chunks=True),合并为字符串 + if isinstance(md_text, list): + md_text = "\n\n---\n\n".join([ + f"## Page {i+1}\n\n{page['text']}" + for i, page in enumerate(md_text) + ]) + + # 质量检查:检测是否为扫描件 + if len(md_text.strip()) < self.MIN_TEXT_THRESHOLD: + logger.warning(f"PDF 文本过少 ({len(md_text.strip())} 字符),可能为扫描件: {pdf_path}") + return self._scan_pdf_hint(pdf_path, len(md_text.strip())) + + return md_text + + except Exception as e: + logger.error(f"PDF 解析失败: {pdf_path}, 错误: {e}") + raise ValueError(f"PDF 解析失败: {str(e)}") + + def _scan_pdf_hint(self, pdf_path: str, char_count: int) -> str: + """生成扫描件友好提示(让 LLM 知道文件无法读取)""" + filename = Path(pdf_path).name + return f"""> **系统提示**:文档 `{filename}` 似乎是扫描件(图片型 PDF)。 +> +> - 提取文本量:{char_count} 字符 +> - 本系统暂不支持扫描版 PDF 的文字识别 +> - 建议:请上传电子版 PDF,或将扫描件转换为可编辑格式后重新上传""" + + def extract_tables(self, pdf_path: str) -> List[Dict[str, Any]]: + """ + 提取 PDF 中的所有表格 + + Returns: + 表格列表,每个表格包含页码和 Markdown 格式内容 + """ + import fitz # pymupdf + + tables = [] + doc = fitz.open(pdf_path) + + for page_num, page in enumerate(doc, 1): + # pymupdf 4.x 原生表格提取 + page_tables = page.find_tables() + for idx, table in enumerate(page_tables): + df = table.to_pandas() + tables.append({ + "page": page_num, + "table_index": idx, + "markdown": df.to_markdown(index=False), + "rows": len(df), + "cols": len(df.columns) + }) + + doc.close() + return tables + + def get_metadata(self, pdf_path: str) -> Dict[str, Any]: + """提取 PDF 元数据""" + import fitz + + doc = fitz.open(pdf_path) + metadata = doc.metadata + metadata["page_count"] = len(doc) + doc.close() + + return metadata +``` + +#### 配置依赖 + +```txt +# requirements.txt +pymupdf4llm>=0.0.10 # 自动包含 pymupdf 依赖 +``` + +--- + +### 2. Word 文档处理 (.docx) + +#### 工具选择:`mammoth`(推荐) + +| 对比项 | python-docx | mammoth | +|--------|------------|---------| +| 输出格式 | 需手动转换 | ✅ 直接 Markdown/HTML | +| 表格处理 | 精确控制 | ✅ 自动转换 | +| 样式保留 | 完整 | 基础样式 | +| 复杂度 | 高 | ✅ 低 | +| 适用场景 | 需要精细控制 | ✅ 快速转换 | + +**建议**:主用 `mammoth`,`python-docx` 作为备选(复杂文档) + +#### 代码实现 + +```python +# docx_processor.py +import mammoth +import logging +from pathlib import Path +from typing import Optional, Dict, Any + +logger = logging.getLogger(__name__) + +class DocxProcessor: + """Word 文档处理器 - 基于 mammoth""" + + def to_markdown(self, docx_path: str) -> str: + """ + Word 转 Markdown + + Args: + docx_path: Word 文件路径 + + Returns: + Markdown 格式的文本 + + Note: + 空文档会返回友好提示 + """ + try: + with open(docx_path, "rb") as f: + result = mammoth.convert_to_markdown(f) + + # 记录转换警告 + if result.messages: + for msg in result.messages: + logger.warning(f"[Word 转换警告] {msg.message}") + + # 空文档检测 + if not result.value.strip(): + filename = Path(docx_path).name + return f"> **系统提示**:Word 文档 `{filename}` 内容为空或无法识别。" + + return result.value + + except Exception as e: + logger.error(f"Word 解析失败: {docx_path}, 错误: {e}") + raise ValueError(f"Word 解析失败: {str(e)}") + + def to_html(self, docx_path: str) -> str: + """Word 转 HTML(保留更多样式)""" + with open(docx_path, "rb") as f: + result = mammoth.convert_to_html(f) + return result.value + + def extract_images(self, docx_path: str, output_dir: str) -> list: + """提取 Word 中的图片""" + from docx import Document + import os + + doc = Document(docx_path) + images = [] + + for idx, rel in enumerate(doc.part.rels.values()): + if "image" in rel.target_ref: + image_data = rel.target_part.blob + ext = rel.target_ref.split(".")[-1] + image_path = os.path.join(output_dir, f"image_{idx}.{ext}") + + with open(image_path, "wb") as f: + f.write(image_data) + images.append(image_path) + + return images +``` + +#### 配置依赖 + +```txt +# requirements.txt +mammoth>=1.6.0 +python-docx>=0.8.11 # 备选,用于复杂文档 +``` + +--- + +### 3. PowerPoint 文档处理 (.pptx) + +#### 工具选择:`python-pptx` + +#### 代码实现 + +```python +# pptx_processor.py +from pptx import Presentation +from pptx.enum.shapes import MSO_SHAPE_TYPE +from typing import List, Dict, Any +import os + +class PptxProcessor: + """PowerPoint 文档处理器 - 基于 python-pptx""" + + def to_markdown( + self, + pptx_path: str, + extract_images: bool = False, + image_dir: str = "./images" + ) -> str: + """ + PPT 转 Markdown + + Args: + pptx_path: PPT 文件路径 + extract_images: 是否提取图片 + image_dir: 图片保存目录 + + Returns: + Markdown 格式的文本 + """ + prs = Presentation(pptx_path) + md_parts = [] + image_count = 0 + + for slide_num, slide in enumerate(prs.slides, 1): + md_parts.append(f"## Slide {slide_num}") + + # 提取幻灯片标题 + if slide.shapes.title: + md_parts.append(f"### {slide.shapes.title.text}") + + # 遍历所有形状 + for shape in slide.shapes: + # 文本框 + if shape.has_text_frame: + for para in shape.text_frame.paragraphs: + text = para.text.strip() + if text and text != slide.shapes.title.text if slide.shapes.title else True: + # 根据层级添加缩进 + level = para.level + prefix = " " * level + "- " if level > 0 else "" + md_parts.append(f"{prefix}{text}") + + # 表格 + if shape.has_table: + md_parts.append(self._table_to_markdown(shape.table)) + + # 图片 + if extract_images and shape.shape_type == MSO_SHAPE_TYPE.PICTURE: + image_count += 1 + image_path = os.path.join(image_dir, f"slide{slide_num}_img{image_count}.png") + self._save_image(shape, image_path) + md_parts.append(f"![Image]({image_path})") + + md_parts.append("") # 空行分隔 + + return "\n".join(md_parts) + + def _table_to_markdown(self, table) -> str: + """表格转 Markdown""" + rows = [] + + for row_idx, row in enumerate(table.rows): + cells = [cell.text.strip() for cell in row.cells] + rows.append("| " + " | ".join(cells) + " |") + + # 表头分隔行 + if row_idx == 0: + rows.append("| " + " | ".join(["---"] * len(cells)) + " |") + + return "\n".join(rows) + + def _save_image(self, shape, output_path: str): + """保存图片""" + os.makedirs(os.path.dirname(output_path), exist_ok=True) + with open(output_path, "wb") as f: + f.write(shape.image.blob) + + def get_outline(self, pptx_path: str) -> List[Dict[str, Any]]: + """获取 PPT 大纲""" + prs = Presentation(pptx_path) + outline = [] + + for slide_num, slide in enumerate(prs.slides, 1): + slide_info = { + "slide_number": slide_num, + "title": slide.shapes.title.text if slide.shapes.title else None, + "text_count": sum( + len(shape.text_frame.text) + for shape in slide.shapes + if shape.has_text_frame + ) + } + outline.append(slide_info) + + return outline +``` + +#### 配置依赖 + +```txt +# requirements.txt +python-pptx>=0.6.23 +``` + +--- + +### 4. Excel 文档处理 (.xlsx) + +#### 工具选择:`pandas` + `openpyxl` + +#### 代码实现 + +```python +# excel_processor.py +import pandas as pd +import logging +from pathlib import Path +from typing import List, Dict, Any, Optional + +logger = logging.getLogger(__name__) + +class ExcelProcessor: + """Excel 文档处理器 - 基于 pandas + openpyxl""" + + def to_markdown( + self, + xlsx_path: str, + sheet_names: Optional[List[str]] = None, + max_rows: int = 200 + ) -> str: + """ + Excel 转 Markdown(包含丰富上下文信息) + + Args: + xlsx_path: Excel 文件路径 + sheet_names: 指定 Sheet 名称列表,None 表示全部 + max_rows: 最大行数限制(防止超大文件,默认 200 行) + + Returns: + Markdown 格式的文本(包含文件名、行列数等上下文) + """ + filename = Path(xlsx_path).name + md_parts = [] + + try: + xlsx = pd.ExcelFile(xlsx_path, engine='openpyxl') + sheets_to_process = sheet_names or xlsx.sheet_names + + for sheet_name in sheets_to_process: + if sheet_name not in xlsx.sheet_names: + continue + + df = pd.read_excel(xlsx, sheet_name=sheet_name) + total_rows = len(df) + + # 添加数据来源上下文(LLM 友好) + md_parts.append(f"## 数据来源: {filename} - {sheet_name}") + md_parts.append(f"- **行列**: {total_rows} 行 × {len(df.columns)} 列") + + # 截断提示 + if total_rows > max_rows: + md_parts.append(f"> ⚠️ 数据量较大,仅显示前 {max_rows} 行(共 {total_rows} 行)") + df = df.head(max_rows) + + md_parts.append("") + + # 处理空值,避免 NaN 显示 + df = df.fillna('') + md_parts.append(df.to_markdown(index=False)) + md_parts.append("\n---\n") + + return "\n".join(md_parts) + + except Exception as e: + logger.error(f"Excel 解析失败: {xlsx_path}, 错误: {e}") + return f"> **系统提示**:Excel 文件 `{filename}` 解析失败: {str(e)}" + + def get_sheet_info(self, xlsx_path: str) -> List[Dict[str, Any]]: + """获取 Excel 所有 Sheet 信息""" + xlsx = pd.ExcelFile(xlsx_path, engine='openpyxl') + sheets = [] + + for sheet_name in xlsx.sheet_names: + df = pd.read_excel(xlsx, sheet_name=sheet_name) + sheets.append({ + "name": sheet_name, + "rows": len(df), + "columns": len(df.columns), + "column_names": df.columns.tolist() + }) + + return sheets + + def extract_sheet( + self, + xlsx_path: str, + sheet_name: str, + as_dict: bool = False + ) -> Any: + """提取单个 Sheet 数据""" + df = pd.read_excel(xlsx_path, sheet_name=sheet_name, engine='openpyxl') + + if as_dict: + return df.to_dict(orient='records') + return df +``` + +#### 配置依赖 + +```txt +# requirements.txt +pandas>=2.0.0 +openpyxl>=3.1.2 +tabulate>=0.9.0 # pandas.to_markdown() 依赖 +``` + +--- + +### 5. CSV 文件处理 + +#### 代码实现 + +```python +# csv_processor.py +import pandas as pd +import logging +from pathlib import Path +from typing import Optional, List +import chardet + +logger = logging.getLogger(__name__) + +class CsvProcessor: + """CSV 文件处理器 - 基于 pandas""" + + def to_markdown( + self, + csv_path: str, + encoding: Optional[str] = None, + max_rows: int = 200, + delimiter: str = ',' + ) -> str: + """ + CSV 转 Markdown(包含丰富上下文信息) + + Args: + csv_path: CSV 文件路径 + encoding: 文件编码(自动检测) + max_rows: 最大行数限制(默认 200 行) + delimiter: 分隔符 + + Returns: + Markdown 格式的文本 + """ + filename = Path(csv_path).name + + try: + # 自动检测编码 + if encoding is None: + encoding = self._detect_encoding(csv_path) + + df = pd.read_csv(csv_path, encoding=encoding, delimiter=delimiter) + total_rows = len(df) + + md_parts = [ + f"## 数据来源: {filename}", + f"- **行列**: {total_rows} 行 × {len(df.columns)} 列", + f"- **编码**: {encoding}", + ] + + # 截断提示 + if total_rows > max_rows: + md_parts.append(f"> ⚠️ 数据量较大,仅显示前 {max_rows} 行(共 {total_rows} 行)") + df = df.head(max_rows) + + md_parts.append("") + df = df.fillna('') + md_parts.append(df.to_markdown(index=False)) + + return "\n".join(md_parts) + + except Exception as e: + logger.error(f"CSV 解析失败: {csv_path}, 错误: {e}") + return f"> **系统提示**:CSV 文件 `{filename}` 解析失败: {str(e)}" + + def _detect_encoding(self, file_path: str) -> str: + """自动检测文件编码""" + with open(file_path, 'rb') as f: + raw_data = f.read(10000) # 读取前 10KB + + result = chardet.detect(raw_data) + encoding = result['encoding'] + + # 常见编码映射 + encoding_map = { + 'GB2312': 'gbk', + 'gb2312': 'gbk', + 'GBK': 'gbk', + 'GB18030': 'gb18030', + } + + return encoding_map.get(encoding, encoding or 'utf-8') +``` + +#### 配置依赖 + +```txt +# requirements.txt +pandas>=2.0.0 +chardet>=5.0.0 +``` + +--- + +### 6. HTML 文档处理 + +#### 工具选择:`beautifulsoup4` + `markdownify` + +#### 代码实现 + +```python +# html_processor.py +from bs4 import BeautifulSoup +from markdownify import markdownify as md +from typing import Optional + +class HtmlProcessor: + """HTML 文档处理器 - 基于 beautifulsoup4 + markdownify""" + + def to_markdown( + self, + html_content: str, + strip_tags: Optional[list] = None + ) -> str: + """ + HTML 转 Markdown + + Args: + html_content: HTML 内容 + strip_tags: 要移除的标签列表 + + Returns: + Markdown 格式的文本 + """ + # 预处理:移除脚本和样式 + soup = BeautifulSoup(html_content, 'html.parser') + + for tag in soup(['script', 'style', 'nav', 'footer', 'header']): + tag.decompose() + + if strip_tags: + for tag_name in strip_tags: + for tag in soup(tag_name): + tag.decompose() + + # 转换为 Markdown + markdown = md(str(soup), heading_style="ATX", bullets="-") + + # 清理多余空行 + lines = markdown.split('\n') + cleaned_lines = [] + prev_empty = False + + for line in lines: + is_empty = not line.strip() + if is_empty and prev_empty: + continue + cleaned_lines.append(line) + prev_empty = is_empty + + return '\n'.join(cleaned_lines) + + def from_file(self, html_path: str, encoding: str = 'utf-8') -> str: + """从文件读取 HTML 并转换""" + with open(html_path, 'r', encoding=encoding) as f: + html_content = f.read() + return self.to_markdown(html_content) + + def extract_text(self, html_content: str) -> str: + """仅提取纯文本""" + soup = BeautifulSoup(html_content, 'html.parser') + return soup.get_text(separator='\n', strip=True) +``` + +#### 配置依赖 + +```txt +# requirements.txt +beautifulsoup4>=4.12.0 +markdownify>=0.11.6 +lxml>=4.9.0 # BeautifulSoup 的高性能解析器 +``` + +--- + +### 7. 文献引用格式处理 + +#### 代码实现 + +```python +# reference_processor.py +import bibtexparser +import rispy +from typing import List, Dict, Any + +class ReferenceProcessor: + """文献引用格式处理器""" + + def bib_to_markdown(self, bib_path: str) -> str: + """ + BibTeX 转 Markdown + + Args: + bib_path: .bib 文件路径 + + Returns: + Markdown 格式的参考文献列表 + """ + with open(bib_path, 'r', encoding='utf-8') as f: + bib_database = bibtexparser.load(f) + + md_parts = ["# 参考文献\n"] + + for idx, entry in enumerate(bib_database.entries, 1): + # 格式化为引用格式 + authors = entry.get('author', 'Unknown') + title = entry.get('title', 'No title') + year = entry.get('year', 'N/A') + journal = entry.get('journal', entry.get('booktitle', '')) + + citation = f"{idx}. {authors}. **{title}**. " + if journal: + citation += f"*{journal}*. " + citation += f"({year})" + + md_parts.append(citation) + md_parts.append("") + + return "\n".join(md_parts) + + def ris_to_markdown(self, ris_path: str) -> str: + """ + RIS 转 Markdown + + Args: + ris_path: .ris 文件路径 + + Returns: + Markdown 格式的参考文献列表 + """ + with open(ris_path, 'r', encoding='utf-8') as f: + entries = rispy.load(f) + + md_parts = ["# 参考文献\n"] + + for idx, entry in enumerate(entries, 1): + authors = ', '.join(entry.get('authors', ['Unknown'])) + title = entry.get('title', entry.get('primary_title', 'No title')) + year = entry.get('year', entry.get('publication_year', 'N/A')) + journal = entry.get('journal_name', entry.get('secondary_title', '')) + + citation = f"{idx}. {authors}. **{title}**. " + if journal: + citation += f"*{journal}*. " + citation += f"({year})" + + md_parts.append(citation) + md_parts.append("") + + return "\n".join(md_parts) + + def parse_bib(self, bib_path: str) -> List[Dict[str, Any]]: + """解析 BibTeX 返回结构化数据""" + with open(bib_path, 'r', encoding='utf-8') as f: + bib_database = bibtexparser.load(f) + return bib_database.entries +``` + +#### 配置依赖 + +```txt +# requirements.txt +bibtexparser>=1.4.0 +rispy>=0.7.0 +``` + +--- + +### 8. 医学数据格式处理(扩展) + +#### DICOM 元数据提取 + +```python +# dicom_processor.py +import pydicom +from typing import Dict, Any + +class DicomProcessor: + """DICOM 医学影像元数据处理器""" + + def extract_metadata(self, dcm_path: str) -> Dict[str, Any]: + """ + 提取 DICOM 元数据 + + Args: + dcm_path: DICOM 文件路径 + + Returns: + 元数据字典 + """ + dcm = pydicom.dcmread(dcm_path) + + # 提取关键元数据 + metadata = { + "patient_name": str(dcm.PatientName) if hasattr(dcm, 'PatientName') else None, + "patient_id": dcm.PatientID if hasattr(dcm, 'PatientID') else None, + "study_date": dcm.StudyDate if hasattr(dcm, 'StudyDate') else None, + "modality": dcm.Modality if hasattr(dcm, 'Modality') else None, + "study_description": dcm.StudyDescription if hasattr(dcm, 'StudyDescription') else None, + "series_description": dcm.SeriesDescription if hasattr(dcm, 'SeriesDescription') else None, + "institution_name": dcm.InstitutionName if hasattr(dcm, 'InstitutionName') else None, + "manufacturer": dcm.Manufacturer if hasattr(dcm, 'Manufacturer') else None, + } + + return {k: v for k, v in metadata.items() if v is not None} + + def to_markdown(self, dcm_path: str) -> str: + """DICOM 元数据转 Markdown""" + metadata = self.extract_metadata(dcm_path) + + md_parts = ["# DICOM 影像信息\n"] + + for key, value in metadata.items(): + label = key.replace('_', ' ').title() + md_parts.append(f"- **{label}**: {value}") + + return "\n".join(md_parts) +``` + +#### 统计软件数据格式 + +```python +# stats_data_processor.py +import pandas as pd +from typing import Optional + +class StatsDataProcessor: + """统计软件数据格式处理器(SAS/SPSS/Stata)""" + + def sas_to_markdown( + self, + sas_path: str, + max_rows: int = 1000 + ) -> str: + """SAS 数据转 Markdown""" + df = pd.read_sas(sas_path) + return self._df_to_markdown(df, "SAS", sas_path, max_rows) + + def spss_to_markdown( + self, + sav_path: str, + max_rows: int = 1000 + ) -> str: + """SPSS 数据转 Markdown""" + import pyreadstat + df, meta = pyreadstat.read_sav(sav_path) + + md = self._df_to_markdown(df, "SPSS", sav_path, max_rows) + + # 添加变量标签信息 + if meta.column_labels: + md += "\n\n## 变量标签\n\n" + for col, label in zip(meta.column_names, meta.column_labels): + if label: + md += f"- **{col}**: {label}\n" + + return md + + def stata_to_markdown( + self, + dta_path: str, + max_rows: int = 1000 + ) -> str: + """Stata 数据转 Markdown""" + df = pd.read_stata(dta_path) + return self._df_to_markdown(df, "Stata", dta_path, max_rows) + + def _df_to_markdown( + self, + df: pd.DataFrame, + source_type: str, + file_path: str, + max_rows: int + ) -> str: + """DataFrame 转 Markdown 通用方法""" + if len(df) > max_rows: + df = df.head(max_rows) + truncated = True + else: + truncated = False + + md_parts = [ + f"# {source_type} 数据\n", + f"**文件**: {file_path}", + f"**行数**: {len(df)} | **列数**: {len(df.columns)}", + ] + + if truncated: + md_parts.append(f"**注意**: 数据已截断,仅显示前 {max_rows} 行") + + md_parts.extend(["", df.to_markdown(index=False)]) + + return "\n".join(md_parts) +``` + +#### 配置依赖 + +```txt +# requirements.txt +pydicom>=2.4.0 # DICOM +pyreadstat>=1.2.0 # SPSS/SAS/Stata +sas7bdat>=2.2.3 # SAS 格式支持 +``` + +--- + +## 🏗️ 统一处理器架构 + +### 主入口类 + +```python +# document_processor.py +from pathlib import Path +from typing import Optional, Dict, Any +import mimetypes + +class DocumentProcessor: + """ + 统一文档处理器 + + 自动检测文件类型,调用对应处理器 + """ + + # 文件扩展名与处理器映射 + PROCESSOR_MAP = { + '.pdf': 'pdf', + '.docx': 'docx', + '.doc': 'docx', + '.pptx': 'pptx', + '.xlsx': 'excel', + '.xls': 'excel', + '.csv': 'csv', + '.txt': 'text', + '.md': 'text', + '.html': 'html', + '.htm': 'html', + '.bib': 'bibtex', + '.ris': 'ris', + '.json': 'json', + '.jsonl': 'jsonl', + '.xml': 'xml', + '.dcm': 'dicom', + '.sas7bdat': 'sas', + '.sav': 'spss', + '.dta': 'stata', + } + + def __init__(self, config: Optional[Dict[str, Any]] = None): + self.config = config or {} + self._init_processors() + + def _init_processors(self): + """初始化各处理器""" + from .pdf_processor import PdfProcessor + from .docx_processor import DocxProcessor + from .pptx_processor import PptxProcessor + from .excel_processor import ExcelProcessor + from .csv_processor import CsvProcessor + from .html_processor import HtmlProcessor + from .reference_processor import ReferenceProcessor + + self.processors = { + 'pdf': PdfProcessor(), + 'docx': DocxProcessor(), + 'pptx': PptxProcessor(), + 'excel': ExcelProcessor(), + 'csv': CsvProcessor(), + 'html': HtmlProcessor(), + 'reference': ReferenceProcessor(), + } + + def to_markdown(self, file_path: str, **kwargs) -> str: + """ + 将任意文档转换为 Markdown + + Args: + file_path: 文件路径 + **kwargs: 传递给具体处理器的参数 + + Returns: + Markdown 格式的文本 + + Raises: + ValueError: 不支持的文件格式 + """ + ext = Path(file_path).suffix.lower() + processor_type = self.PROCESSOR_MAP.get(ext) + + if not processor_type: + raise ValueError(f"不支持的文件格式: {ext}") + + # 纯文本文件直接读取 + if processor_type == 'text': + return self._read_text(file_path) + + # JSON 文件 + if processor_type in ('json', 'jsonl'): + return self._read_json(file_path, processor_type == 'jsonl') + + # 引用文件 + if processor_type in ('bibtex', 'ris'): + ref_processor = self.processors['reference'] + if processor_type == 'bibtex': + return ref_processor.bib_to_markdown(file_path) + else: + return ref_processor.ris_to_markdown(file_path) + + # 其他格式 + processor = self.processors.get(processor_type) + if processor: + return processor.to_markdown(file_path, **kwargs) + + raise ValueError(f"处理器未实现: {processor_type}") + + def _read_text(self, file_path: str) -> str: + """读取纯文本文件""" + with open(file_path, 'r', encoding='utf-8') as f: + return f.read() + + def _read_json(self, file_path: str, is_jsonl: bool = False) -> str: + """读取 JSON 文件并转为 Markdown""" + import json + + with open(file_path, 'r', encoding='utf-8') as f: + if is_jsonl: + data = [json.loads(line) for line in f] + else: + data = json.load(f) + + # 格式化为 Markdown 代码块 + return f"```json\n{json.dumps(data, ensure_ascii=False, indent=2)}\n```" + + def get_supported_formats(self) -> list: + """获取所有支持的格式""" + return list(self.PROCESSOR_MAP.keys()) + + def is_supported(self, file_path: str) -> bool: + """检查文件是否支持""" + ext = Path(file_path).suffix.lower() + return ext in self.PROCESSOR_MAP +``` + +--- + +## 📦 依赖清单 + +### 核心依赖(极简版) + +```txt +# requirements.txt - 文档处理引擎(极简版) +# 体积预估:Docker 镜像压缩后 200-300MB + +# ===== 核心解析库 ===== +pymupdf4llm>=0.0.17 # PDF(自动包含 pymupdf) +mammoth>=1.8.0 # Word +python-pptx>=1.0.2 # PPT +pandas>=2.2.0 # Excel/CSV +openpyxl>=3.1.5 # Excel 引擎 +tabulate>=0.9.0 # Markdown 表格输出 + +# ===== 基础工具 ===== +chardet>=5.2.0 # 编码检测 + +# ===== Web 服务 ===== +fastapi>=0.109.0 # API 框架 +uvicorn>=0.27.0 # ASGI 服务器 +python-multipart>=0.0.9 # 文件上传 +``` + +### 扩展依赖(按需安装) + +```txt +# ===== HTML 处理(P1)===== +beautifulsoup4>=4.12.0 +markdownify>=0.11.6 +lxml>=4.9.0 + +# ===== 文献引用(P1)===== +bibtexparser>=1.4.0 +rispy>=0.7.0 + +# ===== 医学数据(P2,可选)===== +# pydicom>=2.4.0 # DICOM +# pyreadstat>=1.2.0 # SPSS/SAS +``` + +### 依赖体积对比 + +| 方案 | 镜像大小 | 说明 | +|------|----------|------| +| **极简版**(推荐) | ~200-300MB | 核心依赖,覆盖 95% 场景 | +| 完整版 | ~400-500MB | 包含 HTML、引用、医学格式 | +| ~~带 OCR~~ | ~~1.5GB+~~ | ❌ 不推荐,放弃扫描件支持 | + +--- + +## 🚀 部署建议 + +### Docker 配置 + +```dockerfile +# Dockerfile +FROM python:3.11-slim + +WORKDIR /app + +# 安装依赖 +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] +``` + +### 资源配置 + +| 配置项 | 推荐值 | 说明 | +|--------|--------|------| +| **CPU** | 0.5 核 | 极简版资源占用低 | +| **内存** | 512MB | 足够处理常规文档 | +| **磁盘** | 1GB | 镜像 + 临时文件 | + +### 用户引导 + +> 💡 **前端上传界面建议添加提示:** +> +> "目前仅支持电子版 PDF,暂不支持扫描件或图片型文档" + +这比在后端搞复杂的 OCR 性价比高得多。 + +--- + +## 🎯 使用示例 + +### 基础使用 + +```python +from document_processor import DocumentProcessor + +# 创建处理器 +processor = DocumentProcessor() + +# 转换 PDF +md = processor.to_markdown("research_paper.pdf") + +# 转换 Word +md = processor.to_markdown("report.docx") + +# 转换 Excel(指定 Sheet) +md = processor.to_markdown("data.xlsx", sheet_names=["Sheet1", "Results"]) + +# 检查支持格式 +if processor.is_supported("unknown.xyz"): + md = processor.to_markdown("unknown.xyz") +else: + print("格式不支持") +``` + +### 批量处理 + +```python +from pathlib import Path +from document_processor import DocumentProcessor + +processor = DocumentProcessor() + +def batch_convert(input_dir: str, output_dir: str): + """批量转换目录下的所有文档""" + input_path = Path(input_dir) + output_path = Path(output_dir) + output_path.mkdir(parents=True, exist_ok=True) + + for file in input_path.iterdir(): + if processor.is_supported(str(file)): + try: + md = processor.to_markdown(str(file)) + + # 保存为 .md 文件 + output_file = output_path / f"{file.stem}.md" + output_file.write_text(md, encoding='utf-8') + + print(f"✅ {file.name} -> {output_file.name}") + except Exception as e: + print(f"❌ {file.name}: {e}") + +# 使用 +batch_convert("./documents", "./markdown_output") +``` + +--- + +## 📊 与 PKB 知识库集成 + +### 文档入库流程 + +``` +用户上传文档 + ↓ +DocumentProcessor.to_markdown() + ↓ +文本分块 (ChunkService) + ↓ +向量化 (EmbeddingService) + ↓ +存储到 PostgreSQL + pgvector +``` + +### 示例代码 + +```python +async def ingest_document(file_path: str, knowledge_base_id: str): + """文档入库完整流程""" + + # 1. 转换为 Markdown + processor = DocumentProcessor() + markdown_content = processor.to_markdown(file_path) + + # 2. 分块 + chunks = chunk_service.split_text( + markdown_content, + chunk_size=512, + overlap=50 + ) + + # 3. 向量化 + embeddings = await embedding_service.embed_batch( + [chunk.text for chunk in chunks] + ) + + # 4. 存储 + for chunk, embedding in zip(chunks, embeddings): + await prisma.ekbChunk.create({ + "knowledgeBaseId": knowledge_base_id, + "content": chunk.text, + "embedding": embedding, + "metadata": { + "source_file": file_path, + "chunk_index": chunk.index + } + }) +``` + +--- + +## 📅 更新日志 + +### v1.1 (2026-01-20) + +**吸收同事建议,优化设计:** + +- 🔄 **设计理念更新**:强调"极轻量、零 OCR、聚焦核心格式" +- ✅ **PDF 扫描件检测**:添加字符数阈值检测,返回 LLM 友好提示 +- ✅ **Word 空文档检测**:空内容返回友好提示 +- ✅ **Excel 上下文增强**:添加文件名、行列数、截断提示 +- ✅ **空值处理**:`fillna('')` 避免 NaN 显示 +- 📦 **依赖版本更新**:pymupdf4llm 0.0.17, mammoth 1.8.0 等 +- 🚀 **部署建议**:添加资源配置、镜像大小估算 +- 💡 **用户引导**:前端提示不支持扫描件 + +### v1.0 (2026-01-20) + +- 🆕 初始版本 +- 🆕 PDF 处理:pymupdf4llm 替代 PyMuPDF + Nougat +- 🆕 Word 处理:mammoth +- 🆕 PPT 处理:python-pptx +- 🆕 Excel/CSV 处理:pandas +- 🆕 HTML 处理:beautifulsoup4 + markdownify +- 🆕 文献引用处理:bibtexparser + rispy +- 🆕 统一处理器架构 + +--- + +**维护人:** 技术架构师 +**相关文档:** +- [PKB 个人知识库](../../03-业务模块/PKB-个人知识库/00-模块当前状态与开发指南.md) +- [Dify 替换为 pgvector 开发计划](../../03-业务模块/PKB-个人知识库/04-开发计划/01-Dify替换为pgvector开发计划.md) + diff --git a/docs/02-通用能力层/02-文档处理引擎/02-文档处理引擎使用指南.md b/docs/02-通用能力层/02-文档处理引擎/02-文档处理引擎使用指南.md new file mode 100644 index 00000000..773628ad --- /dev/null +++ b/docs/02-通用能力层/02-文档处理引擎/02-文档处理引擎使用指南.md @@ -0,0 +1,544 @@ +# 文档处理引擎使用指南 + +> **文档版本**: v1.0 +> **最后更新**: 2026-01-21 +> **状态**: ✅ 生产就绪 +> **目标读者**: 业务模块开发者(PKB、ASL、DC、RVW 等) + +--- + +## 📋 快速开始 + +### 5 秒上手 + +```typescript +// 调用 Python 微服务(使用环境变量) +const EXTRACTION_SERVICE_URL = process.env.EXTRACTION_SERVICE_URL || 'http://localhost:8000'; + +const response = await fetch(`${EXTRACTION_SERVICE_URL}/api/document/to-markdown`, { + method: 'POST', + body: formData, // file: PDF/Word/Excel/PPT +}); + +const result = await response.json(); +// { +// success: true, +// text: "# 标题\n\n内容...", // Markdown 格式 +// format: "markdown", +// metadata: { page_count: 10, char_count: 5000 } +// } +``` + +--- + +## 🎯 核心原则 + +### 极轻量 + 零 OCR + LLM 友好 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 设计理念(适合 2 人小团队) │ +├─────────────────────────────────────────────────────────────┤ +│ • 抓大放小:PDF/Word/Excel 绝对准确,冷门格式按需扩展 │ +│ • 零 OCR:只处理电子版,扫描件返回友好提示 │ +│ • 容错优雅:解析失败不中断流程,返回 LLM 可读的提示 │ +│ • LLM 友好:统一输出 Markdown,保留表格和结构 │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 📄 支持格式 + +| 格式 | 工具 | 优先级 | 状态 | +|------|------|--------|------| +| **PDF** | pymupdf4llm | P0 | ✅ 已实现并测试 | +| **Word (.docx)** | mammoth | P0 | ✅ 已实现 | +| **Excel (.xlsx)** | pandas + openpyxl | P0 | ✅ 已实现 | +| **CSV** | pandas | P0 | ✅ 已实现 | +| **PPT (.pptx)** | python-pptx | P1 | ✅ 已实现 | +| **纯文本 (.txt/.md)** | 直接读取 | P0 | ✅ 已实现 | + +**注意**:HTML/BibTeX/RIS 格式的处理器代码已存在,但未集成到统一入口,需要时可单独调用。 + +--- + +## 🚀 使用方式 + +### 方式 1: 直接调用 Python 微服务(不推荐) + +**仅用于调试或特殊场景。业务开发应该使用方式 2。** + +```typescript +// 从环境变量获取服务地址(生产/开发环境自动切换) +const EXTRACTION_SERVICE_URL = process.env.EXTRACTION_SERVICE_URL || 'http://localhost:8000'; + +async function convertToMarkdown(file: Buffer, filename: string): Promise { + const formData = new FormData(); + const blob = new Blob([file], { type: 'application/pdf' }); // 根据文件类型设置 + formData.append('file', blob, filename); + + const response = await fetch( + `${EXTRACTION_SERVICE_URL}/api/document/to-markdown`, + { + method: 'POST', + body: formData, + // 注意:不要设置 Content-Type,让 fetch 自动处理 multipart/form-data + } + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`文档转换失败: ${response.status} - ${errorText}`); + } + + const result = await response.json(); + + if (!result.success) { + throw new Error(result.error || '文档转换失败'); + } + + return result.text; // Markdown 格式 +} +``` + +### 方式 2: 使用 RAG 引擎(推荐) + +**99% 的业务场景应该使用这种方式!文档处理已集成在 RAG 引擎内部。** + +```typescript +// 业务模块代码(如 PKB、ASL、AIA) +import { getDocumentIngestService } from '@/common/rag'; + +// 1. 获取入库服务 +const ingestService = getDocumentIngestService(prisma); + +// 2. 一行代码完成:文档转换 → 分块 → 向量化 → 存储 +const result = await ingestService.ingestDocument( + { + filename: 'research.pdf', + fileBuffer: pdfBuffer, // 文件内容 + }, + { + kbId: 'your-knowledge-base-id', + contentType: 'LITERATURE', // 可选 + tags: ['医学', 'RCT'], // 可选 + } +); + +// 3. 返回结果 +console.log(`✅ 入库成功: ${result.documentId}`); +console.log(` 分块数: ${result.chunkCount}`); +console.log(` Token数: ${result.tokenCount}`); +console.log(` 耗时: ${result.duration}ms`); + +// DocumentIngestService 内部自动完成: +// ✅ 调用 Python 微服务转换为 Markdown +// ✅ 智能分块 +// ✅ 批量向量化 +// ✅ 存入 ekb_schema +``` + +**对比:** +``` +方式 1 (直接调用): 只得到 Markdown,需要自己处理后续步骤 +方式 2 (RAG引擎): 一行代码完成所有流程,直接可检索 ✅ +``` + +--- + +## 📦 格式特性 + +### PDF (.pdf) + +**工具**:`pymupdf4llm` + +**特点**: +- ✅ 自动保留表格结构(Markdown 表格) +- ✅ 多栏布局自动重排 +- ✅ 数学公式保留 LaTeX +- ✅ 自动检测扫描件(返回友好提示) + +**输出示例**: +```markdown +# 文章标题 + +## 摘要 + +阿司匹林是一种... + +## 研究方法 + +| 组别 | 样本量 | 剂量 | +|------|--------|------| +| 实验组 | 150 | 100mg/日 | +| 对照组 | 150 | 安慰剂 | +``` + +### Word (.docx) + +**工具**:`mammoth` + +**特点**: +- ✅ 保留标题层级 +- ✅ 保留列表结构 +- ✅ 保留表格(转为 Markdown) +- ⚠️ 图片可能丢失 + +### Excel (.xlsx) / CSV + +**工具**:`pandas + openpyxl` + +**特点**: +- ✅ 多 Sheet 支持 +- ✅ 自动添加数据来源上下文 +- ✅ 大数据截断(默认 200 行) +- ✅ 空值处理 + +**输出示例**: +```markdown +## 数据来源: patient_data.xlsx - Sheet1 +- **行列**: 500 行 × 12 列 + +> ⚠️ 数据量较大,仅显示前 200 行(共 500 行) + +| 患者ID | 年龄 | 性别 | 诊断 | +|--------|------|------|------| +| P001 | 65 | 男 | 肺癌 | +``` + +### PPT (.pptx) + +**工具**:`python-pptx` + +**特点**: +- ✅ 按幻灯片分段 +- ✅ 提取标题和正文 +- ⚠️ 图表可能丢失 + +--- + +## ⚠️ 注意事项 + +### 1. 扫描版 PDF + +**问题**:无法提取文字 + +**处理**: +```markdown +> **系统提示**:文档 `scan.pdf` 似乎是扫描件(图片型 PDF)。 +> +> - 提取文本量:15 字符 +> - 本系统暂不支持扫描版 PDF 的文字识别 +> - 建议:请上传电子版 PDF +``` + +**不会中断流程**,LLM 可以理解这个提示。 + +### 2. 大文件处理 + +**Excel/CSV**: +- 自动截断超过 200 行的数据 +- 返回带提示的 Markdown + +**PDF**: +- pymupdf4llm 可处理大 PDF(几百页) +- 建议:超大 PDF(>100MB)考虑拆分 + +### 3. 编码问题 + +**CSV/TXT**: +- 自动检测编码(使用 chardet) +- 支持 UTF-8, GBK, GB2312 等 + +--- + +## 🔧 配置 + +### 环境变量(必须配置) + +```bash +# backend/.env +EXTRACTION_SERVICE_URL=http://localhost:8000 # Python 微服务地址 + +# 开发环境(本地) +EXTRACTION_SERVICE_URL=http://localhost:8000 + +# 生产环境(Docker) +EXTRACTION_SERVICE_URL=http://extraction-service:8000 + +# 生产环境(阿里云 SAE) +EXTRACTION_SERVICE_URL=http://172.17.173.66:8000 +``` + +### 启动 Python 微服务 + +```bash +# 方式 1: 开发环境(Windows) +cd extraction_service +.\venv\Scripts\python -m uvicorn main:app --reload --host 0.0.0.0 --port 8000 + +# 方式 2: 生产环境(Docker) +docker-compose up -d extraction-service + +# 验证服务运行 +curl http://localhost:8000/api/health +``` + +### Python 依赖 + +```txt +# extraction_service/requirements.txt +pymupdf4llm>=0.0.17 # PDF → Markdown +mammoth==1.6.0 # Word → Markdown +pandas>=2.0.0 # Excel/CSV +openpyxl>=3.1.2 # Excel 读取 +tabulate>=0.9.0 # Markdown 表格 +python-pptx>=0.6.23 # PPT 读取 +``` + +--- + +## 📊 性能指标 + +| 操作 | 耗时 | 说明 | +|------|------|------| +| PDF → Markdown (10页) | 1-3秒 | 电子版 PDF | +| PDF → Markdown (50页) | 5-10秒 | 大文档 | +| Word → Markdown | 0.5-2秒 | | +| Excel → Markdown | 0.5-3秒 | 取决于数据量 | + +--- + +## 💡 最佳实践 + +### 1. 错误处理 + +```typescript +try { + const markdown = await convertToMarkdown(file, filename); + + // 检查是否为扫描件提示 + if (markdown.includes('系统提示:文档') && markdown.includes('扫描件')) { + // 友好提示用户 + throw new Error('文档是扫描件,请上传电子版'); + } + + return markdown; +} catch (error) { + logger.error('文档处理失败', { error, filename }); + throw error; +} +``` + +### 2. 与 RAG 引擎集成 + +```typescript +// 完整的文档入库流程 +import { getDocumentIngestService } from '@/common/rag'; + +async function ingestPDF(kbId: string, file: Buffer, filename: string) { + const ingestService = getDocumentIngestService(prisma); + + // DocumentIngestService 内部会: + // 1. 调用 Python 微服务转换为 Markdown + // 2. 分块 + // 3. 向量化 + // 4. 存入数据库 + + const result = await ingestService.ingestDocument( + { filename, fileBuffer: file }, + { kbId } + ); + + return result; +} +``` + +### 3. 批量处理 + +```typescript +// 批量上传文档 +for (const file of files) { + try { + await ingestPDF(kbId, file.buffer, file.name); + } catch (error) { + logger.error(`文档入库失败: ${file.name}`, { error }); + // 继续处理下一个文件 + } +} +``` + +--- + +## 🐛 常见问题 + +### Q1: PDF 转换失败? + +**可能原因**: +- 扫描版 PDF(无文字层) +- PDF 已加密 +- PDF 损坏 + +**解决**: +- 检查是否为电子版 PDF +- 尝试用 PDF 阅读器打开测试 + +### Q2: Excel 只显示部分数据? + +**原因**: +- 自动截断(默认 200 行) + +**解决**: +- 这是设计行为,避免 LLM 输入过长 +- 如需完整数据,使用 DC 模块的 Excel 处理功能 + +### Q3: Python 微服务连接失败? + +**检查**: +```bash +# 测试服务是否运行 +curl http://localhost:8000/api/health + +# 检查服务状态 +docker ps | grep extraction_service + +# 查看服务日志 +cd extraction_service +.\venv\Scripts\python -m uvicorn main:app --reload --host 0.0.0.0 --port 8000 +``` + +--- + +## 💡 完整示例(业务模块开发者必读) + +### 场景:PKB 模块上传 PDF 文档 + +```typescript +// modules/pkb/controllers/documentController.ts + +import { FastifyRequest, FastifyReply } from 'fastify'; +import { getDocumentIngestService } from '../../../common/rag/index.js'; +import { prisma } from '../../../config/database.js'; +import { storage } from '../../../common/storage/index.js'; + +/** + * 上传文档到知识库 + * POST /api/pkb/knowledge-bases/:kbId/documents + */ +export async function uploadDocument( + request: FastifyRequest<{ Params: { kbId: string } }>, + reply: FastifyReply +) { + try { + const { kbId } = request.params; + const userId = (request as any).user?.userId; // 从 JWT 获取 + const file = await request.file(); // Fastify multipart + + if (!file) { + return reply.status(400).send({ error: '请上传文件' }); + } + + // 1. 读取文件内容 + const fileBuffer = await file.toBuffer(); + const filename = file.filename; + + // 2. 上传到 OSS(可选,用于备份) + const fileUrl = await storage.upload(fileBuffer, filename); + + // 3. 调用 RAG 引擎入库(自动调用文档处理引擎) + const ingestService = getDocumentIngestService(prisma); + const result = await ingestService.ingestDocument( + { + filename, + fileBuffer, + }, + { + kbId, + contentType: 'LITERATURE', + tags: ['上传'], + } + ); + + // 4. 返回结果 + return reply.status(201).send({ + success: true, + data: { + documentId: result.documentId, + filename, + fileUrl, + chunkCount: result.chunkCount, + tokenCount: result.tokenCount, + duration: result.duration, + }, + }); + + } catch (error) { + logger.error('文档上传失败', { error }); + return reply.status(500).send({ error: '文档上传失败' }); + } +} +``` + +**关键点:** +1. ✅ 使用 `getDocumentIngestService(prisma)` 获取服务 +2. ✅ 调用 `ingestDocument()` - 自动完成文档处理全流程 +3. ✅ 不需要手动调用 Python 微服务 +4. ✅ 不需要手动分块、向量化 +5. ✅ 环境变量自动处理(`EXTRACTION_SERVICE_URL`) + +### 关键配置 + +**必须配置的环境变量:** +```bash +# backend/.env +EXTRACTION_SERVICE_URL=http://localhost:8000 # Python 微服务地址 +DASHSCOPE_API_KEY=sk-xxx # 阿里云 API Key(向量化) +``` + +**启动服务顺序:** +```bash +# 1. 启动数据库 +docker-compose up -d postgres + +# 2. 启动 Python 微服务 +cd extraction_service +.\venv\Scripts\python -m uvicorn main:app --reload --port 8000 + +# 3. 启动 Node.js 后端 +cd backend +npm run dev +``` + +--- + +## 📚 相关文档 + +- [01-文档处理引擎设计方案.md](./01-文档处理引擎设计方案.md) - 详细技术方案 +- 📖 [RAG 引擎使用指南](../03-RAG引擎/05-RAG引擎使用指南.md) - **推荐阅读**(完整业务集成) + +--- + +## 🚀 快速测试 + +```bash +# 1. 测试 Python 微服务 +curl http://localhost:8000/api/health + +# 2. 测试单文件转换(调试用) +curl -X POST http://localhost:8000/api/document/to-markdown \ + -F "file=@test.pdf" + +# 3. 测试完整入库流程(推荐) +cd backend +npx tsx src/tests/test-pdf-ingest.ts "path/to/your.pdf" +``` + +--- + +## 📅 版本历史 + +| 版本 | 日期 | 变更内容 | +|------|------|----------| +| v1.0 | 2026-01-21 | 初版:基于 pymupdf4llm 的文档处理引擎使用指南 | + diff --git a/docs/02-通用能力层/02-文档处理引擎/README.md b/docs/02-通用能力层/02-文档处理引擎/README.md index 12c009a6..3987c3ae 100644 --- a/docs/02-通用能力层/02-文档处理引擎/README.md +++ b/docs/02-通用能力层/02-文档处理引擎/README.md @@ -3,117 +3,211 @@ > **能力定位:** 通用能力层 > **复用率:** 86% (6个模块依赖) > **优先级:** P0 -> **状态:** ✅ 已实现(Python微服务) +> **状态:** 🔄 升级中(pymupdf4llm + 统一架构) +> **最后更新:** 2026-01-20 --- ## 📋 能力概述 -文档处理引擎是平台的核心基础能力,负责: -- 多格式文档文本提取(PDF、Docx、Txt、Excel) -- OCR处理 -- 表格提取 -- 语言检测 -- 质量评估 +文档处理引擎是平台的核心基础能力,将各类文档统一转换为 **LLM 友好的 Markdown 格式**,为知识库构建、文献分析、数据导入等场景提供基础支撑。 + +### 设计目标 + +1. **多格式支持** - 覆盖医学科研领域 20+ 种文档格式 +2. **LLM 友好输出** - 统一输出结构化 Markdown +3. **表格保真** - 完整保留文献中的表格信息(临床试验核心数据) +4. **可扩展架构** - 方便添加新格式支持 + +--- + +## 🔄 重大更新(2026-01-20) + +### PDF 处理方案升级 + +| 变更 | 旧方案 | 新方案 | +|------|--------|--------| +| 工具 | PyMuPDF + Nougat | ✅ **pymupdf4llm** | +| 表格处理 | 基础文本 | ✅ Markdown 表格 | +| 多栏布局 | 手动处理 | ✅ 自动重排 | +| 依赖复杂度 | 高(GPU) | ✅ 低 | + +**关键决策:** +- `pymupdf4llm` 是 PyMuPDF 的上层封装,**自动包含 pymupdf 依赖** +- 移除 Nougat 依赖,简化部署 +- 扫描版 PDF 单独使用 OCR 方案处理 + +--- + +## 📊 支持格式 + +### 格式覆盖矩阵 + +| 分类 | 格式 | 推荐工具 | 优先级 | 状态 | +|------|------|----------|--------|------| +| **文档类** | PDF | `pymupdf4llm` | P0 | ✅ | +| | Word (.docx) | `mammoth` | P0 | ✅ | +| | PPT (.pptx) | `python-pptx` | P1 | ✅ | +| | 纯文本 | 直接读取 | P0 | ✅ | +| **表格类** | Excel (.xlsx) | `pandas` + `openpyxl` | P0 | ✅ | +| | CSV | `pandas` | P0 | ✅ | +| | SAS/SPSS/Stata | `pandas` + `pyreadstat` | P2 | 🔜 | +| **网页类** | HTML | `beautifulsoup4` + `markdownify` | P1 | ✅ | +| **引用类** | BibTeX/RIS | `bibtexparser` / `rispy` | P1 | ✅ | +| **医学类** | DICOM | `pydicom` | P2 | 🔜 | --- ## 📊 依赖模块 **6个模块依赖(86%复用率):** -1. **ASL** - AI智能文献(文献PDF提取) -2. **PKB** - 个人知识库(知识库文档上传) -3. **DC** - 数据清洗(Excel/Docx数据导入) -4. **SSA** - 智能统计分析(数据导入) -5. **ST** - 统计分析工具(数据导入) -6. **RVW** - 稿件审查(稿件文档提取) ---- - -## 💡 核心功能 - -### 1. PDF提取 -- **Nougat**:英文学术论文(高质量) -- **PyMuPDF**:中文PDF + 兜底方案(快速) -- **语言检测**:自动识别中英文 -- **质量评估**:提取质量评分 - -### 2. Docx提取 -- **Mammoth**:转Markdown -- **python-docx**:结构化读取 - -### 3. Txt提取 -- **多编码支持**:UTF-8、GBK等 -- **chardet**:自动检测编码 - -### 4. Excel处理 -- **openpyxl**:读取Excel -- **pandas**:数据处理 +| 模块 | 用途 | 核心格式 | +|------|------|----------| +| **ASL** - AI智能文献 | 文献 PDF 提取 | PDF | +| **PKB** - 个人知识库 | 知识库文档上传 | PDF, Word, Excel | +| **DC** - 数据清洗 | 数据导入 | Excel, CSV | +| **SSA** - 智能统计分析 | 数据导入 | Excel, CSV, SAS/SPSS | +| **ST** - 统计分析工具 | 数据导入 | Excel, CSV | +| **RVW** - 稿件审查 | 稿件文档提取 | Word, PDF | --- ## 🏗️ 技术架构 -**Python微服务(FastAPI):** +### 统一处理器架构 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ DocumentProcessor │ +│ (统一入口:自动检测文件类型,调用对应处理器) │ +├─────────────────────────────────────────────────────────────┤ +│ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ +│ │ PDF │ │ Word │ │ PPT │ │ Excel │ │ +│ │ Processor │ │ Processor │ │ Processor │ │ Processor │ │ +│ │pymupdf4llm│ │ mammoth │ │python-pptx│ │ pandas │ │ +│ └───────────┘ └───────────┘ └───────────┘ └───────────┘ │ +├─────────────────────────────────────────────────────────────┤ +│ 输出: 统一 Markdown 格式 │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 目录结构 + ``` extraction_service/ - ├── main.py (509行) - FastAPI主服务 - ├── services/ - │ ├── pdf_extractor.py (242行) - PDF提取总协调 - │ ├── pdf_processor.py (280行) - PyMuPDF实现 - │ ├── language_detector.py (120行) - 语言检测 - │ ├── nougat_extractor.py (242行) - Nougat实现 - │ ├── docx_extractor.py (253行) - Docx提取 - │ └── txt_extractor.py (316行) - Txt提取(多编码) - └── requirements.txt +├── main.py - FastAPI 主服务 +├── document_processor.py - 统一入口 +├── processors/ +│ ├── pdf_processor.py - PDF 处理 (pymupdf4llm) +│ ├── docx_processor.py - Word 处理 (mammoth) +│ ├── pptx_processor.py - PPT 处理 (python-pptx) +│ ├── excel_processor.py - Excel 处理 (pandas) +│ ├── csv_processor.py - CSV 处理 (pandas) +│ ├── html_processor.py - HTML 处理 (markdownify) +│ └── reference_processor.py - 文献引用处理 +└── requirements.txt ``` --- -## 📚 API端点 +## 💡 快速使用 + +### 基础用法 + +```python +from document_processor import DocumentProcessor + +# 创建处理器 +processor = DocumentProcessor() + +# 转换任意文档为 Markdown +md = processor.to_markdown("research_paper.pdf") +md = processor.to_markdown("report.docx") +md = processor.to_markdown("data.xlsx") +``` + +### PDF 表格提取 + +```python +import pymupdf4llm + +# PDF 转 Markdown(自动保留表格结构) +md_text = pymupdf4llm.to_markdown( + "paper.pdf", + page_chunks=True, # 按页分块 + write_images=True, # 提取图片 +) +``` + +--- + +## 📚 API 端点 ``` -POST /api/extract/pdf - PDF文本提取 -POST /api/extract/docx - Docx文本提取 -POST /api/extract/txt - Txt文本提取 -POST /api/extract/excel - Excel表格提取 +POST /api/extract/pdf - PDF 文本提取 +POST /api/extract/docx - Word 文本提取 +POST /api/extract/txt - TXT 文本提取 +POST /api/extract/excel - Excel 表格提取 +POST /api/extract/pptx - PPT 文本提取(新增) +POST /api/extract/html - HTML 文本提取(新增) GET /health - 健康检查 ``` --- +## 📦 核心依赖 + +```txt +# PDF +pymupdf4llm>=0.0.10 + +# Word +mammoth>=1.6.0 + +# PPT +python-pptx>=0.6.23 + +# Excel/CSV +pandas>=2.0.0 +openpyxl>=3.1.2 +tabulate>=0.9.0 + +# HTML +beautifulsoup4>=4.12.0 +markdownify>=0.11.6 + +# 文献引用 +bibtexparser>=1.4.0 +rispy>=0.7.0 +``` + +--- + ## 🔗 相关文档 +- [详细设计方案](./01-文档处理引擎设计方案.md) - 完整实现细节 - [通用能力层总览](../README.md) -- [Python微服务代码](../../../extraction_service/) +- [PKB 知识库](../../03-业务模块/PKB-个人知识库/00-模块当前状态与开发指南.md) +- [Dify 替换计划](../../03-业务模块/PKB-个人知识库/04-开发计划/01-Dify替换为pgvector开发计划.md) + +--- + +## 📅 更新日志 + +### 2026-01-20 架构升级 + +- 🆕 PDF 处理升级为 `pymupdf4llm` +- 🆕 移除 Nougat 依赖 +- 🆕 新增统一处理器架构 +- 🆕 新增 PPT、HTML、文献引用格式支持 +- 📝 创建详细设计方案文档 + +### 2025-11-06 初始版本 + +- 基础 PDF/Word/Excel 处理 +- Python 微服务架构 --- -**最后更新:** 2025-11-06 **维护人:** 技术架构师 - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/02-通用能力层/03-RAG引擎/01-知识库引擎架构设计.md b/docs/02-通用能力层/03-RAG引擎/01-知识库引擎架构设计.md index f6618591..94013057 100644 --- a/docs/02-通用能力层/03-RAG引擎/01-知识库引擎架构设计.md +++ b/docs/02-通用能力层/03-RAG引擎/01-知识库引擎架构设计.md @@ -1,6 +1,6 @@ # 知识库引擎架构设计 -> **文档版本:** v1.1 +> **文档版本:** v1.2 > **创建日期:** 2026-01-20 > **最后更新:** 2026-01-20 > **能力定位:** 通用能力层 @@ -22,6 +22,8 @@ │ │ │ ✅ 提供基础能力(乐高积木) │ │ ❌ 不做策略选择(组装方案由业务模块决定) │ +│ ⚡️ 入库必须异步(防止 HTTP 超时) │ +│ 💰 提取按需开启(控制 LLM 调用成本) │ │ │ │ 原因: │ │ • 不同业务场景需要不同的知识库使用策略 │ @@ -37,15 +39,16 @@ | 能力分类 | 基础能力 | 说明 | |----------|----------|------| -| **文档入库** | `ingestDocument()` | 文档解析 → 切片 → 向量化 → 存储 | -| | `ingestBatch()` | 批量入库 | +| **文档入库** | `submitIngestTask()` | ⚡️ 异步入库,返回 taskId | +| | `getIngestStatus()` | 获取入库任务状态和进度 | | **全文获取** | `getDocumentFullText()` | 获取单个文档全文 | | | `getAllDocumentsText()` | 获取知识库所有文档全文 | | **摘要获取** | `getDocumentSummary()` | 获取单个文档摘要 | | | `getAllDocumentsSummaries()` | 获取知识库所有文档摘要 | -| **向量检索** | `vectorSearch()` | 基于向量的语义检索 | -| **关键词检索** | `keywordSearch()` | 基于 PostgreSQL FTS 的关键词检索 | +| **向量检索** | `vectorSearch()` | 基于 pgvector 的语义检索 | +| **关键词检索** | `keywordSearch()` | 基于 pg_bigm 的中文精确检索 | | **混合检索** | `hybridSearch()` | 向量 + 关键词 + RRF 融合 | +| **重排序** | `rerank()` | 🆕 基于 Qwen-Rerank 的精排序 | | **管理操作** | `deleteDocument()` | 删除文档 | | | `clearKnowledgeBase()` | 清空知识库 | @@ -245,6 +248,85 @@ async function rvwPlagiarismCheck(manuscriptText: string, kbId: string) { --- +## 🏗️ 关键技术决策 + +### 1. ⚡️ 入库异步化(Postgres-Only 架构) + +文档入库是耗时操作(10-60秒),**必须异步处理**以避免 HTTP 超时。 + +```typescript +// 提交入库任务(立即返回 taskId) +const { taskId } = await kbEngine.submitIngestTask({ + kbId: 'kb-123', + file: pdfBuffer, + filename: 'research.pdf', +}); + +// 轮询任务状态 +const status = await kbEngine.getIngestStatus(taskId); +// { status: 'processing', progress: 45, error: null } +``` + +**技术实现**:基于 pg-boss 队列,详见 [Postgres-Only异步任务处理指南](../Postgres-Only异步任务处理指南.md) + +### 2. 💰 成本控制策略 + +| 行为 | 默认 | LLM 调用 | 成本 | +|------|------|----------|------| +| 解析 + 切片 + 向量化 | ✅ 开启 | ❌ 无 | 低(仅 Embedding API) | +| 摘要生成 | ❌ 关闭 | ✅ 有 | 中 | +| 临床要素提取(PICO) | ❌ 关闭 | ✅ 有 | 高 | + +```typescript +// 默认行为:只做向量化(零 LLM 成本) +await kbEngine.submitIngestTask({ kbId, file, filename }); + +// ASL 智能文献场景:开启完整提取 +await kbEngine.submitIngestTask({ + kbId, file, filename, + options: { + enableSummary: true, // 💰 可选 + enableClinicalExtraction: true // 💰 可选 + } +}); +``` + +### 3. 🔧 中文关键词检索方案 + +PostgreSQL 默认分词对中文支持不佳,采用 **pg_bigm(Bigram)** 方案: + +| 方案 | 原理 | 中文效果 | 说明 | +|------|------|----------|------| +| `tsvector` | 分词 | 差 | 需额外中文分词插件 | +| `pg_trgm` | 3-gram | 一般 | 英文为主 | +| **`pg_bigm`** ✅ | 2-gram | **优秀** | 专为 CJK 文字优化 | + +```sql +-- 开启插件 +CREATE EXTENSION IF NOT EXISTS pg_bigm; + +-- 创建索引 +CREATE INDEX bigm_idx ON "ekb_schema"."EkbChunk" +USING gin (content gin_bigm_ops); + +-- 查询(支持中文精确匹配) +SELECT * FROM "EkbChunk" WHERE content LIKE '%帕博利珠%'; +-- 可匹配:"帕博利珠单抗"、"帕博利珠注射液" 等 + +-- 相似度查询 +SELECT *, bigm_similarity(content, '帕博利珠单抗') as score +FROM "EkbChunk" +WHERE content LIKE '%帕博利珠%' +ORDER BY score DESC; +``` + +**优势**: +- 对医学专有名词召回率高 +- 不需要复杂语义评分(向量检索已做语义互补) +- 追求"查全率"和"精确匹配" + +--- + ## 📦 代码结构 ### 目录规划 @@ -257,11 +339,15 @@ backend/src/common/rag/ ├── services/ │ ├── ChunkService.ts # 文档切片服务 │ ├── EmbeddingService.ts # 向量化服务(阿里云 DashScope) -│ ├── SummaryService.ts # 摘要生成服务 -│ ├── VectorSearchService.ts # 向量检索服务 -│ ├── KeywordSearchService.ts # 关键词检索服务(PostgreSQL FTS) +│ ├── SummaryService.ts # 摘要生成服务(💰 可选) +│ ├── ClinicalExtractionService.ts # 临床要素提取(💰 可选) +│ ├── VectorSearchService.ts # 向量检索服务(pgvector) +│ ├── KeywordSearchService.ts # 关键词检索服务(pg_trgm) │ ├── HybridSearchService.ts # 混合检索服务(RRF 融合) -│ └── ClinicalExtractionService.ts # 临床要素提取(可选) +│ └── RerankService.ts # 🆕 重排序服务(Qwen-Rerank) +│ +├── workers/ +│ └── ingestWorker.ts # ⚡️ 异步入库 Worker(pg-boss) │ ├── types/ │ ├── index.ts # 类型定义 @@ -286,28 +372,33 @@ backend/src/common/rag/ export class KnowledgeBaseEngine { constructor(private prisma: PrismaClient) {} - // ==================== 文档入库 ==================== + // ==================== 文档入库(异步) ==================== /** - * 入库文档(完整流程:提取 → 切片 → 向量化 → 存储) + * ⚡️ 提交入库任务(立即返回 taskId) + * 详见:Postgres-Only异步任务处理指南 */ - async ingestDocument(params: { + async submitIngestTask(params: { kbId: string; userId: string; file: Buffer; filename: string; options?: { - extractClinicalData?: boolean; - generateSummary?: boolean; + enableSummary?: boolean; // 💰 默认 false + enableClinicalExtraction?: boolean; // 💰 默认 false chunkSize?: number; chunkOverlap?: number; }; - }): Promise; + }): Promise<{ taskId: string; documentId: string }>; /** - * 批量入库 + * 获取入库任务状态 */ - async ingestBatch(documents: IngestParams[]): Promise; + async getIngestStatus(taskId: string): Promise<{ + status: 'pending' | 'processing' | 'completed' | 'failed'; + progress: number; // 0-100 + error?: string; + }>; // ==================== 内容获取(全文) ==================== @@ -355,7 +446,7 @@ export class KnowledgeBaseEngine { ): Promise; /** - * 关键词检索(PostgreSQL FTS) + * 关键词检索(pg_bigm 中文精确匹配) */ async keywordSearch( kbIds: string[], @@ -377,6 +468,15 @@ export class KnowledgeBaseEngine { } ): Promise; + /** + * 🆕 重排序(Qwen-Rerank API) + */ + async rerank( + documents: SearchResult[], + query: string, + topK?: number + ): Promise; + // ==================== 管理操作 ==================== /** @@ -482,90 +582,15 @@ export class KnowledgeBaseEngine { ## 🗄️ 数据模型 -### Prisma Schema - -```prisma -// schema.prisma - -model EkbDocument { - id String @id @default(uuid()) - kbId String // 所属知识库 - userId String // 上传用户 - - // 基础信息 - filename String - fileType String - fileSizeBytes BigInt - fileUrl String // OSS 地址 - extractedText String? @db.Text // 提取的 Markdown 全文 - summary String? @db.Text // 文档摘要(200-500字) - - // 临床数据(JSONB,可选) - pico Json? // { P, I, C, O } - studyDesign Json? // { design, sampleSize, ... } - regimen Json? // [{ drug, dose, ... }] - safety Json? // { ae_all, ae_grade34 } - criteria Json? // { inclusion, exclusion } - endpoints Json? // { primary, secondary } - - // 状态 - status String @default("pending") // pending | processing | completed | failed - errorMessage String? @db.Text - - // 统计信息 - tokenCount Int? // 文档 token 数量 - - chunks EkbChunk[] - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([kbId]) - @@index([status]) - @@schema("ekb_schema") -} - -model EkbChunk { - id String @id @default(uuid()) - documentId String - - content String @db.Text // 切片文本 - pageNumber Int? // 来源页码 - sectionType String? // 章节类型 - - // pgvector 字段 - embedding Unsupported("vector(1024)")? - - document EkbDocument @relation(fields: [documentId], references: [id], onDelete: Cascade) - - createdAt DateTime @default(now()) - - @@index([documentId]) - @@schema("ekb_schema") -} -``` - -### 索引设计 - -```sql --- HNSW 向量索引(高性能近似最近邻) -CREATE INDEX IF NOT EXISTS ekb_chunk_embedding_idx -ON "ekb_schema"."EkbChunk" -USING hnsw (embedding vector_cosine_ops) -WITH (m = 16, ef_construction = 64); - --- 全文检索索引 -CREATE INDEX IF NOT EXISTS ekb_chunk_content_idx -ON "ekb_schema"."EkbChunk" -USING gin (to_tsvector('simple', content)); - --- JSONB GIN 索引(临床数据查询) -CREATE INDEX IF NOT EXISTS ekb_document_pico_idx -ON "ekb_schema"."EkbDocument" USING gin (pico); - -CREATE INDEX IF NOT EXISTS ekb_document_safety_idx -ON "ekb_schema"."EkbDocument" USING gin (safety); -``` +> 📌 **数据模型详见**:[04-数据模型设计.md](./04-数据模型设计.md) +> +> 本文档不再重复定义数据模型,请以上述文档为准。该文档包含: +> - 四层架构设计原则 +> - EkbDocument / EkbChunk 完整 Prisma Schema +> - 索引设计(HNSW、pg_bigm、GIN) +> - contentType 枚举定义 +> - metadata / structuredData JSONB 结构 +> - 各类型使用示例 --- @@ -600,7 +625,13 @@ ON "ekb_schema"."EkbDocument" USING gin (safety); ┌─────────────────────────────────────────────────────────────┐ │ 知识库引擎 (本模块) │ │ │ -│ 依赖: │ +│ 平台依赖: │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ ⚡️ 异步任务处理(pg-boss) │ │ +│ │ • 入库任务队列 │ │ +│ │ • 详见:Postgres-Only异步任务处理指南 │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ 文档处理引擎 │ │ │ │ • 调用 DocumentProcessor.toMarkdown() │ │ @@ -608,9 +639,9 @@ ON "ekb_schema"."EkbDocument" USING gin (safety); │ └─────────────────────────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────────┐ │ -│ │ LLM 网关 │ │ -│ │ • 调用 LLMFactory.getAdapter('deepseek-v3') │ │ -│ │ • 用于摘要生成、临床要素提取 │ │ +│ │ LLM 网关(💰 可选调用) │ │ +│ │ • 摘要生成(enableSummary: true) │ │ +│ │ • 临床要素提取(enableClinicalExtraction: true) │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────────┐ │ @@ -623,7 +654,7 @@ ON "ekb_schema"."EkbDocument" USING gin (safety); │ ┌─────────────────────────────────────────────────────┐ │ │ │ 阿里云 DashScope API │ │ │ │ • text-embedding-v3 (向量化) │ │ -│ │ • gte-rerank (重排序,可选) │ │ +│ │ • qwen-rerank (重排序) │ │ │ └─────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ ``` @@ -632,21 +663,29 @@ ON "ekb_schema"."EkbDocument" USING gin (safety); ## 📅 开发计划 -详见:[02-pgvector替换Dify计划.md](./02-pgvector替换Dify计划.md) +### 分阶段实施(推荐) -### 里程碑 +详见:[03-分阶段实施方案.md](./03-分阶段实施方案.md) | 阶段 | 内容 | 工期 | 状态 | |------|------|------|------| -| **M1** | 数据库设计 + 核心服务 | 5 天 | 🔜 待开始 | -| **M2** | PKB 模块接入 + 测试 | 3 天 | 📋 规划中 | -| **M3** | 数据迁移 + 上线 | 2 天 | 📋 规划中 | +| **Phase 1 MVP** | 入库 + 向量检索 + 全文获取 | 3 天 | 🔜 待开始 | +| **Phase 2 增强** | + 关键词检索 + 混合检索 + rerank | 2 天 | 📋 规划中 | +| **Phase 3 完整** | + 异步入库 + 摘要 + PICO | 3 天 | 📋 规划中 | + +**核心原则**:先跑通 MVP,让业务走起来,再逐步完善。 + +### 技术实现参考 + +详见:[02-pgvector替换Dify计划.md](./02-pgvector替换Dify计划.md) --- ## 📚 相关文档 -- [pgvector 替换 Dify 开发计划](./02-pgvector替换Dify计划.md) +- [分阶段实施方案](./03-分阶段实施方案.md) - 🆕 MVP → 增强 → 完整 +- [pgvector 替换 Dify 技术方案](./02-pgvector替换Dify计划.md) - 详细技术实现 +- [Postgres-Only异步任务处理指南](../Postgres-Only异步任务处理指南.md) - 异步架构参考 - [文档处理引擎设计方案](../02-文档处理引擎/01-文档处理引擎设计方案.md) - [LLM 网关](../01-LLM大模型网关/README.md) - [通用能力层总览](../README.md) @@ -655,6 +694,16 @@ ON "ekb_schema"."EkbDocument" USING gin (safety); ## 📅 更新日志 +### v1.2 (2026-01-20) + +**架构审核优化:** +- ⚡️ **入库异步化**:`ingestDocument()` → `submitIngestTask()` + `getIngestStatus()`,基于 pg-boss 队列 +- 💰 **成本控制**:摘要生成、临床要素提取默认关闭,按需开启 +- 🔧 **中文检索**:关键词检索从 `tsvector` 改为 `pg_bigm`,专为 CJK 文字优化 +- 🆕 **新增能力**:独立暴露 `rerank()` 重排序能力(Qwen-Rerank API) +- 📦 **代码结构**:新增 `workers/ingestWorker.ts` 和 `RerankService.ts` +- 📋 **分阶段实施**:新增 MVP → 增强 → 完整 三阶段方案 + ### v1.1 (2026-01-20) **设计原则重大更新:** diff --git a/docs/02-通用能力层/03-RAG引擎/02-pgvector替换Dify计划.md b/docs/02-通用能力层/03-RAG引擎/02-pgvector替换Dify计划.md index 1018b885..33bcfc15 100644 --- a/docs/02-通用能力层/03-RAG引擎/02-pgvector替换Dify计划.md +++ b/docs/02-通用能力层/03-RAG引擎/02-pgvector替换Dify计划.md @@ -175,81 +175,9 @@ Week 2: PKB 模块接入 + 测试 + 迁移 **关键代码**: -```prisma -// schema.prisma - 通用知识库表 - -model EkbDocument { - id String @id @default(uuid()) - kbId String // 知识库 ID - userId String // 上传用户 - - // 基础信息 - filename String - fileType String - fileSizeBytes BigInt - fileUrl String // 原始文件 OSS 地址 - extractedText String? @db.Text // 解析后的 Markdown - - // 临床数据(JSONB,可选) - pico Json? - studyDesign Json? - regimen Json? - safety Json? - criteria Json? - endpoints Json? - - // 状态 - status String @default("pending") // pending | processing | completed | failed - errorMessage String? @db.Text - - chunks EkbChunk[] - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([kbId]) - @@index([status]) - @@schema("ekb_schema") -} - -model EkbChunk { - id String @id @default(uuid()) - documentId String - - content String @db.Text - pageNumber Int? - sectionType String? - - // pgvector 字段 - embedding Unsupported("vector(1024)")? - - document EkbDocument @relation(fields: [documentId], references: [id], onDelete: Cascade) - - createdAt DateTime @default(now()) - - @@index([documentId]) - @@schema("ekb_schema") -} -``` - -**手动 SQL(创建索引)**: - -```sql --- 创建 HNSW 索引 -CREATE INDEX IF NOT EXISTS ekb_chunk_embedding_idx -ON "ekb_schema"."EkbChunk" -USING hnsw (embedding vector_cosine_ops) -WITH (m = 16, ef_construction = 64); - --- 创建全文检索索引 -CREATE INDEX IF NOT EXISTS ekb_chunk_content_idx -ON "ekb_schema"."EkbChunk" -USING gin (to_tsvector('simple', content)); - --- 创建 JSONB GIN 索引 -CREATE INDEX IF NOT EXISTS ekb_document_pico_idx -ON "ekb_schema"."EkbDocument" USING gin (pico); -``` +> 📌 **数据模型详见**:[04-数据模型设计.md](./04-数据模型设计.md) +> +> 包含完整的 EkbDocument / EkbChunk Prisma Schema 和索引设计。 --- diff --git a/docs/02-通用能力层/03-RAG引擎/03-分阶段实施方案.md b/docs/02-通用能力层/03-RAG引擎/03-分阶段实施方案.md new file mode 100644 index 00000000..d3abd3e5 --- /dev/null +++ b/docs/02-通用能力层/03-RAG引擎/03-分阶段实施方案.md @@ -0,0 +1,595 @@ +# 知识库引擎分阶段实施方案 + +> **文档版本:** v1.0 +> **创建日期:** 2026-01-20 +> **最后更新:** 2026-01-20 +> **核心原则:** 先跑通 MVP,让业务走起来,再逐步完善 + +--- + +## 📋 概述 + +### 为什么分阶段实施? + +完整的知识库引擎包含多个复杂功能,一次性全部实现风险高、周期长。采用分阶段实施: + +- ✅ **降低风险**:每阶段可交付、可验证 +- ✅ **快速见效**:MVP 3天即可让业务跑起来 +- ✅ **灵活调整**:根据业务反馈调整后续优先级 + +### 三阶段总览 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Phase 1: MVP(3天) │ +│ ───────────────── │ +│ 目标:让业务跑起来 │ +│ 能力:入库 + 向量检索 + 全文获取 │ +│ 场景:PKB 基础问答 │ +├─────────────────────────────────────────────────────────────┤ +│ Phase 2: 增强检索(2天) │ +│ ───────────────── │ +│ 目标:检索质量提升 │ +│ 能力:+ 关键词检索 + 混合检索 + rerank │ +│ 场景:PKB 高质量检索 │ +├─────────────────────────────────────────────────────────────┤ +│ Phase 3: 完整功能(3天) │ +│ ───────────────── │ +│ 目标:完整架构落地 │ +│ 能力:+ 异步入库 + 摘要生成 + 临床要素提取 │ +│ 场景:ASL、AIA 完整功能 │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 🚀 Phase 1: MVP(3天) + +### 目标 + +**最小可用版本**:PKB 能上传文档、能检索、能问答 + +### 交付能力 + +| 能力 | 方法 | 说明 | +|------|------|------| +| **文档入库** | `ingestDocument()` | 同步处理(简化版) | +| **向量检索** | `vectorSearch()` | pgvector 语义检索 | +| **全文获取** | `getDocumentFullText()` | 获取单个文档 | +| | `getAllDocumentsText()` | 获取知识库所有文档 | +| **管理操作** | `deleteDocument()` | 删除文档 | + +### 暂不实现 + +- ❌ 异步入库(pg-boss) +- ❌ 关键词检索(pg_trgm) +- ❌ 混合检索(RRF) +- ❌ rerank 重排序 +- ❌ 摘要生成 +- ❌ 临床要素提取(PICO) + +### 数据模型 + +> 📌 **数据模型详见**:[04-数据模型设计.md](./04-数据模型设计.md) +> +> ⚠️ **重要**:MVP 阶段就创建完整 Schema,但只使用部分字段。避免后续阶段改表迁移。 + +### 字段使用阶段说明 + +| 字段分层 | Phase 1 MVP | Phase 2 增强 | Phase 3 完整 | +|----------|-------------|--------------|--------------| +| **Layer 0-1: 基础信息** | | | | +| filename, fileType, fileSizeBytes | ✅ 填充 | - | - | +| fileUrl, extractedText | ✅ 填充 | - | - | +| status, errorMessage | ✅ 使用 | - | - | +| **Layer 2: 内容增强** | | | | +| summary, tokenCount, pageCount | ❌ 预留 | ❌ 预留 | ✅ 填充 | +| **Layer 3: 分类标签** | | | | +| contentType, tags, category | ⚪ 可选 | ⚪ 可选 | ✅ 启用 | +| **Layer 4: 结构化数据** | | | | +| metadata, structuredData | ❌ 预留 | ❌ 预留 | ✅ 填充 | +| **EkbChunk** | | | | +| content, chunkIndex | ✅ 填充 | - | - | +| pageNumber, sectionType | ⚪ 可选 | - | - | +| embedding | ✅ 填充 | - | - | + +### 核心代码 + +```typescript +// Phase 1: MVP 版本 KnowledgeBaseEngine +export class KnowledgeBaseEngine { + constructor(private prisma: PrismaClient) {} + + /** + * 同步入库(MVP 简化版,小文件场景) + * Phase 3 将升级为异步 + */ + async ingestDocument(params: { + kbId: string; + userId: string; + file: Buffer; + filename: string; + }): Promise<{ documentId: string }> { + // 1. 解析文档 → Markdown + const markdown = await documentProcessor.toMarkdown(params.file, params.filename); + + // 2. 切片 + const chunks = chunkService.split(markdown, { size: 512, overlap: 50 }); + + // 3. 向量化 + const embeddings = await embeddingService.embedBatch(chunks.map(c => c.text)); + + // 4. 上传原始文件到 OSS + const fileUrl = await storage.upload(params.file, params.filename); + + // 5. 存储文档(使用完整 Schema,MVP 只填充部分字段) + const document = await this.prisma.ekbDocument.create({ + data: { + kbId: params.kbId, + userId: params.userId, + filename: params.filename, + fileType: getFileType(params.filename), + fileSizeBytes: BigInt(params.file.length), + fileUrl: fileUrl, + extractedText: markdown, + status: 'completed', + // Phase 3 才填充的字段保持 null: + // summary, tokenCount, pico, studyDesign, regimen, safety, criteria, endpoints + } + }); + + // 6. 存储切片 + 向量(使用完整 Schema) + for (let i = 0; i < chunks.length; i++) { + await this.prisma.$executeRaw` + INSERT INTO "ekb_schema"."EkbChunk" + (id, document_id, content, chunk_index, page_number, section_type, embedding, created_at) + VALUES ( + ${crypto.randomUUID()}, + ${document.id}, + ${chunks[i].text}, + ${i}, + ${chunks[i].pageNumber || null}, + ${chunks[i].sectionType || null}, + ${embeddings[i]}::vector, + NOW() + ) + `; + } + + return { documentId: document.id }; + } + + /** + * 向量检索 + */ + async vectorSearch( + kbIds: string[], + query: string, + topK: number = 10 + ): Promise { + const queryVector = await embeddingService.embed(query); + + const results = await this.prisma.$queryRaw` + SELECT + c.id, + c.content, + c.document_id, + d.filename, + 1 - (c.embedding <=> ${queryVector}::vector) as score + FROM "ekb_schema"."EkbChunk" c + JOIN "ekb_schema"."EkbDocument" d ON c.document_id = d.id + WHERE d.kb_id = ANY(${kbIds}::text[]) + ORDER BY c.embedding <=> ${queryVector}::vector + LIMIT ${topK} + `; + + return results; + } + + /** + * 获取单个文档全文 + */ + async getDocumentFullText(documentId: string): Promise { + const doc = await this.prisma.ekbDocument.findUnique({ + where: { id: documentId }, + select: { id: true, filename: true, extractedText: true } + }); + + if (!doc) throw new Error('Document not found'); + + return { + id: doc.id, + filename: doc.filename, + text: doc.extractedText || '', + }; + } + + /** + * 获取知识库所有文档全文 + */ + async getAllDocumentsText(kbId: string): Promise { + const docs = await this.prisma.ekbDocument.findMany({ + where: { kbId, status: 'completed' }, + select: { id: true, filename: true, extractedText: true } + }); + + return docs.map(doc => ({ + id: doc.id, + filename: doc.filename, + text: doc.extractedText || '', + })); + } + + /** + * 删除文档 + */ + async deleteDocument(documentId: string): Promise { + await this.prisma.ekbDocument.delete({ + where: { id: documentId } + }); + } +} +``` + +### 索引设计(完整版,一次性创建) + +```sql +-- ===== MVP 阶段必须创建 ===== + +-- 1. HNSW 向量索引(语义检索核心) +CREATE INDEX IF NOT EXISTS ekb_chunk_embedding_idx +ON "ekb_schema"."EkbChunk" +USING hnsw (embedding vector_cosine_ops) +WITH (m = 16, ef_construction = 64); + +-- ===== Phase 2 阶段使用(MVP 可预创建)===== + +-- 2. pg_bigm 中文关键词索引 +CREATE EXTENSION IF NOT EXISTS pg_bigm; +CREATE INDEX IF NOT EXISTS ekb_chunk_content_bigm_idx +ON "ekb_schema"."EkbChunk" +USING gin (content gin_bigm_ops); + +-- ===== Phase 3 阶段使用(MVP 可预创建)===== + +-- 3. JSONB GIN 索引(临床数据查询) +CREATE INDEX IF NOT EXISTS ekb_document_pico_idx +ON "ekb_schema"."EkbDocument" USING gin (pico); + +CREATE INDEX IF NOT EXISTS ekb_document_safety_idx +ON "ekb_schema"."EkbDocument" USING gin (safety); + +CREATE INDEX IF NOT EXISTS ekb_document_studydesign_idx +ON "ekb_schema"."EkbDocument" USING gin ("studyDesign"); +``` + +> 💡 **建议**:MVP 阶段一次性创建所有索引,避免后续 DDL 操作。空表创建索引几乎无成本。 + +### 任务清单 + +| 任务 | 预估 | 产出 | +|------|------|------| +| **Schema 迁移(完整版)** | 3h | `ekb_schema` + 完整表结构 + 全部索引 | +| EmbeddingService | 3h | 阿里云 API 封装 | +| ChunkService | 2h | 文本切片 | +| KnowledgeBaseEngine MVP | 4h | 核心类(只实现 MVP 方法) | +| 单元测试 | 3h | 基础测试用例 | +| PKB 集成 | 4h | 替换 Dify 调用 | +| **合计** | **19h (3天)** | | + +> 💡 **关键点**:Schema 迁移一次到位,后续阶段只写代码,不改表。 + +### 验收标准 + +- [ ] PKB 可上传 PDF 文档 +- [ ] PKB 可向量检索 +- [ ] PKB 可获取文档全文 +- [ ] PKB 可删除文档 +- [ ] 基础问答功能正常 + +--- + +## 🔍 Phase 2: 增强检索(2天) + +### 目标 + +**检索质量提升**:支持中文关键词、混合检索、结果重排序 + +### 新增能力 + +| 能力 | 方法 | 说明 | +|------|------|------| +| **关键词检索** | `keywordSearch()` | pg_bigm 中文精确匹配 | +| **混合检索** | `hybridSearch()` | 向量 + 关键词 + RRF | +| **重排序** | `rerank()` | Qwen-Rerank API | + +### 新增索引 + +```sql +-- Phase 2: 关键词检索索引(pg_bigm,专为中文优化) +CREATE EXTENSION IF NOT EXISTS pg_bigm; + +CREATE INDEX IF NOT EXISTS ekb_chunk_content_bigm_idx +ON "ekb_schema"."EkbChunk" +USING gin (content gin_bigm_ops); +``` + +### 新增代码 + +```typescript +// Phase 2 新增方法 + +/** + * 关键词检索(pg_bigm 中文精确匹配) + */ +async keywordSearch( + kbIds: string[], + query: string, + topK: number = 10 +): Promise { + const results = await this.prisma.$queryRaw` + SELECT + c.id, + c.content, + c.document_id, + d.filename, + bigm_similarity(c.content, ${query}) as score + FROM "ekb_schema"."EkbChunk" c + JOIN "ekb_schema"."EkbDocument" d ON c.document_id = d.id + WHERE d.kb_id = ANY(${kbIds}::text[]) + AND c.content LIKE ${'%' + query + '%'} + ORDER BY bigm_similarity(c.content, ${query}) DESC + LIMIT ${topK} + `; + + return results; +} + +/** + * 混合检索(向量 + 关键词 + RRF 融合) + */ +async hybridSearch( + kbIds: string[], + query: string, + topK: number = 10 +): Promise { + // 并发执行两路检索 + const [vectorResults, keywordResults] = await Promise.all([ + this.vectorSearch(kbIds, query, 20), + this.keywordSearch(kbIds, query, 20), + ]); + + // RRF 融合 + return rrfFusion(vectorResults, keywordResults, topK); +} + +/** + * 重排序(Qwen-Rerank API) + */ +async rerank( + documents: SearchResult[], + query: string, + topK: number = 10 +): Promise { + const response = await dashscope.rerank({ + model: 'gte-rerank', + query, + documents: documents.map(d => d.content), + top_n: topK, + }); + + return response.results.map(r => ({ + ...documents[r.index], + score: r.relevance_score, + })); +} +``` + +### RRF 融合算法 + +```typescript +// utils/rrfFusion.ts +export function rrfFusion( + vectorResults: SearchResult[], + keywordResults: SearchResult[], + topK: number, + k: number = 60 +): SearchResult[] { + const scores = new Map(); + + // 向量检索得分 + vectorResults.forEach((r, rank) => { + const score = scores.get(r.id) || 0; + scores.set(r.id, score + 1 / (k + rank + 1)); + }); + + // 关键词检索得分 + keywordResults.forEach((r, rank) => { + const score = scores.get(r.id) || 0; + scores.set(r.id, score + 1 / (k + rank + 1)); + }); + + // 合并去重 + 排序 + const allResults = [...vectorResults, ...keywordResults]; + const uniqueResults = Array.from( + new Map(allResults.map(r => [r.id, r])).values() + ); + + return uniqueResults + .map(r => ({ ...r, score: scores.get(r.id) || 0 })) + .sort((a, b) => b.score - a.score) + .slice(0, topK); +} +``` + +### 任务清单 + +| 任务 | 预估 | 产出 | +|------|------|------| +| pg_bigm 索引 | 1h | SQL 迁移 | +| KeywordSearchService | 2h | 关键词检索(pg_bigm) | +| RRF 融合算法 | 2h | rrfFusion.ts | +| RerankService | 2h | 阿里云 API 封装 | +| hybridSearch 集成 | 2h | 混合检索 | +| 测试 | 3h | 检索质量验证 | +| **合计** | **12h (2天)** | | + +### 验收标准 + +- [ ] 关键词检索支持中文("帕博利珠" 可匹配 "帕博利珠单抗") +- [ ] 混合检索可用 +- [ ] rerank 可用 +- [ ] 检索召回率提升(对比 Phase 1) + +--- + +## 🎯 Phase 3: 完整功能(3天) + +### 目标 + +**完整架构落地**:支持大文件、高级提取功能 + +### 新增能力 + +| 能力 | 方法 | 说明 | +|------|------|------| +| **异步入库** | `submitIngestTask()` | pg-boss 队列 | +| | `getIngestStatus()` | 任务状态查询 | +| **摘要获取** | `getDocumentSummary()` | LLM 生成摘要 | +| | `getAllDocumentsSummaries()` | 批量获取摘要 | +| **临床数据** | `getClinicalData()` | PICO 等结构化数据 | + +### Schema 说明 + +> ✅ **无需 Schema 升级**:MVP 阶段已创建完整表结构,Phase 3 只需填充字段。 + +```typescript +// Phase 3: 开始填充 summary、tokenCount、临床数据字段 +await prisma.ekbDocument.update({ + where: { id: documentId }, + data: { + summary: generatedSummary, // 🆕 Phase 3 填充 + tokenCount: calculatedTokens, // 🆕 Phase 3 填充 + pico: extractedPico, // 🆕 Phase 3 填充 + studyDesign: extractedStudyDesign, // 🆕 Phase 3 填充 + regimen: extractedRegimen, // 🆕 Phase 3 填充 + safety: extractedSafety, // 🆕 Phase 3 填充 + } +}); +``` + +### 异步入库实现 + +详见:[Postgres-Only异步任务处理指南](../Postgres-Only异步任务处理指南.md) + +```typescript +// Phase 3: 异步入库 + +/** + * 提交入库任务 + */ +async submitIngestTask(params: { + kbId: string; + userId: string; + file: Buffer; + filename: string; + options?: { + enableSummary?: boolean; // 💰 默认 false + enableClinicalExtraction?: boolean; // 💰 默认 false + }; +}): Promise<{ taskId: string; documentId: string }> { + // 1. 快速上传到 OSS + const fileUrl = await storage.upload(params.file); + + // 2. 创建文档记录(status: processing) + const document = await this.prisma.ekbDocument.create({ + data: { + kbId: params.kbId, + userId: params.userId, + filename: params.filename, + fileUrl, + status: 'processing', + } + }); + + // 3. 推送任务到 pg-boss + const taskId = await jobQueue.send('ekb-ingest', { + documentId: document.id, + fileUrl, + options: params.options, + }); + + return { taskId, documentId: document.id }; +} + +/** + * 获取任务状态 + */ +async getIngestStatus(taskId: string): Promise<{ + status: 'pending' | 'processing' | 'completed' | 'failed'; + progress: number; + error?: string; +}> { + const job = await jobQueue.getJobById(taskId); + + return { + status: job.state, + progress: job.data?.progress || 0, + error: job.state === 'failed' ? job.output?.error : undefined, + }; +} +``` + +### 任务清单 + +| 任务 | 预估 | 产出 | +|------|------|------| +| pg-boss Worker | 4h | ingestWorker.ts(异步入库) | +| SummaryService | 3h | LLM 摘要生成 | +| ClinicalExtractionService | 4h | PICO 提取 + JSON 容错 | +| 摘要/临床数据 API | 2h | 新增 getDocumentSummary 等方法 | +| 测试 | 4h | 完整流程验证 | +| **合计** | **17h (3天)** | | + +> ✅ **优势**:无需 Schema 迁移,只写业务代码。 + +### 验收标准 + +- [ ] 大文件上传不超时 +- [ ] 任务状态可查询 +- [ ] 摘要生成可用 +- [ ] PICO 提取可用(ASL 场景) +- [ ] AIA 摘要筛选策略可用 + +--- + +## 📊 总体进度 + +| 阶段 | 工期 | 累计 | 状态 | +|------|------|------|------| +| **Phase 1 MVP** | 3天 | 3天 | 🔜 待开始 | +| **Phase 2 增强** | 2天 | 5天 | 📋 规划中 | +| **Phase 3 完整** | 3天 | 8天 | 📋 规划中 | + +--- + +## 🔗 相关文档 + +- [知识库引擎架构设计](./01-知识库引擎架构设计.md) - 完整架构目标 +- [pgvector 替换 Dify 技术方案](./02-pgvector替换Dify计划.md) - 详细技术实现 +- [Postgres-Only异步任务处理指南](../Postgres-Only异步任务处理指南.md) - 异步架构参考 + +--- + +## 📅 更新日志 + +### v1.0 (2026-01-20) + +- 初始版本 +- 确定三阶段实施方案:MVP → 增强 → 完整 + +--- + +**维护人:** 技术架构师 +**最后更新:** 2026-01-20 + diff --git a/docs/02-通用能力层/03-RAG引擎/04-数据模型设计.md b/docs/02-通用能力层/03-RAG引擎/04-数据模型设计.md new file mode 100644 index 00000000..71a4407a --- /dev/null +++ b/docs/02-通用能力层/03-RAG引擎/04-数据模型设计.md @@ -0,0 +1,944 @@ +# 知识库引擎数据模型设计 + +> **文档版本**: v2.0 +> **最后更新**: 2026-01-21 +> **状态**: ✅ 权威文档 +> **说明**: 本文档是知识库引擎数据模型的唯一权威来源,其他文档应引用本文档,避免重复定义。 + +--- + +## 1. 设计原则 + +### 1.1 核心理念 + +> **即使没有任何结构化数据,RAG 检索也必须能工作!** +> +> 结构化数据是"锦上添花",不是"必须有"。 + +### 1.2 四层架构 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Layer 0: RAG 核心层(必须有,检索基础) │ +│ ───────────────────────────────────── │ +│ extractedText → 全文 Markdown │ +│ chunks[] → 文本切片 │ +│ embeddings[] → 向量嵌入 │ +│ │ +│ ✅ 只要有这一层,RAG 就能工作! │ +├─────────────────────────────────────────────────────────────┤ +│ Layer 1: 基础信息层(必须有,系统需要) │ +│ ───────────────────────────────────── │ +│ filename, fileType, fileSizeBytes, fileUrl │ +│ status, errorMessage, createdAt, updatedAt │ +│ │ +│ ✅ 文件管理必需 │ +├─────────────────────────────────────────────────────────────┤ +│ Layer 2: 内容增强层(可选,提升体验) │ +│ ───────────────────────────────────── │ +│ summary → AI 生成摘要(快速预览) │ +│ tokenCount → Token 数量(成本估算) │ +│ pageCount → 页数 │ +│ │ +│ ✅ 有则更好,无也能用 │ +├─────────────────────────────────────────────────────────────┤ +│ Layer 3: 分类标签层(可选,用户自定义) │ +│ ───────────────────────────────────── │ +│ contentType → 内容类型(可 AI 自动识别) │ +│ tags[] → 用户标签(用户自己打) │ +│ category → 分类目录(用户自己选) │ +│ │ +│ ✅ 用户可以不管,系统也能运行 │ +├─────────────────────────────────────────────────────────────┤ +│ Layer 4: 结构化数据层(可选,精准查询) │ +│ ───────────────────────────────────── │ +│ metadata → 文献属性(pmid, doi, journal...) │ +│ structuredData → 类型特定数据(pico, diagnosis...) │ +│ │ +│ ✅ 高级功能,专业用户/药企场景使用 │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 1.3 设计目标 + +| 目标 | 实现方式 | +|------|----------| +| **最小可用** | Layer 0-1 必须有,RAG 就能工作 | +| **渐进增强** | Layer 2-4 可选,有则更强 | +| **用户友好** | 什么都不填也能用 | +| **专业支持** | 专业用户可以填完整信息 | +| **AI 辅助** | contentType、summary 可 AI 自动生成 | +| **灵活扩展** | JSONB 支持任意结构化数据 | + +### 1.4 ER 关系图 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ EkbKnowledgeBase │ +│ ───────────────────────────────────────────── │ +│ id, name, description │ +│ type (USER | SYSTEM) │ +│ ownerId (userId 或 moduleId) │ +│ config (JSONB: chunkSize, topK, enableRerank...) │ +├─────────────────────────────────────────────────────────────────┤ + │ 1:N + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ EkbDocument │ +│ ───────────────────────────────────────────── │ +│ id, kbId(FK), userId │ +│ filename, fileType, fileSizeBytes, fileUrl, fileHash │ +│ extractedText, summary, tokenCount, pageCount │ +│ contentType, tags[], category │ +│ metadata(JSONB), structuredData(JSONB) │ +├─────────────────────────────────────────────────────────────────┤ + │ 1:N + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ EkbChunk │ +│ ───────────────────────────────────────────── │ +│ id, documentId(FK) │ +│ content, chunkIndex │ +│ embedding (vector 1024) │ +│ pageNumber, sectionType │ +│ metadata(JSONB) ← 新增:切片级元数据 │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. EkbKnowledgeBase 模型(容器表) + +### 2.1 设计目的 + +| 痛点 | 解决方案 | +|------|----------| +| 无法区分用户私有库 vs 系统公共库 | `type` 字段:USER / SYSTEM | +| 无法管理"谁拥有这个库" | `ownerId` 字段:userId 或 moduleId | +| 无法为不同库配置不同 RAG 策略 | `config` JSONB:chunkSize, topK 等 | +| 用户配额管理(最多 3 个库) | 通过 `type=USER` 计数 | + +### 2.2 Prisma Schema + +```prisma +model EkbKnowledgeBase { + id String @id @default(uuid()) + name String // 知识库名称 + description String? // 描述 + + // ===== 核心隔离字段 ===== + type String @default("USER") // USER: 用户私有, SYSTEM: 系统公共 + ownerId String // USER: userId, SYSTEM: moduleId (如 "ASL", "AIA") + + // ===== 策略配置 (JSONB) ===== + config Json? // { chunkSize, topK, enableRerank, embeddingModel } + + // ===== 关联 ===== + documents EkbDocument[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // ===== 索引 ===== + @@index([ownerId]) + @@index([type]) + @@schema("ekb_schema") +} +``` + +### 2.3 字段说明 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `id` | String | ✅ | UUID 主键 | +| `name` | String | ✅ | 知识库名称(如"我的文献库"、"ASL 系统文献库") | +| `description` | String | ❌ | 知识库描述 | +| `type` | String | ✅ | 类型:`USER`(用户私有)或 `SYSTEM`(系统公共) | +| `ownerId` | String | ✅ | 所有者 ID:用户库填 userId,系统库填模块 ID | +| `config` | Json | ❌ | 策略配置 JSONB | + +### 2.4 type 枚举 + +| 值 | 说明 | ownerId 含义 | +|------|------|-------------| +| `USER` | 用户私有知识库 | userId(创建者) | +| `SYSTEM` | 系统公共知识库 | moduleId(如 "ASL", "AIA", "IIT") | + +### 2.5 config 结构 + +```typescript +interface KnowledgeBaseConfig { + // RAG 策略 + chunkSize?: number; // 切片大小,默认 512 + chunkOverlap?: number; // 切片重叠,默认 50 + topK?: number; // 检索数量,默认 5 + + // 高级功能 + enableRerank?: boolean; // 启用重排序 + enableSummary?: boolean; // 自动生成摘要 + enableClinicalExtraction?: boolean; // 临床数据提取 + + // 模型配置 + embeddingModel?: string; // 嵌入模型版本 +} +``` + +### 2.6 使用示例 + +```typescript +// 用户创建个人知识库 +await prisma.ekbKnowledgeBase.create({ + data: { + name: '我的肺癌文献库', + type: 'USER', + ownerId: 'user-123', + config: { chunkSize: 512, topK: 5 } + } +}); + +// 系统创建 ASL 模块知识库 +await prisma.ekbKnowledgeBase.create({ + data: { + name: 'ASL 系统文献库', + type: 'SYSTEM', + ownerId: 'ASL', + config: { enableClinicalExtraction: true } + } +}); + +// 查询用户所有知识库(配额检查) +const userKbs = await prisma.ekbKnowledgeBase.count({ + where: { type: 'USER', ownerId: 'user-123' } +}); +if (userKbs >= 3) throw new Error('知识库配额已满'); +``` + +--- + +## 3. EkbDocument 模型 + +### 3.1 Prisma Schema + +```prisma +model EkbDocument { + id String @id @default(uuid()) + kbId String // 所属知识库 + userId String // 上传者(冗余存储,方便快速查询) + + // ===== Layer 1: 基础信息(必须)===== + filename String // 文件名 + fileType String // pdf, docx, pptx, xlsx, md, txt + fileSizeBytes BigInt // 文件大小(字节) + fileUrl String // OSS 存储路径 + fileHash String? // 文件 SHA256 哈希(用于秒传和去重) + status String @default("pending") // 处理状态 + errorMessage String? @db.Text // 错误信息 + + // ===== Layer 0: RAG 核心(必须)===== + extractedText String? @db.Text // Markdown 全文 + + // ===== Layer 2: 内容增强(可选)===== + summary String? @db.Text // AI 摘要 + tokenCount Int? // Token 数量 + pageCount Int? // 页数 + + // ===== Layer 3: 分类标签(可选)===== + contentType String? // 内容类型 + tags String[] // 用户标签 + category String? // 分类目录 + + // ===== Layer 4: 结构化数据(可选)===== + metadata Json? // 文献属性 JSONB + structuredData Json? // 类型特定数据 JSONB + + // ===== 关联 ===== + knowledgeBase EkbKnowledgeBase @relation(fields: [kbId], references: [id], onDelete: Cascade) + chunks EkbChunk[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // ===== 索引 ===== + @@index([kbId]) + @@index([userId]) + @@index([status]) + @@index([contentType]) + @@index([fileHash]) // 支持秒传查询 + @@index([tags], type: Gin) + @@index([metadata], type: Gin) + @@index([structuredData], type: Gin) + @@schema("ekb_schema") +} +``` + +### 3.2 字段说明 + +#### Layer 1: 基础信息(必须) + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `id` | String | ✅ | UUID 主键 | +| `kbId` | String | ✅ | 所属知识库 ID(外键) | +| `userId` | String | ✅ | 上传者用户 ID(冗余存储,便于快速查询) | +| `filename` | String | ✅ | 原始文件名 | +| `fileType` | String | ✅ | 文件类型:pdf, docx, pptx, xlsx, md, txt, csv | +| `fileSizeBytes` | BigInt | ✅ | 文件大小(字节) | +| `fileUrl` | String | ✅ | OSS 存储路径 | +| `fileHash` | String | ❌ | 文件 SHA256 哈希(用于秒传和去重) | +| `status` | String | ✅ | 处理状态:pending, processing, completed, failed | +| `errorMessage` | String | ❌ | 处理失败时的错误信息 | + +#### Layer 0: RAG 核心(必须) + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `extractedText` | String | ❌* | Markdown 格式全文(处理完成后必须有) | + +> *注:上传时为空,处理完成后必须有值 + +#### Layer 2: 内容增强(可选) + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `summary` | String | ❌ | AI 生成的摘要 | +| `tokenCount` | Int | ❌ | 全文 Token 数量(用于成本估算) | +| `pageCount` | Int | ❌ | 文档页数 | + +#### Layer 3: 分类标签(可选) + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `contentType` | String | ❌ | 内容类型标识(见第 5 节) | +| `tags` | String[] | ❌ | 用户自定义标签 | +| `category` | String | ❌ | 分类目录路径,如 "肿瘤科/肺癌" | + +#### Layer 4: 结构化数据(可选) + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `metadata` | Json | ❌ | 文献属性(见第 7 节) | +| `structuredData` | Json | ❌ | 类型特定数据(见第 8 节) | + +### 3.3 status 状态枚举 + +| 状态 | 说明 | +|------|------| +| `pending` | 等待处理 | +| `processing` | 正在处理(解析、向量化) | +| `completed` | 处理完成 | +| `failed` | 处理失败 | + +### 3.4 fileHash 使用示例 + +```typescript +// 计算文件哈希 +import crypto from 'crypto'; + +function calculateFileHash(buffer: Buffer): string { + return crypto.createHash('sha256').update(buffer).digest('hex'); +} + +// 上传时检查是否已存在(秒传) +async function uploadWithDedup(kbId: string, file: Buffer, filename: string) { + const fileHash = calculateFileHash(file); + + // 检查同一知识库内是否已存在相同文件 + const existing = await prisma.ekbDocument.findFirst({ + where: { kbId, fileHash } + }); + + if (existing) { + // 秒传:直接返回已存在的文档 + return { type: 'duplicate', document: existing }; + } + + // 正常上传流程 + const document = await prisma.ekbDocument.create({ + data: { kbId, fileHash, filename, /* ... */ } + }); + return { type: 'new', document }; +} +``` + +--- + +## 4. EkbChunk 模型 + +### 4.1 Prisma Schema + +```prisma +model EkbChunk { + id String @id @default(uuid()) + documentId String // 所属文档 + + // ===== 核心内容 ===== + content String @db.Text // 切片文本(Markdown) + chunkIndex Int // 切片序号(从 0 开始) + + // ===== 向量 ===== + embedding Unsupported("vector(1024)")? // 向量嵌入 + + // ===== 溯源信息(可选)===== + pageNumber Int? // 页码(用于 PDF 溯源) + sectionType String? // 章节类型 + + // ===== 扩展元数据(可选)===== + metadata Json? // 切片级元数据 JSONB + + document EkbDocument @relation(fields: [documentId], references: [id], onDelete: Cascade) + + @@index([documentId]) + @@index([metadata], type: Gin) // JSONB 索引 + @@schema("ekb_schema") +} +``` + +### 4.2 字段说明 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `id` | String | ✅ | UUID 主键 | +| `documentId` | String | ✅ | 所属文档 ID | +| `content` | String | ✅ | 切片文本(Markdown 格式) | +| `chunkIndex` | Int | ✅ | 切片序号,从 0 开始 | +| `embedding` | vector(1024) | ❌* | 向量嵌入(向量化后必须有) | +| `pageNumber` | Int | ❌ | 来源页码(PDF 溯源) | +| `sectionType` | String | ❌ | 章节类型标识 | +| `metadata` | Json | ❌ | 切片级扩展元数据(见 4.4) | + +### 4.3 sectionType 枚举(可选) + +| 值 | 说明 | +|------|------| +| `title` | 标题 | +| `abstract` | 摘要 | +| `introduction` | 引言 | +| `methods` | 方法 | +| `results` | 结果 | +| `discussion` | 讨论 | +| `conclusion` | 结论 | +| `references` | 参考文献 | +| `table` | 表格 | +| `figure` | 图片说明 | + +### 4.4 metadata 结构(切片级元数据) + +```typescript +interface ChunkMetadata { + // 考试题场景 + isAnswer?: boolean; // 是否为答案切片 + questionId?: number; // 所属题目 ID + chunkType?: 'question' | 'options' | 'answer' | 'explanation'; + + // 病历场景 + section?: string; // 病历段落:主诉、现病史、检查结果等 + + // 通用 + importance?: number; // 重要性权重 0-1 + keywords?: string[]; // 关键词(用于加权检索) +} +``` + +**使用示例**: + +```typescript +// 考试题切片 - 题目 +{ + "content": "1. 关于帕博利珠单抗,下列说法正确的是?", + "metadata": { "chunkType": "question", "questionId": 1 } +} + +// 考试题切片 - 选项 +{ + "content": "A. 派姆单抗 B. 纳武利尤单抗 C. 阿特珠单抗 D. 度伐利尤单抗", + "metadata": { "chunkType": "options", "questionId": 1 } +} + +// 考试题切片 - 答案 +{ + "content": "正确答案:A。派姆单抗是帕博利珠单抗的通用名...", + "metadata": { "chunkType": "answer", "questionId": 1, "isAnswer": true } +} + +// 检索时降低纯答案切片的权重 +const results = await vectorSearch(query); +const reranked = results.map(r => ({ + ...r, + score: r.metadata?.isAnswer ? r.score * 0.5 : r.score +})); +``` + +--- + +## 5. 索引设计 + +### 5.1 向量索引(HNSW) + +```sql +-- 创建 HNSW 索引(高性能近似最近邻) +CREATE INDEX idx_ekb_chunk_embedding ON ekb_schema.ekb_chunk +USING hnsw (embedding vector_cosine_ops) +WITH (m = 16, ef_construction = 64); +``` + +**参数说明**: +- `m = 16`: 每层最大连接数 +- `ef_construction = 64`: 构建时搜索范围 + +### 5.2 关键词索引(pg_bigm) + +```sql +-- 安装 pg_bigm 扩展 +CREATE EXTENSION IF NOT EXISTS pg_bigm; + +-- 全文内容索引 +CREATE INDEX idx_ekb_chunk_content_bigm ON ekb_schema.ekb_chunk +USING gin (content gin_bigm_ops); + +-- 文档摘要索引 +CREATE INDEX idx_ekb_document_summary_bigm ON ekb_schema.ekb_document +USING gin (summary gin_bigm_ops); + +-- 提取文本索引 +CREATE INDEX idx_ekb_document_text_bigm ON ekb_schema.ekb_document +USING gin (extracted_text gin_bigm_ops); +``` + +**关键词查询示例**: + +```sql +-- 关键词搜索(支持中英文) +SELECT * FROM ekb_schema.ekb_chunk +WHERE content LIKE '%Pembrolizumab%' +ORDER BY likequery(content, 'Pembrolizumab') DESC; + +-- 中文搜索 +SELECT * FROM ekb_schema.ekb_chunk +WHERE content LIKE '%非小细胞肺癌%'; +``` + +### 5.3 JSONB 索引(GIN) + +```sql +-- 文献属性索引 +CREATE INDEX idx_ekb_document_metadata ON ekb_schema.ekb_document +USING gin (metadata jsonb_path_ops); + +-- 结构化数据索引 +CREATE INDEX idx_ekb_document_structured ON ekb_schema.ekb_document +USING gin (structured_data jsonb_path_ops); + +-- 标签数组索引 +CREATE INDEX idx_ekb_document_tags ON ekb_schema.ekb_document +USING gin (tags); +``` + +**JSONB 查询示例**: + +```sql +-- 按影响因子筛选 +SELECT * FROM ekb_schema.ekb_document +WHERE (metadata->>'ifScore')::float > 10; + +-- 按 PICO 干预措施筛选 +SELECT * FROM ekb_schema.ekb_document +WHERE structured_data->'pico'->>'I' ILIKE '%Pembrolizumab%'; + +-- 按标签筛选 +SELECT * FROM ekb_schema.ekb_document +WHERE tags @> ARRAY['肺癌', '免疫治疗']; +``` + +--- + +## 6. contentType 枚举 + +| 值 | 说明 | 适用场景 | +|------|------|----------| +| `general` | 通用文档(默认) | 任意文档 | +| `literature` | 医学文献 | 论文、研究报告 | +| `case` | 典型病历 | MDT 病例、教学病例 | +| `exam` | 教学考试 | 试题、模拟题 | +| `drug` | 药品资料 | 说明书、处方信息 | +| `guideline` | 临床指南 | NCCN、CSCO 指南 | +| `note` | 个人笔记 | 学习笔记、会议记录 | +| `protocol` | 研究方案 | 临床试验方案 | +| `report` | 工作报告 | 汇报材料、分析报告 | + +--- + +## 7. metadata 结构(文献属性) + +### 7.1 JSON Schema + +```typescript +interface Metadata { + // 基础信息 + title?: string; // 文献标题 + abstract?: string; // 原始摘要 + + // 标识符 + pmid?: string; // PubMed ID + doi?: string; // DOI + nctId?: string; // ClinicalTrials.gov ID + + // 来源信息 + journal?: string; // 期刊名称 + publisher?: string; // 出版商 + authors?: string[]; // 作者列表 + + // 时间与评分 + publishYear?: number; // 发表年份 + publishDate?: string; // 发表日期 (YYYY-MM-DD) + ifScore?: number; // 影响因子 + + // 分类 + docType?: string; // 文献类型:RCT, Meta, Review, Case, Guideline + keywords?: string[]; // 关键词 + meshTerms?: string[]; // MeSH 词表 + + // 语言 + language?: string; // 语言:en, zh, ... +} +``` + +### 7.2 示例 + +```json +{ + "title": "Pembrolizumab versus Chemotherapy for PD-L1–Positive Non–Small-Cell Lung Cancer", + "pmid": "27718847", + "doi": "10.1056/NEJMoa1606774", + "journal": "New England Journal of Medicine", + "authors": ["Reck M", "Rodriguez-Abreu D", "Robinson AG"], + "publishYear": 2016, + "ifScore": 176.079, + "docType": "RCT", + "keywords": ["NSCLC", "Pembrolizumab", "Immunotherapy"], + "language": "en" +} +``` + +--- + +## 8. structuredData 结构(类型特定数据) + +### 8.1 医学文献 (literature) + +```typescript +interface LiteratureData { + // PICO 要素 + pico?: { + P: string; // Population + I: string; // Intervention + C: string; // Comparison + O: string; // Outcome + }; + + // 研究设计 + studyDesign?: { + design: string; // Phase III RCT, Meta-analysis, etc. + sampleSize: number; // 样本量 + blinding: string; // Double-blind, Open-label + duration: string; // 研究周期 + }; + + // 用药方案 + regimen?: Array<{ + drug: string; // 药物名称 + dose: string; // 剂量 + freq: string; // 频率 + route: string; // 给药途径 + }>; + + // 安全性数据 + safety?: { + ae_all: string[]; // 所有不良反应 + ae_grade34: string[]; // 3-4级不良反应 + dropout_rate: string; // 脱落率 + }; + + // 入排标准 + criteria?: { + inclusion: string[]; // 入选标准 + exclusion: string[]; // 排除标准 + }; + + // 观察指标 + endpoints?: { + primary: string[]; // 主要终点 + secondary: string[]; // 次要终点 + results: Record; // 结果数据 + }; +} +``` + +### 8.2 典型病历 (case) + +```typescript +interface CaseData { + // 诊断信息 + diagnosis?: { + primary: string; // 主诊断 + secondary: string[]; // 合并诊断 + staging: string; // 分期 + pathology: string; // 病理类型 + biomarkers: Record; // 生物标志物 + }; + + // 治疗信息 + treatment?: { + firstLine: string; // 一线治疗 + secondLine: string; // 二线治疗 + surgery: string; // 手术 + radiation: string; // 放疗 + response: string; // 疗效评价 + duration: string; // 治疗周期 + }; + + // 预后信息 + prognosis?: { + pfs: string; // 无进展生存 + os: string; // 总生存 + status: string; // 当前状态 + recurrence: string; // 复发情况 + }; + + // 随访信息 + followUp?: { + lastVisit: string; // 最后随访日期 + nextPlan: string; // 下一步计划 + notes: string; // 随访备注 + }; +} +``` + +### 8.3 教学考试 (exam) + +```typescript +interface ExamData { + // 题目列表 + questions?: Array<{ + id: number; + type: 'single' | 'multiple' | 'essay'; // 题型 + content: string; // 题目内容 + options?: string[]; // 选项(选择题) + }>; + + // 答案列表 + answers?: Array<{ + id: number; + answer: string; // 答案 + explanation: string; // 解析 + }>; + + // 知识点 + knowledgePoints?: string[]; + + // 难度 + difficulty?: 'easy' | 'medium' | 'hard'; + + // 来源 + source?: string; // 题目来源 +} +``` + +### 8.4 药品资料 (drug) + +```typescript +interface DrugData { + // 基础信息 + genericName?: string; // 通用名 + brandName?: string; // 商品名 + manufacturer?: string; // 生产厂家 + + // 适应症 + indication?: string[]; + + // 禁忌症 + contraindication?: string[]; + + // 用法用量 + dosage?: { + adult: string; + pediatric: string; + adjustment: string; // 剂量调整 + }; + + // 药物相互作用 + interaction?: string[]; + + // 警告 + warnings?: string[]; + + // 不良反应 + adverseReactions?: { + common: string[]; + serious: string[]; + }; +} +``` + +### 8.5 临床指南 (guideline) + +```typescript +interface GuidelineData { + // 推荐意见 + recommendations?: Array<{ + content: string; // 推荐内容 + level: string; // 证据级别 (1A, 2B, etc.) + strength: string; // 推荐强度 + }>; + + // 适用人群 + population?: string; + + // 发布机构 + organization?: string; // NCCN, CSCO, ESMO + + // 版本 + version?: string; + + // 更新要点 + updates?: string[]; +} +``` + +### 8.6 个人笔记 (note) + +```typescript +interface NoteData { + // 标签(与顶层 tags 字段可重复,这里可存更多) + tags?: string[]; + + // 分类 + category?: string; + + // 关联 + relatedDocs?: string[]; // 关联文档 ID + + // 提醒 + reminder?: string; // 提醒日期 +} +``` + +--- + +## 9. 使用示例 + +### 9.1 普通医生随手上传 + +```typescript +// 医生直接拖文件上传,什么都不填 +await kbEngine.submitIngestTask({ + kbId: 'kb-123', + file: pdfBuffer, + filename: '某篇论文.pdf', + // 其他字段都不填 +}); + +// 系统自动完成: +// ✅ extractedText → 提取全文 +// ✅ chunks → 切片 +// ✅ embeddings → 向量化 +// ✅ summary → AI 摘要(可选开启) +// ✅ contentType → AI 自动识别(可选开启) + +// 结果:RAG 检索完全可用! +``` + +### 9.2 医生想分类管理 + +```typescript +// 医生上传时选择分类、打标签 +await kbEngine.submitIngestTask({ + kbId: 'kb-123', + file: pdfBuffer, + filename: 'KEYNOTE-024.pdf', + contentType: 'literature', + tags: ['肺癌', '免疫治疗', 'RCT'], + category: '肿瘤科/肺癌', +}); + +// 结果:可以按分类、标签筛选 +``` + +### 9.3 药企专业录入 + +```typescript +// 药企医学部专业人员完整填写 +await kbEngine.submitIngestTask({ + kbId: 'kb-456', + file: pdfBuffer, + filename: 'KEYNOTE-024.pdf', + contentType: 'literature', + tags: ['Pembrolizumab', 'NSCLC', 'Phase III'], + category: '临床研究/III期', + metadata: { + title: 'Pembrolizumab versus Chemotherapy...', + pmid: '27718847', + doi: '10.1056/NEJMoa1606774', + journal: 'NEJM', + authors: ['Reck M', 'Rodriguez-Abreu D'], + publishYear: 2016, + ifScore: 176.079, + docType: 'RCT', + }, + options: { enableClinicalExtraction: true } +}); + +// AI 自动提取 structuredData: +// { +// pico: { P: '晚期NSCLC, PD-L1≥50%', I: 'Pembrolizumab', ... }, +// studyDesign: { design: 'Phase III RCT', sampleSize: 305 }, +// ... +// } +``` + +### 9.4 典型病历录入 + +```typescript +await kbEngine.submitIngestTask({ + kbId: 'kb-123', + file: docBuffer, + filename: '肺癌MDT病例.docx', + contentType: 'case', + tags: ['MDT', '肺癌', 'IVA期'], + category: '病例库/肺癌', + options: { enableClinicalExtraction: true } +}); + +// AI 自动提取 structuredData: +// { +// diagnosis: { primary: '非小细胞肺癌 IVA期', ... }, +// treatment: { firstLine: '帕博利珠单抗 + 化疗', ... }, +// ... +// } +``` + +--- + +## 10. 功能可用性矩阵 + +| 功能 | 只有 Layer 0-1 | + Layer 2 | + Layer 3 | + Layer 4 | +|------|----------------|-----------|-----------|-----------| +| **向量检索** | ✅ | ✅ | ✅ | ✅ | +| **关键词检索** | ✅ | ✅ | ✅ | ✅ | +| **混合检索** | ✅ | ✅ | ✅ | ✅ | +| **快速预览** | ❌ | ✅ 有摘要 | ✅ | ✅ | +| **分类筛选** | ❌ | ❌ | ✅ | ✅ | +| **标签筛选** | ❌ | ❌ | ✅ | ✅ | +| **年份筛选** | ❌ | ❌ | ❌ | ✅ | +| **IF 筛选** | ❌ | ❌ | ❌ | ✅ | +| **PICO 查询** | ❌ | ❌ | ❌ | ✅ | + +--- + +## 11. 版本历史 + +| 版本 | 日期 | 变更内容 | +|------|------|----------| +| v1.0 | 2026-01-20 | 初版:四层架构设计,支持多内容类型 | +| v2.0 | 2026-01-21 | 整合审查建议:增加 EkbKnowledgeBase 容器表、EkbDocument.fileHash 字段、EkbChunk.metadata 字段 | + + diff --git a/docs/02-通用能力层/03-RAG引擎/05-RAG引擎使用指南.md b/docs/02-通用能力层/03-RAG引擎/05-RAG引擎使用指南.md new file mode 100644 index 00000000..6dd7a67b --- /dev/null +++ b/docs/02-通用能力层/03-RAG引擎/05-RAG引擎使用指南.md @@ -0,0 +1,559 @@ +# RAG 引擎使用指南 + +> **文档版本**: v1.0 +> **最后更新**: 2026-01-21 +> **状态**: ✅ 生产就绪 +> **目标读者**: 业务模块开发者(PKB、AIA、ASL 等) + +--- + +## 📋 快速开始 + +### 5 秒上手 + +```typescript +import { getVectorSearchService } from '@/common/rag'; + +const searchService = getVectorSearchService(prisma); + +// 单查询检索 +const results = await searchService.vectorSearch('银杏叶对老年痴呆的效果', { + topK: 10, + filter: { kbId: 'your-kb-id' } +}); + +// 业务层生成多查询后检索(推荐) +const queries = ['银杏叶副作用', 'Ginkgo side effects']; +const results = await searchService.searchWithQueries(queries, { + topK: 10, + filter: { kbId: 'your-kb-id' } +}); +``` + +--- + +## 🏗️ 架构设计 + +### 核心原则:"Brain-Hand" 模型 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 业务层 (The Brain) - 你的代码 │ +│ PKB / AIA / ASL │ +│ │ +│ 职责:思考 "怎么搜?" │ +│ • 理解用户意图(上下文、历史) │ +│ • 调用 DeepSeek V3 生成查询词 │ +│ • 决定检索策略(单语/双语/多查询) │ +│ │ +│ 示例: │ +│ const queries = await generateQueries(userInput, context);│ +│ // ["K药副作用", "Keytruda AE", "Pembrolizumab"] │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 引擎层 (The Hand) - RAG 引擎 │ +│ VectorSearchService │ +│ │ +│ 职责:执行检索(无上下文) │ +│ • 向量检索 (text-embedding-v4 1024维) │ +│ • 关键词检索 (pg_bigm) │ +│ • RRF 融合 │ +│ • Rerank (qwen3-rerank) │ +│ │ +│ ✅ 不调用 LLM 理解意图 │ +│ ✅ 只执行检索指令 │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 📦 核心组件 + +### 1. EmbeddingService - 文本向量化 + +```typescript +import { getEmbeddingService } from '@/common/rag'; + +const embeddingService = getEmbeddingService(); + +// 单文本 +const { embedding, tokenCount } = await embeddingService.embed(text); + +// 批量(自动分批,每批10条) +const { embeddings, totalTokens } = await embeddingService.embedBatch(texts); +``` + +**配置:** +```bash +# .env +TEXT_EMBEDDING_MODEL=text-embedding-v4 +TEXT_EMBEDDING_DIMENSIONS=1024 # 推荐:1024(性能平衡) +``` + +### 2. ChunkService - 文本分块 + +```typescript +import { getChunkService } from '@/common/rag'; + +const chunkService = getChunkService(); + +// Markdown 智能分块(保留标题层级) +const { chunks } = chunkService.chunkMarkdown(markdown); + +// 普通文本分块 +const { chunks } = chunkService.chunk(text); +``` + +**默认配置:** +- 最大块大小:1000 字符 +- 块间重叠:200 字符 + +### 3. VectorSearchService - 检索服务 + +```typescript +import { getVectorSearchService } from '@/common/rag'; + +const searchService = getVectorSearchService(prisma); + +// 方法 1: 单查询检索(简单场景) +const results = await searchService.vectorSearch(query, options); + +// 方法 2: 多查询检索(推荐,业务层生成查询词) +const queries = ['Query1', 'Query2', 'Query3']; +const results = await searchService.searchWithQueries(queries, options); + +// 方法 3: 混合检索(向量 + 关键词) +const results = await searchService.hybridSearch(query, options); + +// 方法 4: Rerank 重排序 +const reranked = await searchService.rerank(query, results, { topK: 5 }); +``` + +### 4. DocumentIngestService - 文档入库 + +```typescript +import { getDocumentIngestService } from '@/common/rag'; + +const ingestService = getDocumentIngestService(prisma); + +const result = await ingestService.ingestDocument( + { + filename: 'paper.pdf', + fileBuffer: pdfBuffer, + }, + { + kbId: 'your-kb-id', + contentType: 'LITERATURE', + tags: ['医学', 'RCT'], + } +); +``` + +### 5. QueryRewriter - 查询理解(业务层使用) + +```typescript +import { QueryRewriter } from '@/common/rag'; + +const rewriter = new QueryRewriter(); + +// 智能翻译 + 扩展 +const result = await rewriter.rewrite('K药副作用'); +// { +// original: "K药副作用", +// rewritten: ["Keytruda adverse events", "Pembrolizumab side effects"], +// isChinese: true, +// cost: 0.0001, +// duration: 1500 +// } +``` + +--- + +## 🎯 使用场景 + +### 场景 1: PKB 个人知识库(中英混合) + +```typescript +// modules/pkb/services/ragService.ts + +async function searchKnowledgeBase(userId: string, kbId: string, userQuery: string) { + // ===== 业务层:查询理解 ===== + const rewriter = new QueryRewriter(); + const rewriteResult = await rewriter.rewrite(userQuery); + + // 生成中英双语查询(适配混合知识库) + const queries = rewriteResult.isChinese + ? [userQuery, ...rewriteResult.rewritten] // 中文 + 英文 + : [userQuery]; + + logger.info(`PKB 检索策略: ${queries.length}条查询`, { queries }); + + // ===== 引擎层:执行检索 ===== + const searchService = getVectorSearchService(prisma); + + // 多查询向量检索 + const results = await searchService.searchWithQueries(queries, { + topK: 20, + minScore: 0.2, + filter: { kbId }, + }); + + // Rerank 精排 + const finalResults = await searchService.rerank(userQuery, results, { + topK: 10, + }); + + return finalResults; +} +``` + +### 场景 2: AIA 智能问答(上下文理解) + +```typescript +// modules/aia/services/chatService.ts + +async function chat(userId: string, message: string, chatHistory: Message[]) { + // ===== 业务层:意图理解 ===== + const llm = LLMFactory.getAdapter('deepseek-v3'); + + // 结合历史生成检索词 + const prompt = `用户说:"${message}" +上下文:${chatHistory.slice(-3).map(m => m.content).join('\n')} + +请生成2-3个精准的医学检索词(中英文):`; + + const response = await llm.chat([{ role: 'user', content: prompt }]); + const queries = JSON.parse(response.content); // ["EGFR mutation", "表皮生长因子受体突变"] + + // ===== 引擎层:执行检索 ===== + const results = await searchService.searchWithQueries(queries, { topK: 5 }); + + // 基于检索结果生成回答 + return generateAnswer(message, results); +} +``` + +### 场景 3: ASL 文献筛选(PICO 拆解) + +```typescript +// modules/asl/services/screeningService.ts + +async function screenLiteratures(picoQuery: PICO) { + // ===== 业务层:PICO 拆解 ===== + const queries = [ + `${picoQuery.P} ${picoQuery.I}`, // 人群 + 干预 + `${picoQuery.I} efficacy`, // 干预 + 疗效 + `${picoQuery.O} outcomes`, // 结局指标 + ]; + + logger.info('ASL PICO 检索', { pico: picoQuery, queries }); + + // ===== 引擎层:执行检索 ===== + const results = await searchService.searchWithQueries(queries, { + topK: 50, + filter: { contentType: 'LITERATURE' }, + }); + + return results; +} +``` + +--- + +## ⚠️ 注意事项 + +### 1. 查询理解必须在业务层 + +**❌ 错误:在引擎层调用 LLM** +```typescript +// VectorSearchService.ts (引擎层) +async vectorSearch(query) { + const translated = await deepseek.translate(query); // ❌ 引擎不应该做这个 + // ... +} +``` + +**✅ 正确:在业务层调用 LLM** +```typescript +// PKB ragService.ts (业务层) +async searchKnowledgeBase(query) { + const queries = await deepseek.generateQueries(query, context); // ✅ + const results = await engine.searchWithQueries(queries); + // ... +} +``` + +**原因:** +- 引擎没有上下文(Chat History, PICO) +- 引擎不知道知识库语言 +- 引擎不理解业务场景 + +### 2. 中英双语查询策略 + +```typescript +// 检测到中文查询 + 可能有英文文档 +if (containsChinese(userQuery)) { + const rewriter = new QueryRewriter(); + const result = await rewriter.rewrite(userQuery); + + // 生成中英双语查询词 + const queries = [ + userQuery, // 保留中文(匹配中文文档) + ...result.rewritten, // 添加英文(匹配英文文档) + ]; + + // 中文库和英文库都能匹配! + return await engine.searchWithQueries(queries); +} +``` + +### 3. pg_bigm 关键词检索需要翻译 + +```typescript +// 关键词检索必须用英文(如果文档是英文) +const keywordQuery = rewriteResult.rewritten[0]; // 使用翻译后的英文 +const keywordResults = await searchService.keywordSearch(keywordQuery); + +// 为什么? +// pg_bigm 是字符匹配,"肺癌" 匹配不到 "Lung Cancer" +// 只有 "Lung" 能匹配到 "Lung Cancer" +``` + +### 4. 性能优化 + +```typescript +// 批量向量化自动分批(10条/批) +const embeddings = await embeddingService.embedBatch(chunks); // 自动处理 + +// 多查询并行检索 +const results = await searchService.searchWithQueries([q1, q2, q3]); // 并行执行 + +// Rerank 只对候选集 +const candidates = await searchService.vectorSearch(query, { topK: 20 }); +const final = await searchService.rerank(query, candidates, { topK: 5 }); +``` + +--- + +## 🔧 环境变量配置 + +```bash +# .env + +# ===== 阿里云 API Key(必填)===== +DASHSCOPE_API_KEY=sk-xxx + +# ===== 文本向量模型 ===== +TEXT_EMBEDDING_MODEL=text-embedding-v4 +TEXT_EMBEDDING_DIMENSIONS=1024 # 推荐 1024(平衡) + +# ===== Rerank 模型 ===== +RERANK_MODEL=qwen3-rerank + +# ===== DeepSeek V3(查询理解)===== +DEEPSEEK_API_KEY=sk-xxx # 业务层使用 + +# ===== Python 微服务 ===== +EXTRACTION_SERVICE_URL=http://localhost:8000 + +# ===== PKB RAG 后端 ===== +PKB_RAG_BACKEND=pgvector # pgvector | dify | hybrid +``` + +--- + +## 📊 性能指标 + +| 操作 | 耗时 | 成本 | +|------|------|------| +| PDF → Markdown (Python) | 3-5秒 | ¥0 | +| 文本分块 (72块) | <10ms | ¥0 | +| 批量向量化 (72块) | 5秒 | ¥0.009 | +| 单次向量检索 | 50ms | ¥0 | +| DeepSeek V3 查询重写 | 1-2秒 | ¥0.0001 | +| qwen3-rerank (10候选) | 150ms | ¥0.002 | +| **完整链路** | **2.5秒** | **¥0.0025** | + +--- + +## 🎯 检索效果 + +### 跨语言检索(中文查英文) + +| 方案 | Top 1 准确率 | 相似度 | +|------|-------------|--------| +| 纯向量(v4 1024维) | 中 | 0.56 | +| + DeepSeek V3 查询重写 | 高 | 1.00 | +| + 混合检索 + Rerank | **最高** | **0.77** | + +### 同语言检索(中文查中文) + +| 方案 | Top 1 准确率 | 相似度 | +|------|-------------|--------| +| 纯向量 | 高 | 0.70+ | +| + Rerank | **最高** | **0.85+** | + +--- + +## 💡 最佳实践 + +### 1. 查询理解 Prompt 模板 + +```typescript +// 推荐:在 capability_schema.prompt_templates 中定义 + +const QUERY_REWRITE_PROMPT = `你是医学检索专家。 + +任务: +1. 如果是中文查询,翻译为英文医学术语 +2. 生成 1-2 个同义扩展查询 +3. 标准化俗称(如 "K药" → "Keytruda/Pembrolizumab") + +输入:{query} + +输出JSON数组格式: +["Query1", "Query2", ...] + +示例: +输入:"K药副作用" +输出:["Keytruda adverse events", "Pembrolizumab side effects"]`; +``` + +### 2. 完整检索链路(推荐) + +```typescript +async function intelligentSearch(userQuery: string, kbId: string, context?: any) { + // Step 1: 查询理解(业务层 DeepSeek V3) + const queries = await generateSearchQueries(userQuery, context); + + // Step 2: 混合检索(引擎层) + const candidates = await Promise.all([ + searchService.searchWithQueries(queries, { topK: 20, filter: { kbId } }), + searchService.keywordSearch(queries[queries.length - 1], { topK: 20, filter: { kbId } }), + ]); + + // Step 3: RRF 融合 + const merged = rrfFusion(candidates.flat(), 10); + + // Step 4: Rerank 精排 + const final = await searchService.rerank(userQuery, merged, { topK: 5 }); + + return final; +} +``` + +### 3. 知识库语言感知 + +```typescript +// 建议:在知识库创建时检测语言 +async function createKnowledgeBase(name: string) { + const kb = await prisma.ekbKnowledgeBase.create({ + data: { + name, + config: { + primaryLanguage: 'mixed', // 'zh' | 'en' | 'mixed' + } + } + }); + + return kb; +} + +// 检索时根据语言优化 +if (kb.config.primaryLanguage === 'zh') { + // 纯中文库:不翻译 + queries = [userQuery]; +} else if (kb.config.primaryLanguage === 'en') { + // 纯英文库:翻译 + queries = [...rewritten]; +} else { + // 混合库:双语查询 + queries = [userQuery, ...rewritten]; +} +``` + +--- + +## 🐛 常见问题 + +### Q1: 中文查询返回 0 结果? + +**原因**: +- 文档是英文,查询是中文 +- minScore 阈值太高(跨语言相似度通常 0.2-0.35) + +**解决**: +```typescript +// 方案 1: 降低阈值 +minScore: 0.2 // 跨语言场景 + +// 方案 2: 使用 DeepSeek V3 查询重写(推荐) +const queries = await rewriter.rewrite(userQuery); +``` + +### Q2: 关键词检索返回 0 结果? + +**原因**: +- 中文查询匹配不到英文文档 +- pg_bigm 是字符匹配,不是语义匹配 + +**解决**: +```typescript +// 必须用翻译后的查询 +const keywordQuery = rewritten[0]; // 英文 +await searchService.keywordSearch(keywordQuery); +``` + +### Q3: pg_bigm 未安装怎么办? + +**当前状态**: +- MVP 阶段使用 ILIKE 临时替代 +- Phase 2 会安装 pg_bigm + +**临时方案**: +```typescript +// 当前 keywordSearch 使用 Prisma 的 contains +// 效果:可用,但性能不如 pg_bigm +``` + +--- + +## 📚 相关文档 + +- [04-数据模型设计.md](./04-数据模型设计.md) - 数据库 Schema +- [03-分阶段实施方案.md](./03-分阶段实施方案.md) - 开发计划 +- [08-技术方案-跨语言检索优化.md](../../08-项目管理/08-技术方案-跨语言检索优化.md) - 跨语言优化 +- [01-知识库引擎架构设计.md](../../09-架构实施/01-知识库引擎架构设计.md) - 架构原则 + +--- + +## 🚀 快速测试 + +```bash +cd backend + +# 测试 1: 向量化服务 +npx tsx src/tests/test-embedding-service.ts + +# 测试 2: 端到端(文档入库+检索) +npx tsx src/tests/test-rag-e2e.ts + +# 测试 3: Rerank 效果 +npx tsx src/tests/test-rerank.ts + +# 测试 4: 查询重写(需要 DEEPSEEK_API_KEY) +npx tsx src/tests/test-query-rewrite.ts + +# 测试 5: PDF 入库 +npx tsx src/tests/test-pdf-ingest.ts +``` + +--- + +## 📅 版本历史 + +| 版本 | 日期 | 变更内容 | +|------|------|----------| +| v1.0 | 2026-01-21 | 初版:基于 "Brain-Hand" 架构重构完成 | + diff --git a/docs/02-通用能力层/03-RAG引擎/06-数据模型最终审查报告.md b/docs/02-通用能力层/03-RAG引擎/06-数据模型最终审查报告.md new file mode 100644 index 00000000..b8363d97 --- /dev/null +++ b/docs/02-通用能力层/03-RAG引擎/06-数据模型最终审查报告.md @@ -0,0 +1,86 @@ +# **知识库引擎数据模型 (v2.0) 最终审查报告** + +审查对象: \[04-数据模型设计.md\] (v2.0 版本) +审查结论: ✅ 通过 (Approved) +评价等级: 🌟 优秀 (Excellent) +适用阶段: MVP \-\> 长期生产环境 + +## **1\. 核心问题的回答** + +### **Q1: 还有什么问题吗?** + +基本没有逻辑硬伤。 现在的设计非常稳健。 +唯一需要注意的是 Prisma 与 pgvector 的磨合细节(我在下文的"实施避坑指南"中列出了)。 + +### **Q2: 是否过度设计?是否浪费?** + +**绝对没有。** + +* 空间浪费?零。 + PostgreSQL 的 NULL JSONB 字段存储开销极小(仅几个比特的位图标记)。如果你不填 structuredData,它就不占空间。 +* 开发浪费?负数。 + 相比于“先写死,以后改表结构”,现在的设计让你在未来 1 年内都不需要执行 prisma migrate 修改数据库结构。这反而节省了大量的运维和迁移成本。 +* 计算浪费?零。 + Layer 0 (纯文本 RAG) 是必经之路。你没有做任何多余的计算步骤,只是预留了存放未来 AI 提取结果的“容器”。 + +## **2\. 亮点解析 (为什么这是一个好设计)** + +### **🏆 亮点 1:Layer 0 的“保底”哲学** + +你明确了 "即使没有任何结构化数据,RAG 检索也必须能工作"。 +这对于创业公司至关重要。这意味着我们可以先把文件扔进去(Layer 0),系统就能跑起来。等以后有空了,或者有钱调用更贵的 LLM 了,再回过头来“清洗”出 Layer 4 的数据,而不需要重新上传文件。 + +### **🏆 亮点 2:fileHash 的精准切入** + +在 EkbDocument 中加入 fileHash 是神来之笔。 + +* **场景**:用户上传了一篇《肺癌指南.pdf》,过了一周又上传了一次。 +* **优势**:系统直接检测 Hash,发现已存在,瞬间秒传完成。既提升了用户体验,又节省了昂贵的 OCR 解析费和 Embedding 费用。 + +### **🏆 亮点 3:切片级 Metadata (EkbChunk.metadata)** + +这是很多成熟 RAG 系统后期才会加的功能,你在设计阶段就想到了。 + +* **场景**:搜“帕博利珠单抗的副作用”。 +* **优势**:如果没有切片级元数据,可能搜到的是“副作用”这个词在目录里的切片。有了元数据,我们可以给正文切片加权,降权目录切片。 + +## **3\. 实施避坑指南 (关键!)** + +虽然设计很完美,但在写代码落地时,这 3 个细节请务必注意: + +### **🔧 细节 1:Prisma 不会自动创建向量索引** + +schema.prisma 里写了 Unsupported("vector(1024)"),但 npx prisma migrate **不会**自动帮你创建 HNSW 索引。 + +必须做的动作: +在执行完 migrate 后,必须手动运行以下 SQL(或者创建一个空的 migration 专门放 SQL): +\-- 必须手动执行!否则检索速度会很慢 +CREATE INDEX idx\_ekb\_chunk\_embedding ON "ekb\_schema"."EkbChunk" +USING hnsw (embedding vector\_cosine\_ops) +WITH (m \= 16, ef\_construction \= 64); + +### **🔧 细节 2:fileHash 的作用域选择** + +代码示例里写的是 where: { kbId, fileHash }。 +这意味着:同一个知识库内去重。 + +* **如果用户 A 上传了文件 X,用户 B 也上传了文件 X,系统会存两份。** +* **建议**:对于 2 人团队,暂时维持这个逻辑是安全的(避免跨用户的数据泄露风险)。未来如果存储成本飙升,可以改为全局去重(但需要复杂的文件引用计数逻辑)。**目前保持现状即可。** + +### **🔧 细节 3:JSONB 的查询性能** + +虽然加了 GIN 索引,但查询深层 JSON 字段(如 structuredData-\>'pico'-\>\>'I')的语法比较繁琐。 +建议:在 KnowledgeBaseEngine 中封装好常用的查询构建器,不要让业务层直接拼 SQL。 + +## **4\. MVP 开发优先级建议** + +基于这份设计,你的 MVP 开发清单应该非常清晰: + +1. **Day 1**: 创建数据库表 (prisma migrate) \+ 手动创建 HNSW 索引。 +2. **Day 2**: 实现 ingestDocument 接口。 + * 只实现 Layer 0 (提取文本) \+ Layer 1 (基础信息)。 + * structuredData 暂时存 null。 +3. **Day 3**: 实现 vectorSearch 接口。 + * 暂不处理 JSONB 过滤,先跑通纯向量检索。 + +**结论:** 这是一个**可进可退、丰俭由人**的优秀架构。请立即冻结设计,开始编码!🚀 \ No newline at end of file diff --git a/docs/02-通用能力层/03-RAG引擎/README.md b/docs/02-通用能力层/03-RAG引擎/README.md index 09eb17ee..70eae812 100644 --- a/docs/02-通用能力层/03-RAG引擎/README.md +++ b/docs/02-通用能力层/03-RAG引擎/README.md @@ -27,12 +27,13 @@ ### 基础能力清单 -- 📄 **文档入库** - 文档解析 → 切片 → 向量化 → 存储 +- 📄 **文档入库** - ⚡️ 异步入库(pg-boss),返回 taskId 轮询状态 - 📝 **全文获取** - 单文档/批量获取文档全文 -- 📋 **摘要获取** - 单文档/批量获取文档摘要 +- 📋 **摘要获取** - 单文档/批量获取文档摘要(💰 可选生成) - 🔍 **向量检索** - 基于 pgvector 的语义检索 -- 🔤 **关键词检索** - 基于 PostgreSQL FTS +- 🔤 **关键词检索** - 基于 pg_bigm 的中文精确检索 - 🔀 **混合检索** - 向量 + 关键词 + RRF 融合 +- 🎯 **重排序** - 🆕 基于 Qwen-Rerank 的精排序 > ⚠️ **注意**:不提供 `chat()` 方法!问答策略由业务模块根据场景决定。 @@ -98,25 +99,32 @@ ## 💡 基础能力使用 -### 1. 文档入库 +### 1. 文档入库(⚡️ 异步) ```typescript import { KnowledgeBaseEngine } from '@/common/rag'; const kbEngine = new KnowledgeBaseEngine(prisma); -await kbEngine.ingestDocument({ +// 提交入库任务(立即返回) +const { taskId, documentId } = await kbEngine.submitIngestTask({ kbId: 'kb-123', userId: 'user-456', file: pdfBuffer, filename: 'research.pdf', options: { - generateSummary: true, // 生成摘要 - extractClinicalData: true, // 提取 PICO 等临床数据 + enableSummary: true, // 💰 可选,默认 false + enableClinicalExtraction: true // 💰 可选,默认 false } }); + +// 轮询任务状态 +const status = await kbEngine.getIngestStatus(taskId); +// { status: 'processing', progress: 45 } ``` +> 详见:[Postgres-Only异步任务处理指南](../Postgres-Only异步任务处理指南.md) + ### 2. 全文/摘要获取 ```typescript @@ -136,11 +144,14 @@ const summaries = await kbEngine.getAllDocumentsSummaries(kbId); // 向量检索 const vectorResults = await kbEngine.vectorSearch(kbIds, query, 20); -// 关键词检索 +// 关键词检索(pg_bigm 中文精确匹配) const keywordResults = await kbEngine.keywordSearch(kbIds, query, 20); // 混合检索(向量 + 关键词 + RRF) const hybridResults = await kbEngine.hybridSearch(kbIds, query, 10); + +// 🆕 重排序 +const reranked = await kbEngine.rerank(hybridResults, query, 5); ``` --- @@ -239,9 +250,9 @@ backend/src/common/rag/ ```typescript class KnowledgeBaseEngine { - // ========== 文档入库 ========== - ingestDocument(params: IngestParams): Promise; - ingestBatch(documents: IngestParams[]): Promise; + // ========== 文档入库(⚡️ 异步) ========== + submitIngestTask(params: IngestParams): Promise<{ taskId: string; documentId: string }>; + getIngestStatus(taskId: string): Promise<{ status, progress, error? }>; // ========== 内容获取 ========== getDocumentFullText(documentId: string): Promise; @@ -251,8 +262,9 @@ class KnowledgeBaseEngine { // ========== 检索能力 ========== vectorSearch(kbIds: string[], query: string, topK?: number): Promise; - keywordSearch(kbIds: string[], query: string, topK?: number): Promise; + keywordSearch(kbIds: string[], query: string, topK?: number): Promise; // pg_bigm hybridSearch(kbIds: string[], query: string, topK?: number): Promise; + rerank(docs: SearchResult[], query: string, topK?: number): Promise; // 🆕 // ========== 管理操作 ========== deleteDocument(documentId: string): Promise; @@ -279,27 +291,43 @@ class KnowledgeBaseEngine { ## 📅 开发计划 -详见:[02-pgvector替换Dify计划.md](./02-pgvector替换Dify计划.md) +### 分阶段实施(推荐) -| 里程碑 | 内容 | 工期 | 状态 | -|--------|------|------|------| -| **M1** | 数据库设计 + 核心服务 | 5 天 | 🔜 待开始 | -| **M2** | PKB 模块接入 + 测试 | 3 天 | 📋 规划中 | -| **M3** | 数据迁移 + 上线 | 2 天 | 📋 规划中 | +详见:[03-分阶段实施方案.md](./03-分阶段实施方案.md) + +| 阶段 | 内容 | 工期 | 状态 | +|------|------|------|------| +| **Phase 1 MVP** | 入库 + 向量检索 + 全文获取 | 3 天 | 🔜 待开始 | +| **Phase 2 增强** | + 关键词检索 + 混合检索 + rerank | 2 天 | 📋 规划中 | +| **Phase 3 完整** | + 异步入库 + 摘要 + PICO | 3 天 | 📋 规划中 | + +### 技术实现参考 + +详见:[02-pgvector替换Dify计划.md](./02-pgvector替换Dify计划.md) --- ## 📂 相关文档 -- [知识库引擎架构设计](./01-知识库引擎架构设计.md) -- [pgvector 替换 Dify 开发计划](./02-pgvector替换Dify计划.md) +- [知识库引擎架构设计](./01-知识库引擎架构设计.md) - 完整架构目标 +- [pgvector 替换 Dify 技术方案](./02-pgvector替换Dify计划.md) - 详细技术实现 +- [分阶段实施方案](./03-分阶段实施方案.md) - 🆕 MVP → 增强 → 完整 - [文档处理引擎](../02-文档处理引擎/01-文档处理引擎设计方案.md) +- [Postgres-Only异步任务处理指南](../Postgres-Only异步任务处理指南.md) - [通用能力层清单](../00-通用能力层清单.md) --- ## 📅 更新日志 +### 2026-01-20 v1.2 架构审核优化 + +- ⚡️ **入库异步化**:`submitIngestTask()` + `getIngestStatus()`,基于 pg-boss +- 💰 **成本控制**:摘要/PICO 提取默认关闭,按需开启 +- 🔧 **中文检索**:`tsvector` → `pg_bigm`,专为 CJK 文字优化 +- 🆕 **新增能力**:`rerank()` 重排序(Qwen-Rerank API) +- 📋 **分阶段实施**:新增 MVP → 增强 → 完整 三阶段方案 + ### 2026-01-20 v1.1 设计原则重大更新 - ⭐ **核心原则**:提供基础能力(乐高积木),不做策略选择 diff --git a/docs/02-通用能力层/03-RAG引擎/知识库引擎数据模型设计审查报告.md b/docs/02-通用能力层/03-RAG引擎/知识库引擎数据模型设计审查报告.md new file mode 100644 index 00000000..3df0415c --- /dev/null +++ b/docs/02-通用能力层/03-RAG引擎/知识库引擎数据模型设计审查报告.md @@ -0,0 +1,267 @@ +# **知识库引擎数据模型设计审查报告** + +文档状态: ✅ 已确认 (v2.0 融合版) +审查日期: 2026-01-20 +审查对象: \[04-数据模型设计.md\] & 架构师建议方案 +适用架构: Postgres-Only (Prisma \+ pgvector \+ pg-boss) +目标读者: 开发团队、架构师 + +## **1\. 核心决策摘要 (Executive Summary)** + +经过对业务需求(文献、病历、多模态)和团队现状(2人开发、运维极简)的深度评估,我们做出了以下关键技术决策: + +### **✅ 决策 1:采用 "Unified Schema"(统一模型)策略** + +* **结论**:所有类型的知识库(个人文献库、系统规则库、病历库)**共用一套数据库表结构**。 +* **理由**: + * **运维成本**:避免维护多套向量索引(HNSW),减少 2 人团队的数据库维护负担。 + * **业务灵活性**:通过 contentType 和 JSONB 字段支持未知的新内容类型,无需频繁迁移数据库 (Migration)。 + +### **✅ 决策 2:引入 "Container Table"(容器表)** + +* **结论**:在文档表之上,必须增加 EkbKnowledgeBase 表。 +* **理由**: + * **权限隔离**:明确区分 "User KB" (个人私有) 和 "System KB" (全员共享)。 + * **策略配置**:允许为不同库配置不同的切片大小 (ChunkSize) 或检索策略 (TopK)。 + +### **✅ 决策 3:确立 "四层数据架构"** + +* **结论**:采纳上传文档中的 Layer 0-4 分层设计。 +* **价值**:确保系统的**高容错性** —— 即使 AI 提取结构化数据失败(Layer 4),基础的 RAG 检索(Layer 0)依然可用。 + +## **2\. 最终融合版架构图 (ER Diagram)** + +我们融合了原设计的“四层架构”优势和建议方案的“容器管理”优势,形成了最终的 v2.0 模型。 + +erDiagram + %% 容器层(新增):管理权限和策略 + Ekb\_KnowledgeBase { + string id PK + string type "USER | SYSTEM" + string owner\_id "userId | moduleId" + jsonb config "策略配置(chunkSize等)" + } + + %% 文档层(四层架构):管理内容和元数据 + Ekb\_Document { + string id PK + string kb\_id FK + string content\_type "LITERATURE | CASE | ..." + string extracted\_text "Layer 0: 全文" + jsonb metadata "Layer 4: 标准属性(DOI/作者)" + jsonb structured\_data "Layer 4: 业务属性(PICO/诊断)" + } + + %% 切片层(检索核心):管理向量和索引 + Ekb\_Chunk { + string id PK + string document\_id FK + vector embedding "1024维向量" + string content "切片文本" + jsonb metadata "切片级元数据(IsAnswer)" + } + + Ekb\_KnowledgeBase ||--|{ Ekb\_Document : "contains" + Ekb\_Document ||--|{ Ekb\_Chunk : "split into" + +**潜在风险(需要补充):** + +1. **缺失 `EkbKnowledgeBase` 表**:目前的 Schema 中 `EkbDocument` 有 `kbId` 字段,但没有定义 `EkbKnowledgeBase` 表。 + * *风险*:你之前提到“用户有 3 个库,系统有业务库”。如果没有这个表,你无法管理“谁拥有这个库”、“这是系统库还是用户库”、“这个库的 RAG 策略是什么”。 +2. **`Chunk` 还可以更灵活**:目前的 `EkbChunk` 只有 `sectionType`。 + * *建议*:建议给 Chunk 也加上 `metadata` (JSONB)。比如做职业考试题时,你可能希望直接在 Chunk 层面标记 `{ "isAnswer": true }`,这样检索时可以加权。 + +场景模拟 + +场景 1:ASL 模块搜文献(只搜文献库,不搜病历) + +场景 2:PKB 用户搜自己的知识库(只搜自己的,不搜别人的) + +场景 3:AIA 智能问答(既要查系统医学库,又要查用户上传的病历) + +| 维度 | 我的设计 (上一版) | 你的设计 (上传版) | 🎯 建议融合方案 | +| :---- | :---- | :---- | :---- | +| **知识库管理** | **有 EkbKnowledgeBase 表**。 明确区分 USER vs SYSTEM 类型。 | **无**。 仅在 Document 中引用 kbId。 | **采纳我的**。 必须有这张表来管理权限和配置。 | +| **文档元数据** | 单个 metadata JSONB 字段。 简单,但混杂。 | **分离设计**。 metadata (标准) \+ structuredData (业务)。 | **采纳你的**。 分离设计对医学场景更友好,利于 TS 类型定义。 | +| **全文存储** | 隐含在 Chunk 中。 | **显式存储 extractedText**。 存放在 Document 表中。 | **采纳你的**。 保留全文很有必要,万一以后要换切片策略(比如 512 改为 1024),不用重新解析文件。 | +| **切片属性** | metadata JSONB。 | sectionType 枚举 \+ pageNumber。 | **融合**。 保留 sectionType,但增加 metadata JSONB 以备不时之需。 | + +## **3\. 详细数据模型定义 (Prisma Schema)** + +**开发者注意**:这是最终落地的 Schema,请直接复制到 prisma/schema.prisma 的 ekb\_schema 部分。 + +### **3.1 知识库容器 (EkbKnowledgeBase)** + +**解决痛点**:解决了“怎么区分这是用户的库还是系统的库”的问题。 + +model EkbKnowledgeBase { + id String @id @default(uuid()) + name String + description String? + + // 核心隔离字段 + // USER: 用户创建的,ownerId \= userId,受配额限制 (如最多3个) + // SYSTEM: 系统预置的,ownerId \= moduleId (如 "AIA", "IIT"),全员可读 + type KbType @default(USER) + ownerId String + + // 策略配置 (JSONB) + // 允许未来针对特定库进行优化,例如: + // { "chunkSize": 1024, "enableRerank": true, "embeddingModel": "v3" } + config Json? @db.JsonB + + documents EkbDocument\[\] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index(\[ownerId\]) + @@index(\[type\]) + @@schema("ekb\_schema") +} + +enum KbType { + USER + SYSTEM +} + +### **3.2 通用文档对象 (EkbDocument)** + +**解决痛点**:解决了“医学数据类型极其复杂”的问题。采用**双 JSONB 策略**。 + +model EkbDocument { + id String @id @default(uuid()) + + // 归属关系 + kbId String + knowledgeBase EkbKnowledgeBase @relation(fields: \[kbId\], references: \[id\], onDelete: Cascade) + userId String // 冗余存储上传者,方便快速查询 + + // \===== Layer 1: 基础文件信息 \===== + filename String + fileType String // pdf, docx, md, txt + fileSizeBytes BigInt + fileHash String // 用于实现“秒传”和去重 + fileUrl String // OSS 路径 + status DocStatus @default(PENDING) // 状态机:PENDING \-\> PROCESSING \-\> COMPLETED + errorMessage String? @db.Text + + // \===== Layer 0: RAG 核心层 \===== + // 必须保留全文,以便未来更改切片策略时,无需重新解析原始文件 + extractedText String? @db.Text + + // \===== Layer 2: 内容增强层 \===== + summary String? @db.Text + tokenCount Int? + pageCount Int? + + // \===== Layer 3: 分类标签层 \===== + contentType String? // 枚举字符串:literature, case, exam, drug + tags String\[\] + category String? + + // \===== Layer 4: 结构化数据层 (核心设计) \===== + // 1\. 通用元数据:所有文献都有的 (DOI, Journal, Year, Author) + metadata Json? @db.JsonB + + // 2\. 业务结构化数据:特定类型特有的 (PICO, Diagnosis, Dosage, ExamAnswer) + // 使用 JSONB \+ GIN 索引,实现 NoSQL 般的灵活性 + structuredData Json? @db.JsonB + + chunks EkbChunk\[\] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index(\[kbId\]) + @@index(\[contentType\]) + // GIN 索引:支持对 metadata 和 structuredData 内部字段的高速查询 + @@index(\[metadata\], type: Gin) + @@index(\[structuredData\], type: Gin) + @@schema("ekb\_schema") +} + +enum DocStatus { + PENDING + PROCESSING + COMPLETED + FAILED +} + +### **3.3 向量切片 (EkbChunk)** + +**解决痛点**:解决了“如何让检索更精准”的问题。 + +model EkbChunk { + id String @id @default(uuid()) + documentId String + document EkbDocument @relation(fields: \[documentId\], references: \[id\], onDelete: Cascade) + + // 核心内容 + content String @db.Text + chunkIndex Int + + // 向量数据 (PostgreSQL pgvector) + // 注意:需要手动执行 SQL 创建 HNSW 索引 + embedding Unsupported("vector(1024)")? + + // 溯源信息 + pageNumber Int? + sectionType String? // abstract, methods, results... + + // 扩展元数据 (新增) + // 允许在切片粒度做标记。例如:考试题的答案切片标记 { "isAnswer": true } + // 检索时可降低纯答案切片的权重,优先展示问题 + metadata Json? @db.JsonB + + @@index(\[documentId\]) + @@schema("ekb\_schema") +} + +## **4\. 关键设计问答 (Q\&A)** + +### **Q1: 为什么要把 metadata 和 structuredData 分开?** + +* **清晰度**:metadata 存放通用的、标准化的属性(如标题、作者、时间),这是所有文档共有的;structuredData 存放业务特定的深层数据(如 PICO、诊断结果)。 +* **前端适配**:前端组件可以统一渲染 metadata,而针对 structuredData 则根据 contentType 动态加载不同的展示组件。 + +### **Q2: 为什么要保留 extractedText (全文)?只存 Chunk 不行吗?** + +* **未来兼容性**:如果我们下个月发现 chunkSize=512 效果不好,想改成 1024。如果有 extractedText,我们可以直接在数据库里重新切片 (Re-chunking);如果没有,我们就得去 OSS 重新下载文件并解析,成本巨大。 + +### **Q3: 为什么 Chunk 也要加 metadata?** + +* **精准检索**:在“职业考试”场景中,一个题目可能被切成“题干”和“解析”两段。我们希望用户搜问题时,优先匹配“题干”。如果在 Chunk 上标记 { "type": "question" },检索算法就可以加权。 + +## **5\. 业务场景演练** + +### **场景 A:PKB 个人文献库** + +* **用户操作**:上传一篇 PDF。 +* **数据流**: + 1. 创建 EkbDocument (kbId=用户的个人库ID)。 + 2. status \= PROCESSING。 + 3. 后台解析 \-\> 填充 extractedText。 + 4. 后台切片 \-\> 插入 EkbChunk。 + 5. status \= COMPLETED。 + +### **场景 B:ASL 智能文献筛选** + +* **系统操作**:批量导入 1000 篇文献,并进行 PICO 提取。 +* **数据流**: + 1. 创建 EkbDocument (kbId=ASL临时库ID, contentType='literature')。 + 2. 调用 LLM 提取 PICO。 + 3. 更新 structuredData \= { "pico": { "P": "...", "I": "..." } }。 + 4. **查询时**:SELECT \* FROM EkbDocument WHERE structuredData-\>'pico'-\>\>'I' LIKE '%Aspirin%' (利用 GIN 索引加速)。 + +## **6\. 下一步行动 (Action Plan)** + +1. **数据库迁移**: + * 更新 prisma/schema.prisma。 + * 运行 npx prisma migrate dev \--name init\_ekb\_v2。 +2. **索引创建 (SQL)**: + * 手动执行 HNSW 索引创建语句(Prisma 暂不支持自动生成 vector 索引)。 + * 手动执行 pg\_bigm 索引创建语句。 +3. **Service 层更新**: + * 更新 KnowledgeBaseEngine,在入库时必须要求传入 kbId。 + * 在检索时,增加对 structuredData 的 JSON 过滤支持。 \ No newline at end of file diff --git a/docs/02-通用能力层/Postgres-Only异步任务处理指南.md b/docs/02-通用能力层/Postgres-Only异步任务处理指南.md index cfdb8644..c2b403d3 100644 --- a/docs/02-通用能力层/Postgres-Only异步任务处理指南.md +++ b/docs/02-通用能力层/Postgres-Only异步任务处理指南.md @@ -617,6 +617,9 @@ async saveProcessedData(recordId, newData) { + + + diff --git a/docs/02-通用能力层/快速引用卡片.md b/docs/02-通用能力层/快速引用卡片.md index 79d0d3e7..849fd7fc 100644 --- a/docs/02-通用能力层/快速引用卡片.md +++ b/docs/02-通用能力层/快速引用卡片.md @@ -232,3 +232,6 @@ const userId = 'test'; // ❌ 应该用 getUserId(request) + + + diff --git a/docs/02-通用能力层/通用能力层技术债务清单.md b/docs/02-通用能力层/通用能力层技术债务清单.md index ac0a32a1..4606f300 100644 --- a/docs/02-通用能力层/通用能力层技术债务清单.md +++ b/docs/02-通用能力层/通用能力层技术债务清单.md @@ -804,6 +804,9 @@ export const AsyncProgressBar: React.FC = ({ + + + diff --git a/docs/03-业务模块/ADMIN-运营管理端/00-Phase3.5完成总结.md b/docs/03-业务模块/ADMIN-运营管理端/00-Phase3.5完成总结.md index 999796c6..bdd973e8 100644 --- a/docs/03-业务模块/ADMIN-运营管理端/00-Phase3.5完成总结.md +++ b/docs/03-业务模块/ADMIN-运营管理端/00-Phase3.5完成总结.md @@ -301,3 +301,6 @@ Level 3: 兜底Prompt(缓存也失效) + + + diff --git a/docs/03-业务模块/ADMIN-运营管理端/06-开发记录/2026-01-16_用户管理功能与模块权限系统完成.md b/docs/03-业务模块/ADMIN-运营管理端/06-开发记录/2026-01-16_用户管理功能与模块权限系统完成.md index 88715787..87c6a37e 100644 --- a/docs/03-业务模块/ADMIN-运营管理端/06-开发记录/2026-01-16_用户管理功能与模块权限系统完成.md +++ b/docs/03-业务模块/ADMIN-运营管理端/06-开发记录/2026-01-16_用户管理功能与模块权限系统完成.md @@ -487,3 +487,6 @@ const pageSize = Number(query.pageSize) || 20; + + + diff --git a/docs/03-业务模块/ADMIN-运营管理端/README.md b/docs/03-业务模块/ADMIN-运营管理端/README.md index eabf2d9f..1d8f151d 100644 --- a/docs/03-业务模块/ADMIN-运营管理端/README.md +++ b/docs/03-业务模块/ADMIN-运营管理端/README.md @@ -221,3 +221,6 @@ ADMIN-运营管理端/ + + + diff --git a/docs/03-业务模块/ADMIN运营与INST机构管理端-文档体系建立完成.md b/docs/03-业务模块/ADMIN运营与INST机构管理端-文档体系建立完成.md index 11a5a6c3..3ddc1b4f 100644 --- a/docs/03-业务模块/ADMIN运营与INST机构管理端-文档体系建立完成.md +++ b/docs/03-业务模块/ADMIN运营与INST机构管理端-文档体系建立完成.md @@ -320,3 +320,6 @@ INST-机构管理端/ + + + diff --git a/docs/03-业务模块/AIA-AI智能问答/04-开发计划/03-前端组件设计.md b/docs/03-业务模块/AIA-AI智能问答/04-开发计划/03-前端组件设计.md index d8e5fc4c..c753a957 100644 --- a/docs/03-业务模块/AIA-AI智能问答/04-开发计划/03-前端组件设计.md +++ b/docs/03-业务模块/AIA-AI智能问答/04-开发计划/03-前端组件设计.md @@ -887,3 +887,6 @@ export interface SlashCommand { + + + diff --git a/docs/03-业务模块/AIA-AI智能问答/06-开发记录/2026-01-18-Prompt管理系统集成.md b/docs/03-业务模块/AIA-AI智能问答/06-开发记录/2026-01-18-Prompt管理系统集成.md index d1b9ae28..1fdf9998 100644 --- a/docs/03-业务模块/AIA-AI智能问答/06-开发记录/2026-01-18-Prompt管理系统集成.md +++ b/docs/03-业务模块/AIA-AI智能问答/06-开发记录/2026-01-18-Prompt管理系统集成.md @@ -192,3 +192,6 @@ export type AgentStage = 'topic' | 'design' | 'review' | 'data' | 'writing'; 本次开发遵循了现有 PromptService 的设计模式,与 RVW 模块集成方式保持一致。 + + + diff --git a/docs/03-业务模块/ASL-AI智能文献/04-开发计划/05-全文复筛前端开发计划.md b/docs/03-业务模块/ASL-AI智能文献/04-开发计划/05-全文复筛前端开发计划.md index 0d39d676..48ba9ae0 100644 --- a/docs/03-业务模块/ASL-AI智能文献/04-开发计划/05-全文复筛前端开发计划.md +++ b/docs/03-业务模块/ASL-AI智能文献/04-开发计划/05-全文复筛前端开发计划.md @@ -1297,6 +1297,9 @@ interface FulltextScreeningResult { + + + diff --git a/docs/03-业务模块/ASL-AI智能文献/05-开发记录/2025-01-23_全文复筛前端开发完成.md b/docs/03-业务模块/ASL-AI智能文献/05-开发记录/2025-01-23_全文复筛前端开发完成.md index 64470068..a69e6d0b 100644 --- a/docs/03-业务模块/ASL-AI智能文献/05-开发记录/2025-01-23_全文复筛前端开发完成.md +++ b/docs/03-业务模块/ASL-AI智能文献/05-开发记录/2025-01-23_全文复筛前端开发完成.md @@ -411,6 +411,9 @@ GET /api/v1/asl/fulltext-screening/tasks/:taskId/export + + + diff --git a/docs/03-业务模块/ASL-AI智能文献/05-开发记录/2025-01-23_全文复筛前端逻辑调整.md b/docs/03-业务模块/ASL-AI智能文献/05-开发记录/2025-01-23_全文复筛前端逻辑调整.md index 82fef801..aaf060b6 100644 --- a/docs/03-业务模块/ASL-AI智能文献/05-开发记录/2025-01-23_全文复筛前端逻辑调整.md +++ b/docs/03-业务模块/ASL-AI智能文献/05-开发记录/2025-01-23_全文复筛前端逻辑调整.md @@ -354,6 +354,9 @@ Linter错误:0个 + + + diff --git a/docs/03-业务模块/ASL-AI智能文献/05-开发记录/2025-11-23_Day5_全文复筛API开发.md b/docs/03-业务模块/ASL-AI智能文献/05-开发记录/2025-11-23_Day5_全文复筛API开发.md index 792b547e..c773502e 100644 --- a/docs/03-业务模块/ASL-AI智能文献/05-开发记录/2025-11-23_Day5_全文复筛API开发.md +++ b/docs/03-业务模块/ASL-AI智能文献/05-开发记录/2025-11-23_Day5_全文复筛API开发.md @@ -513,6 +513,9 @@ Failed to open file '\\tmp\\extraction_service\\temp_10000_test.pdf' + + + diff --git a/docs/03-业务模块/ASL-AI智能文献/05-开发记录/2026-01-18_智能文献检索DeepSearch集成.md b/docs/03-业务模块/ASL-AI智能文献/05-开发记录/2026-01-18_智能文献检索DeepSearch集成.md index c97acb55..4d2b5fbd 100644 --- a/docs/03-业务模块/ASL-AI智能文献/05-开发记录/2026-01-18_智能文献检索DeepSearch集成.md +++ b/docs/03-业务模块/ASL-AI智能文献/05-开发记录/2026-01-18_智能文献检索DeepSearch集成.md @@ -173,3 +173,6 @@ UNIFUNCS_API_KEY=sk-xxxx - [数据库开发规范](../../04-开发规范/09-数据库开发规范.md) + + + diff --git a/docs/03-业务模块/DC-数据清洗整理/04-开发计划/工具C_AI_Few-shot示例库.md b/docs/03-业务模块/DC-数据清洗整理/04-开发计划/工具C_AI_Few-shot示例库.md index 32280b02..a646ab2b 100644 --- a/docs/03-业务模块/DC-数据清洗整理/04-开发计划/工具C_AI_Few-shot示例库.md +++ b/docs/03-业务模块/DC-数据清洗整理/04-开发计划/工具C_AI_Few-shot示例库.md @@ -579,6 +579,9 @@ df['creatinine'] = pd.to_numeric(df['creatinine'], errors='coerce') + + + diff --git a/docs/03-业务模块/DC-数据清洗整理/04-开发计划/工具C_Bug修复总结_2025-12-08.md b/docs/03-业务模块/DC-数据清洗整理/04-开发计划/工具C_Bug修复总结_2025-12-08.md index 2e315a89..6c2a2147 100644 --- a/docs/03-业务模块/DC-数据清洗整理/04-开发计划/工具C_Bug修复总结_2025-12-08.md +++ b/docs/03-业务模块/DC-数据清洗整理/04-开发计划/工具C_Bug修复总结_2025-12-08.md @@ -417,6 +417,9 @@ npm run dev + + + diff --git a/docs/03-业务模块/DC-数据清洗整理/04-开发计划/工具C_Day3开发计划.md b/docs/03-业务模块/DC-数据清洗整理/04-开发计划/工具C_Day3开发计划.md index d9cd50f4..ff386e01 100644 --- a/docs/03-业务模块/DC-数据清洗整理/04-开发计划/工具C_Day3开发计划.md +++ b/docs/03-业务模块/DC-数据清洗整理/04-开发计划/工具C_Day3开发计划.md @@ -994,6 +994,9 @@ export const aiController = new AIController(); + + + diff --git a/docs/03-业务模块/DC-数据清洗整理/04-开发计划/工具C_Day4-5前端开发计划.md b/docs/03-业务模块/DC-数据清洗整理/04-开发计划/工具C_Day4-5前端开发计划.md index c2f48681..99ff6d1d 100644 --- a/docs/03-业务模块/DC-数据清洗整理/04-开发计划/工具C_Day4-5前端开发计划.md +++ b/docs/03-业务模块/DC-数据清洗整理/04-开发计划/工具C_Day4-5前端开发计划.md @@ -1328,6 +1328,9 @@ npm install react-markdown + + + diff --git a/docs/03-业务模块/DC-数据清洗整理/04-开发计划/工具C_Pivot列顺序优化总结.md b/docs/03-业务模块/DC-数据清洗整理/04-开发计划/工具C_Pivot列顺序优化总结.md index 5deafb4c..9bd2b59f 100644 --- a/docs/03-业务模块/DC-数据清洗整理/04-开发计划/工具C_Pivot列顺序优化总结.md +++ b/docs/03-业务模块/DC-数据清洗整理/04-开发计划/工具C_Pivot列顺序优化总结.md @@ -236,6 +236,9 @@ FMA___基线 | FMA___1个月 | FMA___2个月 + + + diff --git a/docs/03-业务模块/DC-数据清洗整理/04-开发计划/工具C_方案B实施总结_2025-12-09.md b/docs/03-业务模块/DC-数据清洗整理/04-开发计划/工具C_方案B实施总结_2025-12-09.md index f44d638d..95433c8e 100644 --- a/docs/03-业务模块/DC-数据清洗整理/04-开发计划/工具C_方案B实施总结_2025-12-09.md +++ b/docs/03-业务模块/DC-数据清洗整理/04-开发计划/工具C_方案B实施总结_2025-12-09.md @@ -394,6 +394,9 @@ formula = "FMA总分(0-100) / 100" + + + diff --git a/docs/03-业务模块/DC-数据清洗整理/04-开发计划/工具C_缺失值处理_开发进度_2025-12-10.md b/docs/03-业务模块/DC-数据清洗整理/04-开发计划/工具C_缺失值处理_开发进度_2025-12-10.md index 211e0db8..3f0c9021 100644 --- a/docs/03-业务模块/DC-数据清洗整理/04-开发计划/工具C_缺失值处理_开发进度_2025-12-10.md +++ b/docs/03-业务模块/DC-数据清洗整理/04-开发计划/工具C_缺失值处理_开发进度_2025-12-10.md @@ -228,6 +228,9 @@ async handleFillnaMice(request, reply) { + + + diff --git a/docs/03-业务模块/DC-数据清洗整理/04-开发计划/工具C_缺失值处理功能_更新说明.md b/docs/03-业务模块/DC-数据清洗整理/04-开发计划/工具C_缺失值处理功能_更新说明.md index f753a756..5079d21b 100644 --- a/docs/03-业务模块/DC-数据清洗整理/04-开发计划/工具C_缺失值处理功能_更新说明.md +++ b/docs/03-业务模块/DC-数据清洗整理/04-开发计划/工具C_缺失值处理功能_更新说明.md @@ -200,6 +200,9 @@ method: 'mean' | 'median' | 'mode' | 'constant' | 'ffill' | 'bfill' + + + diff --git a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-02_工作总结.md b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-02_工作总结.md index bb489398..b53a3418 100644 --- a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-02_工作总结.md +++ b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-02_工作总结.md @@ -350,6 +350,9 @@ Changes: + + + diff --git a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-06_工具C_Day1开发完成总结.md b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-06_工具C_Day1开发完成总结.md index 03ccac27..bc32ed4b 100644 --- a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-06_工具C_Day1开发完成总结.md +++ b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-06_工具C_Day1开发完成总结.md @@ -422,6 +422,9 @@ cd path; command + + + diff --git a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-06_工具C_Day2开发完成总结.md b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-06_工具C_Day2开发完成总结.md index e091b24d..1ef7ed1b 100644 --- a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-06_工具C_Day2开发完成总结.md +++ b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-06_工具C_Day2开发完成总结.md @@ -651,6 +651,9 @@ import { logger } from '../../../../common/logging/index.js'; + + + diff --git a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-07_AI对话核心功能增强总结.md b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-07_AI对话核心功能增强总结.md index 94457185..400b568e 100644 --- a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-07_AI对话核心功能增强总结.md +++ b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-07_AI对话核心功能增强总结.md @@ -655,6 +655,9 @@ Content-Length: 45234 + + + diff --git a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-07_Bug修复_DataGrid空数据防御.md b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-07_Bug修复_DataGrid空数据防御.md index 2e929808..8c0c88ca 100644 --- a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-07_Bug修复_DataGrid空数据防御.md +++ b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-07_Bug修复_DataGrid空数据防御.md @@ -307,6 +307,9 @@ Response: + + + diff --git a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-07_Day5_Ant-Design-X重构完成.md b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-07_Day5_Ant-Design-X重构完成.md index dcd18d0b..dc8f9ec6 100644 --- a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-07_Day5_Ant-Design-X重构完成.md +++ b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-07_Day5_Ant-Design-X重构完成.md @@ -460,6 +460,9 @@ Response: + + + diff --git a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-07_Day5最终总结.md b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-07_Day5最终总结.md index a4a605b3..3c3a67d3 100644 --- a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-07_Day5最终总结.md +++ b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-07_Day5最终总结.md @@ -454,6 +454,9 @@ import { ChatContainer } from '@/shared/components/Chat'; + + + diff --git a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-07_UI优化与Bug修复.md b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-07_UI优化与Bug修复.md index f43f9c6e..6eb6706a 100644 --- a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-07_UI优化与Bug修复.md +++ b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-07_UI优化与Bug修复.md @@ -364,6 +364,9 @@ const initialMessages = defaultMessages.length > 0 ? defaultMessages : [{ + + + diff --git a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-07_后端API完整对接完成.md b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-07_后端API完整对接完成.md index e0f6cfb3..d3035683 100644 --- a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-07_后端API完整对接完成.md +++ b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-07_后端API完整对接完成.md @@ -404,6 +404,9 @@ python main.py + + + diff --git a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-07_完整UI优化与功能增强.md b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-07_完整UI优化与功能增强.md index 7e279914..f9ab48ce 100644 --- a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-07_完整UI优化与功能增强.md +++ b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-07_完整UI优化与功能增强.md @@ -652,6 +652,9 @@ http://localhost:5173/data-cleaning/tool-c + + + diff --git a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-07_工具C_Day4前端基础完成.md b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-07_工具C_Day4前端基础完成.md index 33a35327..31433336 100644 --- a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-07_工具C_Day4前端基础完成.md +++ b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-07_工具C_Day4前端基础完成.md @@ -262,6 +262,9 @@ Day 5 (6-8小时): + + + diff --git a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/DC模块重建完成总结-Day1.md b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/DC模块重建完成总结-Day1.md index a4a82676..ee051561 100644 --- a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/DC模块重建完成总结-Day1.md +++ b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/DC模块重建完成总结-Day1.md @@ -440,6 +440,9 @@ Docs: docs/03-业务模块/DC-数据清洗整理/06-开发记录/DC模块重建 + + + diff --git a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/Phase1-Portal页面开发完成-2025-12-02.md b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/Phase1-Portal页面开发完成-2025-12-02.md index 944e158e..c3c99c12 100644 --- a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/Phase1-Portal页面开发完成-2025-12-02.md +++ b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/Phase1-Portal页面开发完成-2025-12-02.md @@ -415,6 +415,9 @@ const mockAssets: Asset[] = [ + + + diff --git a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/Phase2-ToolB-Step1-2开发完成-2025-12-03.md b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/Phase2-ToolB-Step1-2开发完成-2025-12-03.md index c909229b..d6a004dc 100644 --- a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/Phase2-ToolB-Step1-2开发完成-2025-12-03.md +++ b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/Phase2-ToolB-Step1-2开发完成-2025-12-03.md @@ -399,6 +399,9 @@ frontend-v2/src/modules/dc/ + + + diff --git a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/Portal页面UI优化-2025-12-02.md b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/Portal页面UI优化-2025-12-02.md index ad9da518..7b8c1b2c 100644 --- a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/Portal页面UI优化-2025-12-02.md +++ b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/Portal页面UI优化-2025-12-02.md @@ -359,6 +359,9 @@ + + + diff --git a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/Tool-B-MVP完成总结-2025-12-03.md b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/Tool-B-MVP完成总结-2025-12-03.md index b4669e51..909e2541 100644 --- a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/Tool-B-MVP完成总结-2025-12-03.md +++ b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/Tool-B-MVP完成总结-2025-12-03.md @@ -313,6 +313,9 @@ ConflictDetectionService // 冲突检测(字段级对比) + + + diff --git a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/ToolB-UI优化-2025-12-03.md b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/ToolB-UI优化-2025-12-03.md index 752bf080..7b15795e 100644 --- a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/ToolB-UI优化-2025-12-03.md +++ b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/ToolB-UI优化-2025-12-03.md @@ -362,6 +362,9 @@ + + + diff --git a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/ToolB-UI优化-Round2-2025-12-03.md b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/ToolB-UI优化-Round2-2025-12-03.md index 440aaf53..33874622 100644 --- a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/ToolB-UI优化-Round2-2025-12-03.md +++ b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/ToolB-UI优化-Round2-2025-12-03.md @@ -325,6 +325,9 @@ + + + diff --git a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/ToolB浏览器测试计划-2025-12-03.md b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/ToolB浏览器测试计划-2025-12-03.md index af3f66dd..2bf91256 100644 --- a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/ToolB浏览器测试计划-2025-12-03.md +++ b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/ToolB浏览器测试计划-2025-12-03.md @@ -389,6 +389,9 @@ + + + diff --git a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/后端API测试报告-2025-12-02.md b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/后端API测试报告-2025-12-02.md index 1fc3812a..eabc12f1 100644 --- a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/后端API测试报告-2025-12-02.md +++ b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/后端API测试报告-2025-12-02.md @@ -477,6 +477,9 @@ Tool B后端代码**100%复用**了平台通用能力层,无任何重复开发 + + + diff --git a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/待办事项-下一步工作.md b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/待办事项-下一步工作.md index 80e37478..7c061525 100644 --- a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/待办事项-下一步工作.md +++ b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/待办事项-下一步工作.md @@ -323,6 +323,9 @@ + + + diff --git a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/数据库验证报告-2025-12-02.md b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/数据库验证报告-2025-12-02.md index 0f7a6d42..38c53b8f 100644 --- a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/数据库验证报告-2025-12-02.md +++ b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/数据库验证报告-2025-12-02.md @@ -254,6 +254,9 @@ $ node scripts/check-dc-tables.mjs + + + diff --git a/docs/03-业务模块/DC-数据清洗整理/07-技术债务/Tool-B技术债务清单.md b/docs/03-业务模块/DC-数据清洗整理/07-技术债务/Tool-B技术债务清单.md index 10337f28..75d7eb10 100644 --- a/docs/03-业务模块/DC-数据清洗整理/07-技术债务/Tool-B技术债务清单.md +++ b/docs/03-业务模块/DC-数据清洗整理/07-技术债务/Tool-B技术债务清单.md @@ -487,6 +487,9 @@ ${fields.map((f, i) => `${i + 1}. ${f.name}:${f.desc}`).join('\n')} + + + diff --git a/docs/03-业务模块/IIT Manager Agent/02-技术设计/IIT Manager Agent 技术路径与架构设计.md b/docs/03-业务模块/IIT Manager Agent/02-技术设计/IIT Manager Agent 技术路径与架构设计.md index e1adbf26..db19a43b 100644 --- a/docs/03-业务模块/IIT Manager Agent/02-技术设计/IIT Manager Agent 技术路径与架构设计.md +++ b/docs/03-业务模块/IIT Manager Agent/02-技术设计/IIT Manager Agent 技术路径与架构设计.md @@ -692,6 +692,9 @@ private async processMessageAsync(xmlData: any) { + + + diff --git a/docs/03-业务模块/IIT Manager Agent/04-开发计划/REDCap对接技术方案与实施指南.md b/docs/03-业务模块/IIT Manager Agent/04-开发计划/REDCap对接技术方案与实施指南.md index 15ad37c3..fb545abc 100644 --- a/docs/03-业务模块/IIT Manager Agent/04-开发计划/REDCap对接技术方案与实施指南.md +++ b/docs/03-业务模块/IIT Manager Agent/04-开发计划/REDCap对接技术方案与实施指南.md @@ -1086,6 +1086,9 @@ async function testIntegration() { + + + diff --git a/docs/03-业务模块/IIT Manager Agent/04-开发计划/企业微信注册指南.md b/docs/03-业务模块/IIT Manager Agent/04-开发计划/企业微信注册指南.md index 28bead5c..0bd4d25b 100644 --- a/docs/03-业务模块/IIT Manager Agent/04-开发计划/企业微信注册指南.md +++ b/docs/03-业务模块/IIT Manager Agent/04-开发计划/企业微信注册指南.md @@ -227,6 +227,9 @@ Content-Type: application/json + + + diff --git a/docs/03-业务模块/IIT Manager Agent/06-开发记录/2026-01-04-Dify知识库集成开发记录.md b/docs/03-业务模块/IIT Manager Agent/06-开发记录/2026-01-04-Dify知识库集成开发记录.md index bd59412f..f0cb8c01 100644 --- a/docs/03-业务模块/IIT Manager Agent/06-开发记录/2026-01-04-Dify知识库集成开发记录.md +++ b/docs/03-业务模块/IIT Manager Agent/06-开发记录/2026-01-04-Dify知识库集成开发记录.md @@ -647,6 +647,9 @@ REDCap API: exportRecords success { recordCount: 1 } + + + diff --git a/docs/03-业务模块/IIT Manager Agent/06-开发记录/Day2-REDCap实时集成开发完成记录.md b/docs/03-业务模块/IIT Manager Agent/06-开发记录/Day2-REDCap实时集成开发完成记录.md index 6a5f7b12..c4c60342 100644 --- a/docs/03-业务模块/IIT Manager Agent/06-开发记录/Day2-REDCap实时集成开发完成记录.md +++ b/docs/03-业务模块/IIT Manager Agent/06-开发记录/Day2-REDCap实时集成开发完成记录.md @@ -653,6 +653,9 @@ backend/src/modules/iit-manager/ + + + diff --git a/docs/03-业务模块/IIT Manager Agent/06-开发记录/Day3-企业微信集成与端到端测试完成记录.md b/docs/03-业务模块/IIT Manager Agent/06-开发记录/Day3-企业微信集成与端到端测试完成记录.md index e4d41bec..cb1fc86d 100644 --- a/docs/03-业务模块/IIT Manager Agent/06-开发记录/Day3-企业微信集成与端到端测试完成记录.md +++ b/docs/03-业务模块/IIT Manager Agent/06-开发记录/Day3-企业微信集成与端到端测试完成记录.md @@ -803,6 +803,9 @@ CREATE TABLE iit_schema.wechat_tokens ( + + + diff --git a/docs/03-业务模块/IIT Manager Agent/06-开发记录/Day3-企业微信集成开发完成记录.md b/docs/03-业务模块/IIT Manager Agent/06-开发记录/Day3-企业微信集成开发完成记录.md index 48f54bc4..e85bb28b 100644 --- a/docs/03-业务模块/IIT Manager Agent/06-开发记录/Day3-企业微信集成开发完成记录.md +++ b/docs/03-业务模块/IIT Manager Agent/06-开发记录/Day3-企业微信集成开发完成记录.md @@ -560,6 +560,9 @@ Day 3 的开发工作虽然遇到了多个技术问题,但最终成功完成 + + + diff --git a/docs/03-业务模块/IIT Manager Agent/06-开发记录/Phase1.5-AI对话集成REDCap完成记录.md b/docs/03-业务模块/IIT Manager Agent/06-开发记录/Phase1.5-AI对话集成REDCap完成记录.md index dc291ff3..bcb2b772 100644 --- a/docs/03-业务模块/IIT Manager Agent/06-开发记录/Phase1.5-AI对话集成REDCap完成记录.md +++ b/docs/03-业务模块/IIT Manager Agent/06-开发记录/Phase1.5-AI对话集成REDCap完成记录.md @@ -327,6 +327,9 @@ AI: "出生日期:2017-01-04 + + + diff --git a/docs/03-业务模块/IIT Manager Agent/06-开发记录/V1.1更新完成报告.md b/docs/03-业务模块/IIT Manager Agent/06-开发记录/V1.1更新完成报告.md index b572bc41..ee705687 100644 --- a/docs/03-业务模块/IIT Manager Agent/06-开发记录/V1.1更新完成报告.md +++ b/docs/03-业务模块/IIT Manager Agent/06-开发记录/V1.1更新完成报告.md @@ -271,6 +271,9 @@ Day 4: REDCap EM(Webhook推送)← 作为增强,而非核心 + + + diff --git a/docs/03-业务模块/IIT Manager Agent/07-技术债务/IIT Manager Agent 技术债务清单.md b/docs/03-业务模块/IIT Manager Agent/07-技术债务/IIT Manager Agent 技术债务清单.md index 6fa47f96..fc0e6bae 100644 --- a/docs/03-业务模块/IIT Manager Agent/07-技术债务/IIT Manager Agent 技术债务清单.md +++ b/docs/03-业务模块/IIT Manager Agent/07-技术债务/IIT Manager Agent 技术债务清单.md @@ -685,6 +685,9 @@ const answer = `根据研究方案[1]和CRF表格[2],纳入标准包括: + + + diff --git a/docs/03-业务模块/INST-机构管理端/00-模块当前状态与开发指南.md b/docs/03-业务模块/INST-机构管理端/00-模块当前状态与开发指南.md index 3962f9ab..2a49c4b1 100644 --- a/docs/03-业务模块/INST-机构管理端/00-模块当前状态与开发指南.md +++ b/docs/03-业务模块/INST-机构管理端/00-模块当前状态与开发指南.md @@ -446,3 +446,6 @@ export const calculateAvailableQuota = async (tenantId: string) => { + + + diff --git a/docs/03-业务模块/INST-机构管理端/README.md b/docs/03-业务模块/INST-机构管理端/README.md index 4e980d2a..1e0668de 100644 --- a/docs/03-业务模块/INST-机构管理端/README.md +++ b/docs/03-业务模块/INST-机构管理端/README.md @@ -319,3 +319,6 @@ https://platform.example.com/t/pharma-abc/login + + + diff --git a/docs/03-业务模块/PKB-个人知识库/00-模块当前状态与开发指南.md b/docs/03-业务模块/PKB-个人知识库/00-模块当前状态与开发指南.md index de5aa5d7..f1a12eaa 100644 --- a/docs/03-业务模块/PKB-个人知识库/00-模块当前状态与开发指南.md +++ b/docs/03-业务模块/PKB-个人知识库/00-模块当前状态与开发指南.md @@ -1,11 +1,12 @@ # PKB个人知识库模块 - 当前状态与开发指南 -> **文档版本:** v2.1 +> **文档版本:** v2.2 > **创建日期:** 2026-01-07 > **维护者:** PKB模块开发团队 -> **最后更新:** 2026-01-19 -> **重大进展:** 🎉 **PKB模块核心功能全部实现,pgvector向量数据库已集成!** +> **最后更新:** 2026-01-20 +> **重大进展:** 🎉 **知识库能力提升为通用能力层,PKB 将作为首个接入模块!** > **基础设施:** ✅ pgvector 0.8.1 已安装,RAG检索模式基础设施就绪 +> **架构变更:** 知识库引擎迁移至 `common/rag/`,详见通用能力层文档 > **文档目的:** 反映模块真实状态,记录开发历程 --- @@ -70,10 +71,22 @@ UI组件: Ant Design v6 + Ant Design X Schema: pkb_schema (独立隔离) 向量存储: pgvector (PostgreSQL原生向量扩展) ✅ 2026-01-19 已集成 LLM: DeepSeek-V3, Qwen-Max (通过LLMFactory) -RAG: Dify知识库集成 → 计划迁移到 pgvector 原生RAG +RAG: 通用能力层知识库引擎 (common/rag/) 🔄 2026-01-20 架构升级中 存储: OSS对象存储 ``` +### 依赖的通用能力层 + +| 通用能力 | 用途 | 状态 | +|----------|------|------| +| **知识库引擎** | 文档入库、向量检索、RAG 问答 | 🔄 开发中 | +| **文档处理引擎** | PDF/Word/Excel → Markdown | ✅ 已就绪 | +| **LLM 网关** | 大模型调用 | ✅ 已接入 | +| **存储服务** | 文档存储到 OSS | ✅ 已接入 | + +> 📍 **架构说明**:知识库能力已提升为通用能力层,PKB 模块将调用 `common/rag/KnowledgeBaseEngine`, +> 详见 [通用能力层 - 知识库引擎](../../02-通用能力层/03-RAG引擎/README.md) + ### API路由 ``` diff --git a/docs/03-业务模块/PKB-个人知识库/00-系统设计/从搜索引擎到RAG:技术演进与借鉴指南.md b/docs/03-业务模块/PKB-个人知识库/00-系统设计/从搜索引擎到RAG:技术演进与借鉴指南.md new file mode 100644 index 00000000..013680f7 --- /dev/null +++ b/docs/03-业务模块/PKB-个人知识库/00-系统设计/从搜索引擎到RAG:技术演进与借鉴指南.md @@ -0,0 +1,92 @@ +# **从搜索引擎到 RAG:技术演进与借鉴指南** + +文档版本: v1.0 +核心议题: RAG 的历史溯源与搜索引擎技术的跨时代应用 +适用场景: 企业级知识库 (EKB) 检索策略优化 + +## **一、 历史溯源:RAG 是新瓶装旧酒吗?** + +### **1.1 RAG 的前世今生** + +| 时代 | 技术形态 | 核心逻辑 | 代表产物 | +| :---- | :---- | :---- | :---- | +| **前 LLM 时代** (2000-2018) | **QA 系统 (Question Answering)** | 检索 \-\> 抽取 从文档中直接把答案那句话“抠”出来。 | IBM Watson, Google Featured Snippets | +| **推荐系统时代** (2010-2020) | **向量召回 (Vector Retrieval)** | User Embedding \-\> Item Embedding 用向量找相似物品。 | 淘宝/抖音推荐算法, FAISS (Facebook) | +| **LLM 时代** (2020-至今) | **RAG (Retrieval-Augmented Generation)** | 检索 \-\> 生成 检索相关片段,让 LLM 读懂并“写”出新答案。 | ChatGPT with Browsing, Perplexity | + +**结论:** + +* **向量技术**:早就有了(推荐系统、搜图)。 +* **检索流程**:早就有了(搜索引擎、QA)。 +* **LLM 带来的质变**:**“生成能力”**。以前的系统只能“搬运”答案,现在的系统能“理解并重写”答案。这让知识库从“图书馆管理员”变成了“研究员”。 + +## **二、 RAG 本质论:搜索引擎的进化体** + +您可以把 RAG 理解为 **"搜索引擎 2.0"**。 + +* **搜索引擎 1.0 (Google/Baidu)**: + * **输入**:关键词。 + * **输出**:一堆链接(Documents)。 + * **用户行为**:用户自己点开链接,自己阅读,自己总结。 + * **瓶颈**:用户的阅读速度。 +* **搜索引擎 2.0 (RAG/Perplexity)**: + * **输入**:自然语言问题。 + * **中间层**:系统代替用户完成了“点开链接、阅读全文、提取关键点”的工作。 + * **输出**:一个直接的答案(Answer)。 + * **本质**:**RAG \= 检索 (Search) \+ 阅读理解 (Reading Comprehension)**。 + +## **三、 借古鉴今:搜索引擎有哪些“神技”可以救 RAG?** + +目前的 RAG 系统(特别是纯向量检索)经常遇到“搜不准”的问题。其实,**传统搜索引擎早在 10 年前就解决了这些问题**。我们完全可以把这些老技术搬过来。 + +### **策略 1:倒排索引与关键词匹配 (Inverted Index & BM25)** + +* **痛点**:向量检索不仅懂语义,也容易“懂过头”。搜“维生素A”,它可能给你召回“维生素C”(因为向量离得很近)。 +* **搜索引擎解法**:**倒排索引**。强制要求文档里必须包含“A”这个字符。 +* **EKB 落地**:这就是我们架构中的 **pg\_bigm / tsvector**。**混合检索**就是这一思想的产物。 + +### **策略 2:查询扩展与改写 (Query Expansion / Rewriting)** + +* **痛点**:用户搜“PD-1”,文档里写的是“帕博利珠单抗”。字面不匹配,向量也可能不够近。 +* **搜索引擎解法**:当用户搜 A 时,后台自动帮他搜 (A OR B OR C)。Google 内部有巨大的同义词库。 +* **EKB 落地**: + * **方法**:在 Node.js Router 层,调用 DeepSeek 把用户问题改写成 3 个变体。 + * *Prompt*: "用户问'PD-1效果',请生成3个更专业的搜索词,如'免疫检查点抑制剂疗效'、'Keytruda 临床数据'。" + +### **3\. 策略 3:相关性反馈 (Relevance Feedback / Pseudo-Relevance Feedback)** + +* **痛点**:第一次搜出来的东西不准。 +* **搜索引擎解法**:假设第一次搜出来的前 3 个文档是相关的,从这 3 个文档里提取新的关键词,再搜一次。 +* **EKB 落地**:**HyDE (Hypothetical Document Embeddings)**。 + * 先让 LLM 生成一个“假设的完美答案”。 + * 用这个假设答案去搜真实文档。 + * *这本质上就是一种高级的“相关性反馈”。* + +### **4\. 策略 4:多路召回与精排 (Multi-stage Retrieval & Rerank)** + +* **痛点**:海量数据中,怎么保证 Top 1 是对的? +* **搜索引擎解法**:**漏斗架构**。 + * **L1 (粗排)**:用便宜的方法(倒排索引)快速捞出 1000 条。 + * **L2 (精排)**:用昂贵的方法(学习排序模型 LambdaMART)对这 1000 条精细打分,选出 Top 10。 +* **EKB 落地**:这就是我们的 **R-C-R-G 范式**。 + * **L1**:Postgres (Vector \+ Keyword) 捞 Top 50。 + * **L2**:Qwen-Rerank (Cross-Encoder 模型) 精排 Top 10。 + +### **5\. 策略 5:元数据过滤 (Faceted Search)** + +* **痛点**:搜“最新指南”,结果出来全是 2010 年的。 +* **搜索引擎解法**:分面搜索(电商左侧的筛选栏:品牌、价格、年份)。 +* **EKB 落地**:**SQL 结构化过滤**。 + * 我们提取 PICO、年份、期刊 IF 分,本质上就是为了支持 Faceted Search。 + * 先 WHERE year \> 2023,再做向量搜索。 + +## **四、 总结:您的技术路线是“集大成者”** + +您的 **EKB 方案** 之所以被打高分,就是因为它没有掉进“唯向量论”的陷阱,而是**融合了搜索引擎的经典智慧**: + +1. **pgvector** \= 现代向量检索 (推荐系统技术) +2. **pg\_bigm** \= 传统倒排索引 (Google/Lucene 技术) +3. **Rerank** \= 搜索精排模型 (广告推荐技术) +4. **DeepSeek** \= 阅读理解器 (LLM 技术) + +**您正在用 2025 年的算力,复兴 2005 年的搜索智慧,这才是最扎实的路径。** \ No newline at end of file diff --git a/docs/03-业务模块/PKB-个人知识库/04-开发计划/01-Dify替换为pgvector开发计划.md b/docs/03-业务模块/PKB-个人知识库/04-开发计划/01-Dify替换为pgvector开发计划.md index 59e27595..60574a05 100644 --- a/docs/03-业务模块/PKB-个人知识库/04-开发计划/01-Dify替换为pgvector开发计划.md +++ b/docs/03-业务模块/PKB-个人知识库/04-开发计划/01-Dify替换为pgvector开发计划.md @@ -1,672 +1,57 @@ -# PKB 模块:Dify 替换为 pgvector 开发计划 +# ⚠️ 文档已迁移 -> **文档版本:** v1.0 -> **创建日期:** 2026-01-19 -> **预计工期:** 2 周(10个工作日) -> **前置条件:** ✅ pgvector 0.8.1 已安装 -> **目标:** 用 PostgreSQL + pgvector 原生 RAG 替代 Dify,实现 R-C-R-G 混合检索架构 +> **迁移日期:** 2026-01-20 +> **迁移原因:** 知识库能力提升为通用能力层,不再局限于 PKB 模块 --- -## 📊 整体难度评估 +## 📍 新文档位置 -### 总体评估:⭐⭐⭐ 中等难度 +本文档已迁移至通用能力层: -| 评估维度 | 难度 | 说明 | -|----------|------|------| -| **数据库设计** | ⭐⭐ 低 | Prisma schema 直接写,pgvector 已就绪 | -| **Embedding 服务** | ⭐⭐ 低 | 调用阿里云 API,简单封装 | -| **文档切片** | ⭐⭐ 低 | 成熟方案,RecursiveCharacterTextSplitter | -| **全要素提取** | ⭐⭐⭐ 中 | 需要调优 Prompt,处理 JSON 异常 | -| **向量检索** | ⭐⭐⭐ 中 | pgvector SQL 语法需要学习 | -| **混合检索(RRF)** | ⭐⭐⭐ 中 | 核心算法,需要调优 | -| **服务替换** | ⭐⭐⭐⭐ 中高 | 需要保持 API 兼容,测试覆盖 | -| **数据迁移** | ⭐⭐⭐ 中 | 现有文档需重新向量化 | - -**综合评估**:技术上完全可行,主要挑战在于**服务替换的平滑过渡**和**检索效果调优**。 +**[02-通用能力层/03-RAG引擎/02-pgvector替换Dify计划.md](../../../02-通用能力层/03-RAG引擎/02-pgvector替换Dify计划.md)** --- -## 🔥 核心挑战分析 +## 🔄 架构变更说明 -### 挑战 1:混合检索效果调优 🔴 高风险 +### 变更原因 -**问题描述**: -- 替换 Dify 后,检索效果可能下降 -- 需要调优向量检索 + 关键词检索的权重 -- RRF 参数(k 值)需要实验确定 +知识库(RAG 引擎)是**通用能力**,不应局限于单一业务模块: -**应对策略**: -- 准备测试数据集(100+ 查询) -- 建立效果评估指标(Recall@K, MRR) -- 先用小批量数据验证,再全量迁移 +| 业务模块 | 使用场景 | +|----------|----------| +| **PKB** 个人知识库 | 知识库管理、RAG 问答 | +| **AIA** AI智能问答 | @知识库 问答、附件理解 | +| **ASL** AI智能文献 | 文献库检索、智能综述 | +| **RVW** 稿件审查 | 稿件与文献对比、查重 | -**预留时间**:2 天专门用于调优 - ---- - -### 挑战 2:全要素提取的准确性 🟡 中风险 - -**问题描述**: -- LLM 提取的 JSON 可能格式错误 -- PICO、用药方案等字段提取不完整 -- 不同类型文献(RCT/综述/病例)提取策略不同 - -**应对策略**: -- 三层 JSON 解析容错(直接解析 → 提取代码块 → LLM修复) -- 字段级校验(必填字段、类型校验) -- 分文献类型设计 Prompt - -**预留时间**:1 天用于 Prompt 调优 - ---- - -### 挑战 3:服务替换的兼容性 🟡 中风险 - -**问题描述**: -- 需要保持 API 接口不变(前端零修改) -- `searchKnowledgeBase()` 返回格式需兼容 -- 文档上传流程需要无缝切换 - -**应对策略**: -- 定义适配层,转换返回格式 -- 新旧服务并行运行,灰度切换 -- 充分测试所有使用场景 - -**预留时间**:1 天专门用于兼容性测试 - ---- - -### 挑战 4:向量数据的批量处理 🟢 低风险 - -**问题描述**: -- 批量 Embedding 调用需要控制并发 -- 阿里云 API 有 QPS 限制 -- 大文档切片后向量较多 - -**应对策略**: -- 使用 p-queue 控制并发(固定 3 并发) -- 批量 Embedding(每次最多 25 条) -- 增量处理,支持断点续传 - ---- - -## 📅 详细开发计划 - -### 总览时间线 +### 新架构 ``` -Week 1: 基础设施 + 核心服务开发 -├── Day 1: 数据库设计 + Prisma 迁移 -├── Day 2: Embedding 服务 + 切片服务 -├── Day 3: 全要素提取服务(Prompt 调优) -├── Day 4: 向量检索服务(pgvector SQL) -├── Day 5: 混合检索 + RRF 融合 - -Week 2: 服务替换 + 测试 + 迁移 -├── Day 6: 修改 knowledgeBaseService(检索替换) -├── Day 7: 修改 documentService(上传替换) -├── Day 8: 集成测试 + 效果调优 -├── Day 9: 数据迁移(现有文档向量化) -├── Day 10: 清理 + 文档 + 上线 +┌─────────────────────────────────────────────────────────────┐ +│ 业务模块层 │ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ +│ │ PKB │ │ AIA │ │ ASL │ │ RVW │ │ +│ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │ +│ └────────────┴────────────┴────────────┘ │ +│ │ │ +│ ▼ │ +├─────────────────────────────────────────────────────────────┤ +│ 知识库引擎(通用能力层) │ +│ 代码位置:backend/src/common/rag/ │ +│ 文档位置:docs/02-通用能力层/03-RAG引擎/ │ +└─────────────────────────────────────────────────────────────┘ ``` --- -### Day 1:数据库设计 + Prisma 迁移 +## 📚 相关文档 -**目标**:创建向量存储的数据表 - -**任务清单**: -- [ ] 设计 `EkbDocument` 表(增强文档,含临床数据 JSONB 字段) -- [ ] 设计 `EkbChunk` 表(向量切片,含 pgvector 字段) -- [ ] 编写 Prisma schema -- [ ] 运行 `prisma migrate dev` -- [ ] 创建 HNSW 索引(手动 SQL) -- [ ] 验证向量插入和查询 - -**交付物**: -- `prisma/schema.prisma` 更新 -- `migrations/xxx_add_ekb_tables.sql` -- 索引创建脚本 - -**预计工时**:4-6 小时 - -**关键代码**: - -```prisma -// schema.prisma - -model EkbDocument { - id String @id @default(uuid()) - kbId String - userId String - - // 基础信息 - filename String - fileType String - fileSizeBytes BigInt - fileUrl String // 原始 PDF 的 OSS 地址 - extractedText String? @db.Text // 解析后的 Markdown/文本 - - // 临床数据(JSONB) - pico Json? - studyDesign Json? - regimen Json? - safety Json? - criteria Json? - endpoints Json? - - // 状态 - status String @default("pending") - errorMessage String? @db.Text - - chunks EkbChunk[] - knowledgeBase KnowledgeBase @relation(fields: [kbId], references: [id], onDelete: Cascade) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([kbId]) - @@index([status]) - @@schema("pkb_schema") -} - -model EkbChunk { - id String @id @default(uuid()) - documentId String - - content String @db.Text - pageNumber Int? - sectionType String? - - // pgvector 字段(需要手动创建) - embedding Unsupported("vector(1024)")? - - document EkbDocument @relation(fields: [documentId], references: [id], onDelete: Cascade) - - createdAt DateTime @default(now()) - - @@index([documentId]) - @@schema("pkb_schema") -} -``` - -**手动 SQL(创建索引)**: - -```sql --- 创建 HNSW 索引 -CREATE INDEX IF NOT EXISTS ekb_chunk_embedding_idx -ON "pkb_schema"."EkbChunk" -USING hnsw (embedding vector_cosine_ops) -WITH (m = 16, ef_construction = 64); - --- 创建全文检索索引 -CREATE INDEX IF NOT EXISTS ekb_chunk_content_idx -ON "pkb_schema"."EkbChunk" -USING gin (to_tsvector('simple', content)); - --- 创建 JSONB GIN 索引(用于临床数据查询) -CREATE INDEX IF NOT EXISTS ekb_document_pico_idx -ON "pkb_schema"."EkbDocument" -USING gin (pico); - -CREATE INDEX IF NOT EXISTS ekb_document_safety_idx -ON "pkb_schema"."EkbDocument" -USING gin (safety); -``` +- [知识库引擎架构设计](../../../02-通用能力层/03-RAG引擎/01-知识库引擎架构设计.md) +- [pgvector 替换 Dify 开发计划](../../../02-通用能力层/03-RAG引擎/02-pgvector替换Dify计划.md) +- [通用能力层 - RAG 引擎 README](../../../02-通用能力层/03-RAG引擎/README.md) --- -### Day 2:Embedding 服务 + 切片服务 - -**目标**:实现文本向量化和文档切片 - -**任务清单**: -- [ ] 创建 `EmbeddingService.ts`(阿里云 text-embedding-v3) -- [ ] 创建 `ChunkService.ts`(RecursiveCharacterTextSplitter) -- [ ] 单元测试:Embedding API 调用 -- [ ] 单元测试:切片效果验证 -- [ ] 环境变量配置(DASHSCOPE_API_KEY) - -**交付物**: -- `backend/src/common/rag/EmbeddingService.ts` -- `backend/src/common/rag/ChunkService.ts` -- 单元测试文件 - -**预计工时**:4-6 小时 - -**关键代码**: - -```typescript -// EmbeddingService.ts -export class EmbeddingService { - private apiKey: string; - private baseUrl = 'https://dashscope.aliyuncs.com/api/v1/services/embeddings/text-embedding/text-embedding'; - - async embed(text: string): Promise { ... } - async embedBatch(texts: string[]): Promise { ... } - async embedQuery(query: string): Promise { ... } -} - -// ChunkService.ts -export class ChunkService { - splitDocument( - text: string, - options: { chunkSize: number; chunkOverlap: number } - ): Chunk[] { ... } - - detectSections(text: string): Section[] { ... } -} -``` - ---- - -### Day 3:全要素提取服务 - -**目标**:实现 PICO、用药方案等临床数据的 AI 提取 - -**任务清单**: -- [ ] 创建 `ClinicalExtractionService.ts` -- [ ] 设计提取 Prompt(参考 EKB 方案) -- [ ] 实现三层 JSON 解析容错 -- [ ] 测试不同类型文献(RCT、综述、病例) -- [ ] Prompt 调优(提高提取准确率) - -**交付物**: -- `backend/src/modules/pkb/services/ClinicalExtractionService.ts` -- `backend/prompts/clinical_extraction.txt` -- 测试用例 - -**预计工时**:6-8 小时(含 Prompt 调优) - -**关键 Prompt**: - -``` -你是一个医学数据专家。请阅读这篇文献,严格按照以下 JSON 格式提取关键信息。 -如果文中未提及,字段留空(null)。 - -提取字段: -1. pico: { "P": "患者人群", "I": "干预措施", "C": "对照", "O": "结局指标" } -2. studyDesign: { "design": "研究类型", "sampleSize": 数字, "blinding": "盲法" } -3. regimen: [{ "drug": "药物名", "dose": "剂量", "frequency": "频率" }] -4. safety: { "ae_all": ["不良反应列表"], "ae_grade34": ["严重不良反应"] } -5. criteria: { "inclusion": ["纳入标准"], "exclusion": ["排除标准"] } -6. endpoints: { "primary": ["主要终点"], "secondary": ["次要终点"] } - -输出必须是纯 JSON,不要有任何前言或后缀。 - ---- -文献内容: -{{fullText}} -``` - ---- - -### Day 4:向量检索服务(pgvector SQL) - -**目标**:实现基于 pgvector 的向量检索 - -**任务清单**: -- [ ] 创建 `VectorSearchService.ts` -- [ ] 实现向量检索(余弦相似度) -- [ ] 实现关键词检索(PostgreSQL FTS) -- [ ] 测试检索性能(1000+ 向量) -- [ ] 优化查询(索引使用验证) - -**交付物**: -- `backend/src/common/rag/VectorSearchService.ts` -- 性能测试报告 - -**预计工时**:6 小时 - -**关键 SQL**: - -```sql --- 向量检索 -SELECT - c.id, - c.content, - d.filename, - 1 - (c.embedding <=> $1::vector) as score -FROM "pkb_schema"."EkbChunk" c -JOIN "pkb_schema"."EkbDocument" d ON c."documentId" = d.id -WHERE d."kbId" = $2 -ORDER BY c.embedding <=> $1::vector -LIMIT $3; - --- 关键词检索 -SELECT - c.id, - c.content, - d.filename, - ts_rank_cd(to_tsvector('simple', c.content), plainto_tsquery('simple', $1)) as score -FROM "pkb_schema"."EkbChunk" c -JOIN "pkb_schema"."EkbDocument" d ON c."documentId" = d.id -WHERE d."kbId" = $2 - AND to_tsvector('simple', c.content) @@ plainto_tsquery('simple', $1) -ORDER BY score DESC -LIMIT $3; -``` - ---- - -### Day 5:混合检索 + RRF 融合 - -**目标**:实现 R-C-R-G 架构中的混合检索 - -**任务清单**: -- [ ] 实现 RRF(Reciprocal Rank Fusion)算法 -- [ ] 实现并发三路检索(向量 + 关键词 + SQL 筛选) -- [ ] 集成 Rerank API(可选,qwen-rerank) -- [ ] 效果评估(对比 Dify 检索) -- [ ] 参数调优(k 值、权重) - -**交付物**: -- RRF 融合模块 -- 效果评估报告 - -**预计工时**:6-8 小时 - -**RRF 算法**: - -```typescript -function rrfFusion( - vectorResults: Result[], - keywordResults: Result[], - k: number = 60 // RRF 常数,通常取 60 -): Result[] { - const scoreMap = new Map(); - - // 计算 RRF 分数 - vectorResults.forEach((r, idx) => { - const rrfScore = 1 / (k + idx + 1); - scoreMap.set(r.id, (scoreMap.get(r.id) || 0) + rrfScore); - }); - - keywordResults.forEach((r, idx) => { - const rrfScore = 1 / (k + idx + 1); - scoreMap.set(r.id, (scoreMap.get(r.id) || 0) + rrfScore); - }); - - // 排序返回 - return Array.from(scoreMap.entries()) - .sort((a, b) => b[1] - a[1]) - .map(([id, score]) => ({ id, score })); -} -``` - ---- - -### Day 6:修改 knowledgeBaseService(检索替换) - -**目标**:替换 Dify 检索为 pgvector 检索 - -**任务清单**: -- [ ] 修改 `searchKnowledgeBase()` 函数 -- [ ] 移除 `difyClient.retrieveKnowledge()` 调用 -- [ ] 使用 `vectorSearchService.hybridSearch()` -- [ ] 保持返回格式兼容(前端零修改) -- [ ] 单元测试 - -**交付物**: -- 更新后的 `knowledgeBaseService.ts` - -**预计工时**:4 小时 - -**关键修改**: - -```typescript -// 修改前 -const results = await difyClient.retrieveKnowledge( - knowledgeBase.difyDatasetId, - query, - { retrieval_model: { search_method: 'semantic_search', top_k: topK } } -); - -// 修改后 -const searchResults = await vectorSearchService.hybridSearch(kbId, query, topK); - -// 格式转换(保持兼容) -return { - query: { content: query }, - records: searchResults.map((r, idx) => ({ - segment_id: r.id, - document_id: r.documentId, - document_name: r.documentName, - position: idx + 1, - score: r.score, - content: r.content, - })), -}; -``` - ---- - -### Day 7:修改 documentService(上传替换) - -**目标**:替换 Dify 上传流程为本地向量化流程 - -**任务清单**: -- [ ] 修改 `uploadDocument()` 函数 -- [ ] 移除 `difyClient.uploadDocumentDirectly()` 调用 -- [ ] 实现本地处理流程(提取 → 切片 → 向量化) -- [ ] 移除 Dify 状态轮询逻辑 -- [ ] 实现自己的异步处理和状态更新 -- [ ] 单元测试 - -**交付物**: -- 更新后的 `documentService.ts` - -**预计工时**:6 小时 - ---- - -### Day 8:集成测试 + 效果调优 - -**目标**:端到端测试,确保功能正常 - -**任务清单**: -- [ ] 前端测试:创建知识库 -- [ ] 前端测试:上传文档 -- [ ] 前端测试:RAG 检索问答 -- [ ] 效果对比:Dify vs pgvector 检索质量 -- [ ] 性能测试:检索延迟 -- [ ] Bug 修复 - -**交付物**: -- 测试报告 -- Bug 修复记录 - -**预计工时**:8 小时 - ---- - -### Day 9:数据迁移(现有文档向量化) - -**目标**:将现有知识库文档迁移到新表并向量化 - -**任务清单**: -- [ ] 编写迁移脚本(Document → EkbDocument) -- [ ] 批量向量化现有文档 -- [ ] 验证迁移完整性 -- [ ] 验证检索效果 - -**交付物**: -- `scripts/migrate-to-ekb.ts` -- 迁移日志 - -**预计工时**:6 小时 - -**迁移脚本**: - -```typescript -// scripts/migrate-to-ekb.ts -async function migrateDocuments() { - // 1. 获取所有现有文档 - const documents = await prisma.document.findMany({ - where: { status: 'completed', extractedText: { not: null } }, - }); - - console.log(`Found ${documents.length} documents to migrate`); - - // 2. 逐个迁移 - for (const doc of documents) { - try { - // 创建 EkbDocument - const ekbDoc = await prisma.ekbDocument.create({ - data: { - kbId: doc.kbId, - userId: doc.userId, - filename: doc.filename, - fileType: doc.fileType, - fileSizeBytes: doc.fileSizeBytes, - extractedText: doc.extractedText, - status: 'embedding', - }, - }); - - // 切片 - const chunks = chunkService.splitDocument(doc.extractedText!); - - // 向量化 - const embeddings = await embeddingService.embedBatch( - chunks.map(c => c.content) - ); - - // 存入数据库 - // ... - - console.log(`✅ Migrated: ${doc.filename}`); - } catch (error) { - console.error(`❌ Failed: ${doc.filename}`, error); - } - } -} -``` - ---- - -### Day 10:清理 + 文档 + 上线 - -**目标**:清理遗留代码,更新文档,正式上线 - -**任务清单**: -- [ ] 删除 `DifyClient.ts` -- [ ] 删除 `difyDatasetId` 字段(可选,下个版本) -- [ ] 删除 `difyDocumentId` 字段(可选,下个版本) -- [ ] 更新 `00-模块当前状态与开发指南.md` -- [ ] 更新环境变量文档 -- [ ] 代码 Review -- [ ] 合并到主分支 - -**交付物**: -- 更新后的文档 -- 清理后的代码 - -**预计工时**:4 小时 - ---- - -## ⚠️ 风险评估与应对 - -### 风险矩阵 - -| 风险 | 概率 | 影响 | 等级 | 应对措施 | -|------|------|------|------|----------| -| 检索效果下降 | 中 | 高 | 🔴 | 效果评估 + 参数调优 + 回滚方案 | -| API 兼容性问题 | 低 | 高 | 🟡 | 格式转换层 + 充分测试 | -| Embedding API 限流 | 中 | 中 | 🟡 | 并发控制 + 重试机制 | -| 迁移数据丢失 | 低 | 高 | 🟡 | 备份 + 验证 + 回滚 | -| 性能下降 | 低 | 中 | 🟢 | 索引优化 + 缓存 | - -### 回滚方案 - -如果新方案效果不理想,可以: -1. 保留 `difyDatasetId` 字段,随时切回 Dify -2. 新旧服务通过 Feature Flag 切换 -3. 灰度发布:先 10% 用户使用 pgvector - ---- - -## 📊 资源需求 - -### 人力资源 - -| 角色 | 工作量 | 说明 | -|------|--------|------| -| 后端开发 | 10 人天 | 核心开发 | -| 测试 | 2 人天 | 集成测试 + 效果评估 | -| **总计** | **12 人天** | 约 2 周 | - -### 技术资源 - -| 资源 | 用途 | 成本 | -|------|------|------| -| 阿里云 DashScope | Embedding API | ~¥50/月 | -| 阿里云 DashScope | Rerank API(可选) | ~¥20/月 | -| PostgreSQL | 已有 | ¥0 | - ---- - -## ✅ 验收标准 - -### 功能验收 - -- [ ] 创建知识库:不依赖 Dify,直接创建 -- [ ] 上传文档:本地处理 + 向量化 -- [ ] RAG 检索:混合检索效果 ≥ Dify -- [ ] 全文阅读模式:正常工作 -- [ ] 批处理模式:正常工作 - -### 性能验收 - -- [ ] 检索延迟:< 500ms(95 分位) -- [ ] 上传处理:< 60s/文档(平均) -- [ ] 向量化吞吐:> 100 文档/小时 - -### 质量验收 - -- [ ] 检索召回率:≥ 80%(测试集) -- [ ] 无 Dify 相关代码残留 -- [ ] 文档更新完整 - ---- - -## 📝 附录 - -### A. 相关文档 - -- [企业级医学知识库综合技术解决方案 V2](../00-系统设计/企业级医学知识库_综合技术解决方案%20V2.md) -- [PostgreSQL与pgvector深度应用分析](../00-系统设计/医疗科研AI系统架构评估报告:PostgreSQL与pgvector在RAG及知识库中的深度应用分析.md) -- [PKB模块当前状态](../00-模块当前状态与开发指南.md) - -### B. 环境变量配置 - -```bash -# .env 新增 -DASHSCOPE_API_KEY=sk-xxx # 阿里云 DashScope API Key -EMBEDDING_MODEL=text-embedding-v3 # Embedding 模型 -EMBEDDING_DIMENSION=1024 # 向量维度 -RERANK_MODEL=gte-rerank # Rerank 模型(可选) -``` - -### C. 依赖更新 - -```json -// package.json -{ - "dependencies": { - // 新增 - "langchain": "^0.1.0", // 可选,用于切片 - "p-queue": "^8.0.0" // 并发控制 - } -} -``` - ---- - -**文档维护**:PKB 模块开发团队 -**最后更新**:2026-01-19 -**下次更新**:开发完成后更新进度 - +**请访问新文档位置获取最新内容。** diff --git a/docs/03-业务模块/PKB-个人知识库/06-开发记录/2026-01-07-前端迁移与批处理功能完善.md b/docs/03-业务模块/PKB-个人知识库/06-开发记录/2026-01-07-前端迁移与批处理功能完善.md index e4223c50..5be89322 100644 --- a/docs/03-业务模块/PKB-个人知识库/06-开发记录/2026-01-07-前端迁移与批处理功能完善.md +++ b/docs/03-业务模块/PKB-个人知识库/06-开发记录/2026-01-07-前端迁移与批处理功能完善.md @@ -369,3 +369,6 @@ const newResults = resultsData.map((docResult: any) => ({ + + + diff --git a/docs/03-业务模块/PKB-个人知识库/06-开发记录/2026-01-07_PKB模块前端V3设计实现.md b/docs/03-业务模块/PKB-个人知识库/06-开发记录/2026-01-07_PKB模块前端V3设计实现.md index 81122d58..33316756 100644 --- a/docs/03-业务模块/PKB-个人知识库/06-开发记录/2026-01-07_PKB模块前端V3设计实现.md +++ b/docs/03-业务模块/PKB-个人知识库/06-开发记录/2026-01-07_PKB模块前端V3设计实现.md @@ -242,3 +242,6 @@ const chatApi = axios.create({ + + + diff --git a/docs/03-业务模块/Redcap/01-部署与配置/10-REDCap_Docker部署操作手册.md b/docs/03-业务模块/Redcap/01-部署与配置/10-REDCap_Docker部署操作手册.md index ebd13638..fc7c5975 100644 --- a/docs/03-业务模块/Redcap/01-部署与配置/10-REDCap_Docker部署操作手册.md +++ b/docs/03-业务模块/Redcap/01-部署与配置/10-REDCap_Docker部署操作手册.md @@ -775,6 +775,9 @@ docker exec redcap-apache php /tmp/create-redcap-password.php + + + diff --git a/docs/03-业务模块/Redcap/README.md b/docs/03-业务模块/Redcap/README.md index 72dfcf4c..f067149d 100644 --- a/docs/03-业务模块/Redcap/README.md +++ b/docs/03-业务模块/Redcap/README.md @@ -157,6 +157,9 @@ AIclinicalresearch/redcap-docker-dev/ + + + diff --git a/docs/04-开发规范/09-数据库开发规范.md b/docs/04-开发规范/09-数据库开发规范.md index 3d727ce8..aef3254c 100644 --- a/docs/04-开发规范/09-数据库开发规范.md +++ b/docs/04-开发规范/09-数据库开发规范.md @@ -327,3 +327,6 @@ npx tsx check_iit_asl_data.ts + + + diff --git a/docs/04-开发规范/10-模块认证规范.md b/docs/04-开发规范/10-模块认证规范.md index 15a20ba3..a7eb1cec 100644 --- a/docs/04-开发规范/10-模块认证规范.md +++ b/docs/04-开发规范/10-模块认证规范.md @@ -195,3 +195,6 @@ interface DecodedToken { + + + diff --git a/docs/05-部署文档/02-SAE部署完全指南(产品经理版).md b/docs/05-部署文档/02-SAE部署完全指南(产品经理版).md index 3fabf52b..cac0fbbd 100644 --- a/docs/05-部署文档/02-SAE部署完全指南(产品经理版).md +++ b/docs/05-部署文档/02-SAE部署完全指南(产品经理版).md @@ -894,6 +894,9 @@ ACR镜像仓库: + + + diff --git a/docs/05-部署文档/07-前端Nginx-SAE部署操作手册.md b/docs/05-部署文档/07-前端Nginx-SAE部署操作手册.md index 614cee08..0c5ef2d7 100644 --- a/docs/05-部署文档/07-前端Nginx-SAE部署操作手册.md +++ b/docs/05-部署文档/07-前端Nginx-SAE部署操作手册.md @@ -1381,6 +1381,9 @@ SAE应用配置: + + + diff --git a/docs/05-部署文档/08-PostgreSQL数据库部署操作手册.md b/docs/05-部署文档/08-PostgreSQL数据库部署操作手册.md index fc80ceb5..e426f024 100644 --- a/docs/05-部署文档/08-PostgreSQL数据库部署操作手册.md +++ b/docs/05-部署文档/08-PostgreSQL数据库部署操作手册.md @@ -1197,6 +1197,9 @@ docker exec -e PGPASSWORD="密码" ai-clinical-postgres psql -h RDS地址 -U air + + + diff --git a/docs/05-部署文档/10-Node.js后端-Docker镜像构建手册.md b/docs/05-部署文档/10-Node.js后端-Docker镜像构建手册.md index bee11446..2cb8f1f7 100644 --- a/docs/05-部署文档/10-Node.js后端-Docker镜像构建手册.md +++ b/docs/05-部署文档/10-Node.js后端-Docker镜像构建手册.md @@ -608,6 +608,9 @@ scripts/*.ts + + + diff --git a/docs/05-部署文档/11-Node.js后端-SAE部署配置清单.md b/docs/05-部署文档/11-Node.js后端-SAE部署配置清单.md index 6129e584..0be445b5 100644 --- a/docs/05-部署文档/11-Node.js后端-SAE部署配置清单.md +++ b/docs/05-部署文档/11-Node.js后端-SAE部署配置清单.md @@ -296,6 +296,9 @@ Node.js后端部署成功后: + + + diff --git a/docs/05-部署文档/12-Node.js后端-SAE部署操作手册.md b/docs/05-部署文档/12-Node.js后端-SAE部署操作手册.md index 3e602cdc..72ab5f2d 100644 --- a/docs/05-部署文档/12-Node.js后端-SAE部署操作手册.md +++ b/docs/05-部署文档/12-Node.js后端-SAE部署操作手册.md @@ -519,6 +519,9 @@ Node.js后端 (SAE) ← http://172.17.173.88:3001 + + + diff --git a/docs/05-部署文档/13-Node.js后端-镜像修复记录.md b/docs/05-部署文档/13-Node.js后端-镜像修复记录.md index b0e39b35..f9e403f4 100644 --- a/docs/05-部署文档/13-Node.js后端-镜像修复记录.md +++ b/docs/05-部署文档/13-Node.js后端-镜像修复记录.md @@ -234,6 +234,9 @@ curl http://localhost:3001/health + + + diff --git a/docs/05-部署文档/14-Node.js后端-pino-pretty问题修复.md b/docs/05-部署文档/14-Node.js后端-pino-pretty问题修复.md index bfaf1b86..862cbd07 100644 --- a/docs/05-部署文档/14-Node.js后端-pino-pretty问题修复.md +++ b/docs/05-部署文档/14-Node.js后端-pino-pretty问题修复.md @@ -272,6 +272,9 @@ npm run dev + + + diff --git a/docs/05-部署文档/16-前端Nginx-部署成功总结.md b/docs/05-部署文档/16-前端Nginx-部署成功总结.md index e1abf8e0..ae59c64c 100644 --- a/docs/05-部署文档/16-前端Nginx-部署成功总结.md +++ b/docs/05-部署文档/16-前端Nginx-部署成功总结.md @@ -496,6 +496,9 @@ pgm-2zex1m2y3r23hdn5.pg.rds.aliyuncs.com:5432 + + + diff --git a/docs/05-部署文档/17-完整部署实战手册-2025版.md b/docs/05-部署文档/17-完整部署实战手册-2025版.md index 69819613..b7b1d9db 100644 --- a/docs/05-部署文档/17-完整部署实战手册-2025版.md +++ b/docs/05-部署文档/17-完整部署实战手册-2025版.md @@ -1824,6 +1824,9 @@ curl http://8.140.53.236/ + + + diff --git a/docs/05-部署文档/18-部署文档使用指南.md b/docs/05-部署文档/18-部署文档使用指南.md index 40a3570f..8868ccd8 100644 --- a/docs/05-部署文档/18-部署文档使用指南.md +++ b/docs/05-部署文档/18-部署文档使用指南.md @@ -372,6 +372,9 @@ crpi-cd5ij4pjt65mweeo.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/backend-se + + + diff --git a/docs/05-部署文档/19-日常更新快速操作手册.md b/docs/05-部署文档/19-日常更新快速操作手册.md index 15727197..40e1c846 100644 --- a/docs/05-部署文档/19-日常更新快速操作手册.md +++ b/docs/05-部署文档/19-日常更新快速操作手册.md @@ -694,6 +694,9 @@ docker login --username=gofeng117@163.com \ + + + diff --git a/docs/05-部署文档/文档修正报告-20251214.md b/docs/05-部署文档/文档修正报告-20251214.md index 07c34ca5..6f666786 100644 --- a/docs/05-部署文档/文档修正报告-20251214.md +++ b/docs/05-部署文档/文档修正报告-20251214.md @@ -505,6 +505,9 @@ NAT网关成本¥100/月,对初创团队是一笔开销 + + + diff --git a/docs/07-运维文档/03-SAE环境变量配置指南.md b/docs/07-运维文档/03-SAE环境变量配置指南.md index d4641b44..c66498e7 100644 --- a/docs/07-运维文档/03-SAE环境变量配置指南.md +++ b/docs/07-运维文档/03-SAE环境变量配置指南.md @@ -410,6 +410,9 @@ curl http://你的SAE地址:3001/health + + + diff --git a/docs/07-运维文档/05-Redis缓存与队列的区别说明.md b/docs/07-运维文档/05-Redis缓存与队列的区别说明.md index 7ad87b66..8c806e01 100644 --- a/docs/07-运维文档/05-Redis缓存与队列的区别说明.md +++ b/docs/07-运维文档/05-Redis缓存与队列的区别说明.md @@ -742,6 +742,9 @@ const job = await queue.getJob(jobId); + + + diff --git a/docs/07-运维文档/06-长时间任务可靠性分析.md b/docs/07-运维文档/06-长时间任务可靠性分析.md index c73d7400..3debb985 100644 --- a/docs/07-运维文档/06-长时间任务可靠性分析.md +++ b/docs/07-运维文档/06-长时间任务可靠性分析.md @@ -509,6 +509,9 @@ processLiteraturesInBackground(task.id, projectId, testLiteratures); + + + diff --git a/docs/07-运维文档/07-Redis使用需求分析(按模块).md b/docs/07-运维文档/07-Redis使用需求分析(按模块).md index 22b93aba..94093820 100644 --- a/docs/07-运维文档/07-Redis使用需求分析(按模块).md +++ b/docs/07-运维文档/07-Redis使用需求分析(按模块).md @@ -986,6 +986,9 @@ ROI = (¥22,556 - ¥144) / ¥144 × 100% = 15,564% + + + diff --git a/docs/08-项目管理/01-文档处理引擎设计方案_v1.2.md b/docs/08-项目管理/01-文档处理引擎设计方案_v1.2.md new file mode 100644 index 00000000..04d9b788 --- /dev/null +++ b/docs/08-项目管理/01-文档处理引擎设计方案_v1.2.md @@ -0,0 +1,185 @@ +# **文档处理引擎设计方案** + +文档版本: v1.2 (极简版) +更新日期: 2026-01-20 +核心变更: 移除 PaddleOCR,追求极致轻量化 +适用范围: PKB 知识库、ASL 智能文献、DC 数据清洗 + +## **📋 概述** + +### **设计目标** + +构建一个 "极轻量、零OCR、LLM 友好" 的文档解析微服务。 +核心原则:只处理可编辑文档(电子版),放弃扫描件支持,换取极致的部署速度和低资源占用。 + +构建一个 "容错性强、LLM 友好" 的文档解析微服务。对于 2 人团队,核心原则是:抓大放小,确保 PDF/Word/Excel 的绝对准确,放弃冷门格式。 + +### **架构概览 (Pipeline)** + +graph LR + Input\[文档输入\] \--\> Router{格式路由} + + Router \--\>|PDF| pymupdf4llm\[pymupdf4llm\] + pymupdf4llm \--\>|成功| MD\_Out + pymupdf4llm \--\>|文本过少| Error\[报错:不支持扫描件\] + + Router \--\>|Word| Mammoth\[Mammoth\] + Router \--\>|PPT| Pptx\[Python-pptx\] + Router \--\>|Excel/CSV| Pandas\[Pandas \+ Context\] + + Mammoth \--\> MD\_Out + Pptx \--\> MD\_Out + Pandas \--\> MD\_Out\[Markdown 输出\] + +## **🔧 核心实现方案** + +### **1\. PDF 文档处理 (极简版)** + +策略:只用 pymupdf4llm。 +逻辑:尝试解析 \-\> 如果字数太少 \-\> 抛出异常(告诉前端提示用户上传电子版)。 + +#### **代码实现 (pdf\_processor.py)** + +import pymupdf4llm +import logging + +logger \= logging.getLogger(\_\_name\_\_) + +class PdfProcessor: + def to\_markdown(self, pdf\_path: str) \-\> str: + """ + PDF 转 Markdown (仅支持电子版) + """ + try: + \# 1\. 尝试快速解析 (保留表格结构) + md\_text \= pymupdf4llm.to\_markdown(pdf\_path, show\_progress=False) + + \# 2\. 质量检查:如果提取内容极少(\<50字符),视为扫描件 + if len(md\_text.strip()) \< 50: + msg \= f"解析失败:提取文本过少({len(md\_text)}字符)。可能为扫描版PDF,本系统暂不支持。" + logger.warning(msg) + \# 选择策略:是返回空字符串让流程继续,还是报错? + \# 建议:返回一段提示文本,让 LLM 知道这个文件没读出来 + return "\> \*\*系统提示\*\*:此文档似乎是扫描件(图片),无法提取文本内容。" + + return md\_text + + except Exception as e: + logger.error(f"pymupdf4llm failed: {e}") + raise ValueError(f"PDF解析失败: {str(e)}") + +### **2\. Word 文档处理** + +**策略**:mammoth。轻量、快速、HTML/Markdown 转换效果好。 + +#### **代码实现 (docx\_processor.py)** + +import mammoth + +class DocxProcessor: + def to\_markdown(self, docx\_path: str) \-\> str: + with open(docx\_path, "rb") as f: + result \= mammoth.convert\_to\_markdown(f) + + if not result.value.strip(): + return "\> \*\*系统提示\*\*:Word文档内容为空或无法识别。" + + return result.value + +### **3\. Excel/CSV 处理** + +**策略**:pandas。加上文件名上下文。 + +#### **代码实现 (excel\_processor.py)** + +import pandas as pd +import os + +class ExcelProcessor: + def to\_markdown(self, file\_path: str, max\_rows: int \= 200\) \-\> str: + """Excel/CSV 转 Markdown""" + ext \= os.path.splitext(file\_path)\[1\].lower() + filename \= os.path.basename(file\_path) + md\_output \= \[\] + + try: + if ext \== '.csv': + dfs \= {'Sheet1': pd.read\_csv(file\_path)} + else: + dfs \= pd.read\_excel(file\_path, sheet\_name=None) + + for sheet\_name, df in dfs.items(): + md\_output.append(f"\#\# 数据来源: {filename} \- {sheet\_name}") + md\_output.append(f"- \*\*行列\*\*: {len(df)}行 x {len(df.columns)}列") + + if len(df) \> max\_rows: + md\_output.append(f"\> (仅显示前 {max\_rows} 行)") + df \= df.head(max\_rows) + + df \= df.fillna('') + md\_output.append(df.to\_markdown(index=False)) + md\_output.append("\\n---\\n") + + return "\\n".join(md\_output) + + except Exception as e: + return f"Error processing Excel: {str(e)}" + +## **🏗️ 统一入口 (document\_processor.py)** + +import os +from .pdf\_processor import PdfProcessor +from .docx\_processor import DocxProcessor +from .excel\_processor import ExcelProcessor +from .pptx\_processor import PptxProcessor + +class DocumentProcessor: + def \_\_init\_\_(self): + self.pdf \= PdfProcessor() + self.docx \= DocxProcessor() + self.excel \= ExcelProcessor() + self.pptx \= PptxProcessor() + + def process(self, file\_path: str) \-\> str: + ext \= os.path.splitext(file\_path)\[1\].lower() + + if ext \== '.pdf': + return self.pdf.to\_markdown(file\_path) + elif ext in \['.docx', '.doc'\]: + return self.docx.to\_markdown(file\_path) + elif ext in \['.xlsx', '.xls', '.csv'\]: + return self.excel.to\_markdown(file\_path) + elif ext \== '.pptx': + return self.pptx.to\_markdown(file\_path) + elif ext in \['.txt', '.md'\]: + with open(file\_path, 'r', encoding='utf-8', errors='ignore') as f: + return f.read() + else: + return f"Unsupported file format: {ext}" + +## **📦 极简依赖清单 (requirements.txt)** + +**体积预估**: + +* 整个 Docker 镜像压缩后可能只有 **200MB \- 300MB**。 +* 相比带 PaddleOCR 的版本(1.5GB+),缩小了 5 倍以上。 + +\# 核心解析库 +pymupdf4llm\>=0.0.17 +mammoth\>=1.8.0 +python-pptx\>=1.0.2 +pandas\>=2.2.0 +openpyxl\>=3.1.5 +tabulate\>=0.9.0 + +\# 基础工具 +chardet\>=5.2.0 +fastapi\>=0.109.0 +uvicorn\>=0.27.0 +python-multipart\>=0.0.9 + +## **🚀 部署建议** + +1. **Docker 基础镜像**:可以使用 python:3.11-slim,非常小。 +2. **资源限制**:这个服务甚至可以在 **0.5核 CPU / 512MB 内存** 的微型容器里跑起来。 +3. **用户引导**:在前端上传界面加一行小字:“目前仅支持电子版 PDF,暂不支持扫描件或图片”。这比在后端搞复杂的 OCR 性价比高得多。 \ No newline at end of file diff --git a/docs/08-项目管理/01-知识库引擎架构设计_v1.2.md b/docs/08-项目管理/01-知识库引擎架构设计_v1.2.md new file mode 100644 index 00000000..d97c7783 --- /dev/null +++ b/docs/08-项目管理/01-知识库引擎架构设计_v1.2.md @@ -0,0 +1,120 @@ +# **知识库引擎架构设计** + +文档版本: v1.2 (架构审核优化版) +创建日期: 2026-01-20 +最后更新: 2026-01-20 +核心变更: 强调异步入库、中文检索方案、成本控制策略 +能力定位: 通用能力层 + +## **📋 概述** + +### **能力定位** + +知识库引擎是平台的**核心通用能力**,提供知识库相关的**基础能力(乐高积木)**,供业务模块根据场景自由组合。 + +### **⭐ 核心设计原则** + +┌─────────────────────────────────────────────────────────────┐ +│ │ +│ ✅ 提供基础能力(乐高积木) │ +│ ❌ 不做策略选择(组装方案由业务模块决定) │ +│ ⚡️ 入库必须异步(防止超时) │ +│ 💰 提取按需开启(控制成本) │ +│ │ +└─────────────────────────────────────────────────────────────┘ + +## **🎯 基础能力清单 (API Definition)** + +### **1\. 文档入库 (异步核心)** + +/\*\* + \* 文档入库任务提交 + \* @returns taskId \- 用于轮询进度 + \*/ +async function submitIngestTask(params: { + kbId: string; + file: Buffer; + options?: { + // 💰 成本控制开关 + enableSummary?: boolean; // 是否生成摘要 (DeepSeek) + enableClinicalExtraction?: boolean; // 是否提取PICO (DeepSeek) + chunkSize?: number; // 切片大小 + } +}): Promise\<{ taskId: string }\>; + +/\*\* + \* 获取任务状态 + \*/ +async function getIngestStatus(taskId: string): Promise\<{ + status: 'pending' | 'processing' | 'completed' | 'failed'; + progress: number; // 0-100 + error?: string; +}\>; + +### **2\. 内容获取 (数据积木)** + +| 方法 | 说明 | 典型场景 | +| :---- | :---- | :---- | +| getDocumentFullText(id) | 获取 Markdown 全文 | 少量文档精读 (PKB) | +| getDocumentSummary(id) | 获取 AI 生成的摘要 | 快速筛选 (AIA) | +| getClinicalData(id) | 获取 PICO/JSON 结构化数据 | 药物评价 (ASL) | + +### **3\. 检索能力 (搜索积木)** + +| 方法 | 说明 | 技术实现 | +| :---- | :---- | :---- | +| vectorSearch(query, k) | 语义检索 | pgvector (HNSW) | +| keywordSearch(query, k) | 关键词检索 | **pg\_trgm (ILIKE)** / tsvector | +| hybridSearch(query, k) | 混合检索 | RRF 融合算法 | +| rerank(docs, query) | **\[新增\]** 重排序 | Qwen-Rerank API | + +## **🏗️ 关键技术决策** + +### **1\. 中文关键词检索方案** + +鉴于 PostgreSQL 默认分词对中文支持不佳,且 RDS 插件管理受限,采用 **pg\_trgm (Trigram)** 方案。 + +* **优势**:对模糊匹配(如 "帕博利珠" 匹配 "帕博利珠单抗")效果极佳,配置简单。 +* **实现**: + \-- 开启插件 + CREATE EXTENSION IF NOT EXISTS pg\_trgm; + \-- 创建索引 + CREATE INDEX trgm\_idx ON "ekb\_schema"."EkbChunk" USING gin (content gin\_trgm\_ops); + \-- 查询 + SELECT \* FROM chunk WHERE content ILIKE '%关键词%'; + +### **2\. 成本控制策略** + +* **默认行为**:ingestDocument 默认**只做** 解析 \+ 切片 \+ 向量化。这是零 LLM 成本的。 +* **高级行为**:只有当 enableClinicalExtraction: true 时,才调用 DeepSeek 进行 PICO 提取。这通常用于 ASL(智能文献)模块,而在 PKB(个人知识库)中可选开启。 + +## **📊 业务模块策略组合 (Updated)** + +### **场景 1:ASL 智能文献筛选 (高精度)** + +* **入库**:开启 enableClinicalExtraction,提取 PICO 和 结果数据。 +* **检索**: + 1. **SQL 粗筛**:WHERE pico-\>\>'P' ILIKE '%肺癌%' + 2. **混合检索**:hybridSearch (Top 50\) + 3. **重排序**:rerank (Top 10\) + 4. **回答**:基于 Top 10 生成。 + +### **场景 2:PKB 个人知识库 (低成本)** + +* **入库**:关闭高级提取,仅做向量化。 +* **检索**: + 1. **混合检索**:hybridSearch (Top 20\) + 2. **回答**:基于 Top 20 生成。 + +## **📅 更新日志** + +### **v1.2 (2026-01-20)** + +* ⚡️ **架构调整**:入库接口改为异步,返回 taskId。 +* 🔧 **技术选型**:关键词检索明确使用 pg\_trgm 方案以支持中文。 +* 💰 **策略优化**:增加 options 开关,默认关闭高成本提取功能。 +* 🆕 **新增接口**:独立暴露 rerank() 能力。 + +### **v1.1 (2026-01-20)** + +* 确立“积木”原则,移除 Chat 方法。 \ No newline at end of file diff --git a/docs/08-项目管理/03-每周计划/2025-12-13-Postgres-Only架构改造完成.md b/docs/08-项目管理/03-每周计划/2025-12-13-Postgres-Only架构改造完成.md index 6b238d0a..e8581536 100644 --- a/docs/08-项目管理/03-每周计划/2025-12-13-Postgres-Only架构改造完成.md +++ b/docs/08-项目管理/03-每周计划/2025-12-13-Postgres-Only架构改造完成.md @@ -1043,6 +1043,9 @@ Redis 实例:¥500/月 + + + diff --git a/docs/08-项目管理/05-技术债务/通用对话服务抽取计划.md b/docs/08-项目管理/05-技术债务/通用对话服务抽取计划.md index 007dbaa6..1103b755 100644 --- a/docs/08-项目管理/05-技术债务/通用对话服务抽取计划.md +++ b/docs/08-项目管理/05-技术债务/通用对话服务抽取计划.md @@ -501,6 +501,9 @@ import { ChatContainer } from '@/shared/components/Chat'; + + + diff --git a/docs/08-项目管理/08-技术方案-跨语言检索优化.md b/docs/08-项目管理/08-技术方案-跨语言检索优化.md new file mode 100644 index 00000000..7b64b182 --- /dev/null +++ b/docs/08-项目管理/08-技术方案-跨语言检索优化.md @@ -0,0 +1,128 @@ +# **08-技术方案-跨语言检索优化** + +状态: 🟢 建议采纳 +日期: 2026-01-20 +问题描述: 中文查询搜英文文献时,因向量空间差异,相似度低于 0.3 导致无结果。 +核心策略: Query Translation (查询翻译) \+ Query Expansion (查询扩展)。 + +## **1\. 问题根因分析** + +| 现象 | 原因 | +| :---- | :---- | +| **同语言检索** | Query(英) 与 Doc(英) 的向量在同一个语义高密度区,相似度通常 \> 0.5。 | +| **跨语言检索** | Query(中) 与 Doc(英) 虽然语义相关,但向量空间存在“对齐损耗”,相似度往往在 0.25 \- 0.35 之间。 | +| **阈值陷阱** | 我们设置的 0.3 阈值对于同语言是合理的过滤噪音线,但对于跨语言则是“高墙”。 | + +## **2\. 解决方案:LLM 查询重写 (Query Rewriting)** + +不要直接拿用户的中文去搜英文库。在检索之前,加一个极轻量的 LLM 步骤,把中文翻译并扩展成英文。 + +### **2.1 流程图** + +graph TD + A\[用户输入: "帕博利珠单抗治疗肺癌的效果"\] \--\> B{包含中文?} + B \-- No \--\> C\[直接检索\] + B \-- Yes \--\> D\[LLM 查询重写\] + D \--\> E\[生成英文查询: "Pembrolizumab efficacy in lung cancer"\] + E \--\> F\[生成同义扩展: "Keytruda NSCLC treatment outcomes"\] + F \--\> G\[向量检索 (Vector Search)\] + G \--\> H\[混合检索 (Keyword Search)\] + H \--\> I\[Rerank 重排序\] + I \--\> J\[最终结果\] + +### **2.2 为什么这个方案最好?** + +1. **解决向量距离问题**:将“中-英”匹配转化为“英-英”匹配,相似度会直接飙升到 0.5 以上,突破 0.3 的阈值。 +2. **激活关键词检索**:你们架构中使用了 pg\_bigm。如果用户搜中文,pg\_bigm 在英文文档里永远匹配不到关键词。只有翻译成英文,关键词检索才能生效! +3. **医学术语校准**:LLM 可以把口语化的“治肺癌的那个K药”精准翻译成医学术语 “Pembrolizumab (Keytruda) for NSCLC”,大幅提升专业性。 + +## **3\. 代码实现指南** + +在 KnowledgeBaseEngine 中增加一个私有方法 rewriteQuery。 + +### **3.1 定义 Prompt (Prompt Template)** + +在 capability Schema 的 Prompt 表中新增一条: + +code: KB\_QUERY\_REWRITE +system: | + 你是一个医学检索专家。用户的查询可能是中文。 + 请将其翻译为精准的英文医学术语,并提供 1-2 个相关的同义扩展查询。 + 只返回 JSON 数组格式,不要废话。 +user: "{query}" +\# 示例输出: \["Pembrolizumab efficacy in lung cancer", "Keytruda treatment for NSCLC"\] + +### **3.2 改造检索逻辑 (TypeScript)** + +// backend/src/common/rag/KnowledgeBaseEngine.ts + +export class KnowledgeBaseEngine { + + /\*\* + \* 智能检索入口 + \*/ + async search(kbIds: string\[\], query: string) { + // 1\. 检测是否包含中文 (简单正则) + const hasChinese \= /\[\\u4e00-\\u9fa5\]/.test(query); + + let searchQueries \= \[query\]; + + // 2\. 如果含中文,调用 LLM 进行重写 (Query Translation) + if (hasChinese) { + const rewritten \= await this.rewriteQueryWithLLM(query); + // 将原中文查询和生成的英文查询合并,既保底又增强 + searchQueries \= \[...searchQueries, ...rewritten\]; + } + + // 3\. 执行并行检索 (对每个 Query 都搜一遍) + const allResults \= await Promise.all( + searchQueries.map(q \=\> this.vectorSearchInternal(kbIds, q)) + ); + + // 4\. 结果去重与合并 (RRF \- Reciprocal Rank Fusion) + const fusedResults \= this.rrfFusion(allResults.flat()); + + // 5\. Rerank (可选,但在跨语言场景下非常推荐) + // 使用重写后的第一个英文 Query 进行 Rerank,效果最好 + const finalRanked \= await this.rerank(fusedResults, searchQueries\[1\] || query); + + return finalRanked; + } + + /\*\* + \* 调用 LLM 进行查询重写 + \*/ + private async rewriteQueryWithLLM(query: string): Promise\ { + // 调用你们现有的 LLM 网关 + // 使用 fast model (如 DeepSeek-V3 或 Qwen-Turbo) 降低延迟 + const response \= await llmService.chat({ + promptCode: 'KB\_QUERY\_REWRITE', + variables: { query } + }); + + try { + return JSON.parse(response.content); + } catch (e) { + console.error("Query rewrite failed", e); + return \[query\]; // 降级策略:失败了就用原词 + } + } +} + +## **4\. 备选方案对比** + +| 方案 | 描述 | 评价 | 适用场景 | +| :---- | :---- | :---- | :---- | +| **方案 A: 调低阈值** | 将阈值从 0.3 降到 0.15。 | ❌ **不推荐**。会导致大量的噪音(False Positives),搜出完全不相关的东西。 | 仅做 MVP 快速演示 | +| **方案 B: 翻译插件** | 接入百度/Google 翻译 API。 | 😐 **一般**。通用翻译不懂医学术语(比如把 "K药" 翻译成 "K Drug" 而不是 "Keytruda")。 | 通用领域 | +| **方案 C: LLM 重写** | **(推荐)** LLM 翻译 \+ 扩展。 | ✅ **最佳**。懂医学,且解决了关键词匹配问题。 | **医学/专业领域** | + +## **5\. 实施建议** + +1. **不要在前端做**:让后端处理,前端只管发用户的原始输入。 +2. **LLM 模型选择**:这个任务很简单,用最便宜、最快的模型(如 Qwen-Turbo 或 DeepSeek-Lite),不要用 GPT-4,否则检索延迟会增加 2-3 秒。 +3. **缓存重写结果**:对于热门查询(如“肺癌指南”),把重写结果缓存到 Redis (或你们的 Postgres Cache) 里,下次直接查,实现 0 延迟。 + +通过这个方案,你的检索链路就变成了: +中文 Query \-\> (LLM) \-\> 英文 Query \-\> (Vector) \-\> 英文 Doc +这就是标准的\*\*“英-英”\*\*高精度检索,0.3 的阈值完全不是问题。 \ No newline at end of file diff --git a/docs/08-项目管理/2026-01-11-数据库事故总结.md b/docs/08-项目管理/2026-01-11-数据库事故总结.md index ceb22a7b..6899b0d0 100644 --- a/docs/08-项目管理/2026-01-11-数据库事故总结.md +++ b/docs/08-项目管理/2026-01-11-数据库事故总结.md @@ -211,3 +211,6 @@ VALUES ('user-mock-001', '13800000000', ..., 'tenant-mock-001', ...); + + + diff --git a/docs/08-项目管理/PKB前端问题修复报告.md b/docs/08-项目管理/PKB前端问题修复报告.md index 28eb0f24..71e67b50 100644 --- a/docs/08-项目管理/PKB前端问题修复报告.md +++ b/docs/08-项目管理/PKB前端问题修复报告.md @@ -423,3 +423,6 @@ frontend-v2/src/modules/pkb/ + + + diff --git a/docs/08-项目管理/PKB前端验证指南.md b/docs/08-项目管理/PKB前端验证指南.md index 704a6f12..984915e7 100644 --- a/docs/08-项目管理/PKB前端验证指南.md +++ b/docs/08-项目管理/PKB前端验证指南.md @@ -285,3 +285,6 @@ npm run dev + + + diff --git a/docs/08-项目管理/PKB功能审查报告-阶段0.md b/docs/08-项目管理/PKB功能审查报告-阶段0.md index 4e460e53..f2eac5bd 100644 --- a/docs/08-项目管理/PKB功能审查报告-阶段0.md +++ b/docs/08-项目管理/PKB功能审查报告-阶段0.md @@ -800,3 +800,6 @@ AIA智能问答模块 + + + diff --git a/docs/08-项目管理/PKB和RVW功能迁移计划.md b/docs/08-项目管理/PKB和RVW功能迁移计划.md index aa1bd004..8033fafd 100644 --- a/docs/08-项目管理/PKB和RVW功能迁移计划.md +++ b/docs/08-项目管理/PKB和RVW功能迁移计划.md @@ -941,6 +941,9 @@ CREATE INDEX idx_rvw_tasks_created_at ON rvw_schema.review_tasks(created_at); + + + diff --git a/docs/08-项目管理/PKB精细化优化报告.md b/docs/08-项目管理/PKB精细化优化报告.md index 1da5c981..3a1a9216 100644 --- a/docs/08-项目管理/PKB精细化优化报告.md +++ b/docs/08-项目管理/PKB精细化优化报告.md @@ -598,3 +598,6 @@ const typography = { + + + diff --git a/docs/08-项目管理/PKB迁移-超级安全执行计划.md b/docs/08-项目管理/PKB迁移-超级安全执行计划.md index 3388a9a0..18db2719 100644 --- a/docs/08-项目管理/PKB迁移-超级安全执行计划.md +++ b/docs/08-项目管理/PKB迁移-超级安全执行计划.md @@ -910,3 +910,6 @@ app.use('/api/v1/knowledge', (req, res) => { + + + diff --git a/docs/08-项目管理/PKB迁移-阶段1完成报告.md b/docs/08-项目管理/PKB迁移-阶段1完成报告.md index cd56e00e..ed6c9e91 100644 --- a/docs/08-项目管理/PKB迁移-阶段1完成报告.md +++ b/docs/08-项目管理/PKB迁移-阶段1完成报告.md @@ -224,3 +224,6 @@ rm -rf src/modules/pkb + + + diff --git a/docs/08-项目管理/PKB迁移-阶段2完成报告.md b/docs/08-项目管理/PKB迁移-阶段2完成报告.md index 8eb19a42..fadd9123 100644 --- a/docs/08-项目管理/PKB迁移-阶段2完成报告.md +++ b/docs/08-项目管理/PKB迁移-阶段2完成报告.md @@ -399,3 +399,6 @@ GET /api/v2/pkb/batch-tasks/batch/templates + + + diff --git a/docs/08-项目管理/PKB迁移-阶段2进行中.md b/docs/08-项目管理/PKB迁移-阶段2进行中.md index 2c2a6d60..11b59994 100644 --- a/docs/08-项目管理/PKB迁移-阶段2进行中.md +++ b/docs/08-项目管理/PKB迁移-阶段2进行中.md @@ -43,3 +43,6 @@ import pkbRoutes from './modules/pkb/routes/index.js'; + + + diff --git a/docs/08-项目管理/PKB迁移-阶段3完成报告.md b/docs/08-项目管理/PKB迁移-阶段3完成报告.md index 3cb6aefc..775695f4 100644 --- a/docs/08-项目管理/PKB迁移-阶段3完成报告.md +++ b/docs/08-项目管理/PKB迁移-阶段3完成报告.md @@ -312,3 +312,6 @@ backend/ + + + diff --git a/docs/08-项目管理/PKB迁移-阶段4完成报告.md b/docs/08-项目管理/PKB迁移-阶段4完成报告.md index 4cb8544f..2b926a46 100644 --- a/docs/08-项目管理/PKB迁移-阶段4完成报告.md +++ b/docs/08-项目管理/PKB迁移-阶段4完成报告.md @@ -523,3 +523,6 @@ const response = await fetch('/api/v2/pkb/batch-tasks/batch/execute', { + + + diff --git a/docs/09-架构实施/01-知识库引擎架构设计.md b/docs/09-架构实施/01-知识库引擎架构设计.md new file mode 100644 index 00000000..67c2bc55 --- /dev/null +++ b/docs/09-架构实施/01-知识库引擎架构设计.md @@ -0,0 +1,136 @@ +# **知识库引擎架构设计** + +文档版本: v2.0 (架构修正版) +创建日期: 2026-01-20 +最后更新: 2026-01-21 +核心原则: 引擎负责“执行”,业务负责“思考”。 + +## **📋 概述** + +### **能力定位修正** + +**KnowledgeBaseEngine** 是一个**纯粹的检索执行器**。它不负责“理解用户意图”,只负责“执行检索指令”。 + +**❌ 错误的设计:** + +* 引擎内部调用 LLM 分析聊天记录。 +* 引擎依赖 Chat History 数据结构。 + +**✅ 正确的设计:** + +* **业务模块 (AIA/ASL)**:调用 DeepSeek 分析意图 \-\> 生成 \[Query1, Query2, Query3\]。 +* **引擎模块 (EKB)**:接收 queries\[\] \-\> 执行向量/关键词检索 \-\> RRF 融合 \-\> 返回结果。 + +## **🏗️ 交互流程图 (The "Brain-Hand" Model)** + +sequenceDiagram + participant User + participant Biz as 业务模块 (AIA/ASL) + participant LLM as DeepSeek V3 (Brain) + participant Engine as 知识库引擎 (Hand) + participant DB as PostgreSQL + + User-\>\>Biz: "副作用大吗?" (带着上下文) + + rect rgb(240, 248, 255\) + Note over Biz, LLM: 🧠 思考阶段 (策略层) + Biz-\>\>LLM: Prompt: "结合历史,生成检索词" + LLM--\>\>Biz: \["NSCLC一线治疗副作用", "Pembrolizumab AE"\] + end + + rect rgb(255, 240, 245\) + Note over Biz, Engine: ✋ 执行阶段 (机制层) + Biz-\>\>Engine: search(queries=\[...\]) + Engine-\>\>DB: 并行向量检索 \+ 关键词检索 + Engine-\>\>Engine: RRF 融合 & Rerank + Engine--\>\>Biz: 返回 Top-K 文档 + end + + Biz-\>\>LLM: Prompt: "基于这些文档回答用户" + LLM--\>\>User: 最终回答 + +## **📦 API 设计 (KnowledgeBaseEngine)** + +引擎的 API 变得更加干净、通用: + +export class KnowledgeBaseEngine { + /\*\* + \* 纯粹的检索接口 + \* @param kbIds 知识库 ID 列表 + \* @param searchQueries 检索词列表(由业务层生成好的) + \*/ + async search( + kbIds: string\[\], + searchQueries: string\[\], // 👈 接收一组词,而不是一个 query + options?: { + topK?: number; + filters?: SearchFilters; // 结构化过滤,如 { year: 2024 } + } + ): Promise\ { + // 1\. 并行执行所有 query 的检索 (Fan-out) + const allResults \= await Promise.all( + searchQueries.map(q \=\> this.vectorSearchInternal(kbIds, q)) + ); + + // 2\. RRF 融合 + const fusedResults \= this.rrfFusion(allResults.flat()); + + // 3\. Rerank (可选) + // 使用第一个 Query 作为基准进行重排序 + return this.rerank(fusedResults, searchQueries\[0\]); + } +} + +把“语义理解”和“意图分析”强行塞进底层的 RAG 引擎,确实会让引擎变得臃肿且不通用。 + +### **为什么你的思路是“架构级”的提升?** + +1. **解耦 (Decoupling)**: + * **RAG 引擎 (KnowledgeBaseEngine)** 应该是一个\*\*“精准的执行者”\*\*(机制)。它的职责是:“你给我一组词,我给你找出最匹配的文档”。它不应该关心“这组词是来自一个愤怒的医生,还是来自一个做作业的学生”。 + * **业务模块 (AIA/ASL)** 才是\*\*“聪明的决策者”\*\*(策略)。只有业务模块才知道当前的对话上下文(Context)、用户的角色(Role)以及当前任务的目标(Goal)。 +2. **场景化差异 (Context Matters)**: + * **AIA(聊天)**:上下文是 `Chat History`,策略是“意图补全”。 + * **ASL(文献筛选)**:上下文是 `Inclusion Criteria`(入排标准),策略是“PICO 拆解”。 + * **如果都塞进引擎**:引擎内部就要写一堆 `if (isChat) ... else if (isLiterature) ...`,这是架构的大忌。 + +--- + +我根据你的这个关键修正,将 **《01-知识库引擎架构设计》** 和 **《08-技术方案》** 进行了**最终架构对齐**。 + +### **修改点概览** + +1. **RAG 引擎**:回归纯粹。删除 `search(history)` 这种带业务逻辑的接口,只保留 `search(queries[])`。 +2. **业务层**:新增 **“检索策略层”**。DeepSeek 的思考过程发生在业务层。 + +也就是说,利用DeepSeek做翻译,做语义理解都不应该放在RAG引擎里,对吗?还是说我们只保留翻译?还是都不保留,在业务模块调用DeepSeek的时候,就考虑中英文翻译的事 + +**结论:是的,应该全部剥离。** + +在 KnowledgeBaseEngine(RAG 引擎)里,**既不保留语义理解,也不保留翻译**。它应该是一个“没有任何感情的杀手”,只负责执行。 + +### **1\. 最终架构定调:各司其职** + +为了让你跟团队沟通更明确,我把这种模式总结为 **“决策与执行分离”**: + +| 层级 | 角色 | 职责 (谁做?) | 核心逻辑 | +| :---- | :---- | :---- | :---- | +| **业务层** (AIA/ASL) | **决策者** (The Brain) | **DeepSeek V3** | **“怎么搜?”** 它负责看懂用户的话,看懂历史记录,然后决定:“我要用中文搜一遍,再用英文搜一遍,还要查一下 K 药的学名”。 最终产出:\["K药", "Keytruda", "Pembrolizumab"\] | +| **引擎层** (EKB) | **执行者** (The Hand) | **Postgres \+ Vector** | **“去搜!”** 它根本不知道什么是“K药”,它只收到一个指令:search(queries: string\[\])。 它负责把这 3 个词扔进数据库,并行跑向量检索和关键词检索,然后把结果捞上来。 | + +### **2\. 为什么要连“翻译”都移出去?** + +你可能会问:“翻译不是通用的吗?为什么不留在引擎里?” + +因为在医学场景下,**“翻译”往往依赖“上下文”**,而引擎是没有上下文的。 + +* **场景 A(AIA 聊天)**: + * 用户说:“阳性吗?”(上文在聊 **EGFR 基因突变**)。 + * **业务层 DeepSeek**:结合历史,生成检索词 \["EGFR mutation positive", "EGFR 突变 阳性"\]。 + * **如果是引擎层做翻译**:引擎只看到“阳性吗?”,翻译成 Is it positive?,去搜可能会搜出“新冠阳性”或“艾滋病阳性”,完全跑偏。 +* **场景 B(ASL 文献筛选)**: + * 用户设定:“年龄 \> 60岁”。 + * **业务层 DeepSeek**:生成检索词 \["Elderly patients", "Geriatric", "Age \> 60"\]。 + * **如果是引擎层做翻译**:引擎根本不知道这代表“老年人”,可能直译。 + +**所以,只有业务层才有资格做“精准的翻译(即意图理解)”。** + diff --git a/docs/09-架构实施/07-架构决策-新增EKB独立Schema.md b/docs/09-架构实施/07-架构决策-新增EKB独立Schema.md new file mode 100644 index 00000000..ab41bc84 --- /dev/null +++ b/docs/09-架构实施/07-架构决策-新增EKB独立Schema.md @@ -0,0 +1,151 @@ +# **架构决策记录 (ADR-013):关于新增第 13 个 Schema (ekb\_schema) 的提案** + +状态: 🟢 提议中 (Proposed) +日期: 2026-01-20 +决策者: 架构师 & 开发团队 +涉及模块: 知识库引擎 (EKB), 个人知识库 (PKB), 智能文献 (ASL), 智能问答 (AIA) + +## **1\. 背景与问题** + +目前我们的 Postgres-Only 架构已成功实施了 12 个 Schema 的隔离策略(platform, common, pkb, asl 等)。 + +随着 **知识库引擎 (Knowledge Base Engine)** 的引入,我们需要存储海量的向量切片数据(EkbChunk 表)和多模态文档数据(EkbDocument 表)。 + +**当前面临的问题是:这些数据应该存放在哪里?** + +我们面临三个选项: + +1. **选项 A**:放在 pkb Schema 中(因为 PKB 是第一个使用者)。 +2. **选项 B**:放在 common 或 capability Schema 中(因为它是通用能力)。 +3. **选项 C**:新建第 13 个独立 Schema —— **ekb\_schema**。 + +## **2\. 决策结论** + +**我们决定采用【选项 C】:创建独立的 ekb\_schema。** + +这意味着我们的 Prisma datasource 配置将包含 13 个 Schema: +schemas \= \[..., "ekb\_schema"\] + +## **3\. 决策详细理由** + +### **3.1 架构分层:避免依赖倒置 (Dependency Inversion)** + +知识库引擎是**底层基础设施(Infrastructure Layer)**,类似于“图书馆大楼”;而 PKB、ASL、AIA 是**上层业务应用(Application Layer)**,类似于“租户”。 + +* 如果将引擎表放在 pkb Schema 中,会导致逻辑上的“ASL 依赖 PKB”,这是错误的依赖关系。 +* 通过独立 ekb\_schema,所有业务模块(PKB, ASL, AIA)都平等地依赖 EKB,架构层次清晰。 + +### **3.2 运维隔离:重型数据的特殊需求** + +向量表 (EkbChunk) 具有显著的\*\*“重数据”\*\*特征: + +* **数据量大**:可能迅速增长到百万/千万行级别。 +* **索引特殊**:使用 HNSW 向量索引,构建和维护成本高。 +* **维护频繁**:向量表对 UPDATE/DELETE 敏感,需要更激进的 VACUUM (垃圾回收) 策略。 + +将其隔离在独立 Schema 中,允许 DBA 未来针对该 Schema 进行独立的性能调优(如分配特定的 Tablespace 或调整内存参数),而不影响 users 或 orders 等轻量级业务表。 + +### **3.3 业务边界清晰** + +* common:存放纯技术组件(Log, Storage 记录)。 +* capability:存放轻量级业务配置(Prompt 模板)。 +* ekb\_schema:存放核心知识资产和向量数据。 + +混在一起会导致 common 变得极其臃肿,增加后续拆分微服务的难度。 + +## **4\. 性能影响评估 (Performance Review)** + +团队可能担心:“13 个 Schema 会不会太多?会不会拖慢速度?” + +**技术评估结论:对性能几乎无负面影响(Zero Overhead)。** + +| 关注点 | 技术事实 | 结论 | +| :---- | :---- | :---- | +| **查询速度** | PostgreSQL 内部使用 OID 查找表,Schema 只是逻辑命名空间。跨 Schema Join (JOIN ekb.Chunk) 与同 Schema Join 性能完全一致。 | ✅ 无损耗 | +| **连接资源** | 我们使用的是 Prisma 单一连接池。所有 Schema 复用同一个 TCP 连接,不增加数据库连接数。 | ✅ 无损耗 | +| **内存占用** | Schema 本身只占用极少的元数据空间。Postgres 支持单库数千个 Schema 毫无压力。 | ✅ 可忽略 | +| **维护效率** | 独立的 Schema 让 pg\_dump 备份和 VACUUM 维护更灵活(可只备份业务数据,单独备份向量数据)。 | ✅ 正向收益 | + +## **5\. 潜在风险与应对 (Risks & Mitigation)** + +虽然性能无忧,但在多 Schema 开发中存在以下“坑”,需提前规避: + +### **🔧 坑 1:Prisma 的跨 Schema 关联** + +* **问题**:跨 Schema 定义外键(如 EkbDocument 关联 User)时,容易因缺少标记报错。 +* **解决方案**: + 1. **显式标记**:关联的两个 Model 必须都带有 @@schema("...") 标记。 + 2. **双向定义**:在两边都定义 @relation 字段,确保 Prisma Client 能正确生成跨 Schema 的 Join 查询。 + 3. **弱关联推荐**:对于非强一致性业务,建议仅存储 ID 字符串(如 userId),减少数据库层面的硬外键约束,提升灵活性。 + +### **🔧 坑 2:原生 SQL 的写法复杂度** + +* **问题**:在使用 prisma.$queryRaw 进行向量检索时,很容易忘记加 Schema 前缀,导致 Relation "EkbChunk" does not exist 错误。 +* **解决方案**: + * **强制带前缀**:在写 SQL 时必须使用双引号包裹 Schema 和表名,例如 FROM "ekb\_schema"."EkbChunk"。 + * **封装服务**:禁止在 Controller 层写 SQL,所有向量检索逻辑必须封装在 KnowledgeBaseEngine 类中,屏蔽底层细节。 + +### **🔧 坑 3:迁移文件管理 (Migration Clutter)** + +* **问题**:Prisma 将所有 Schema 的变更都放在同一个 prisma/migrations 文件夹下,文件多了容易混乱。 +* **解决方案**: + * **命名规范**:执行迁移时强制加前缀。 + * ✅ npx prisma migrate dev \--name ekb\_init\_vector\_table + * ✅ npx prisma migrate dev \--name aia\_update\_agents + * 这样在排查问题时,能一眼看出该 Migration 属于哪个模块。 + +## **6\. 实施计划 (Implementation)** + +### **步骤 1: 更新 Prisma 配置** + +在 prisma/schema.prisma 中: + +generator client { + provider \= "prisma-client-js" + previewFeatures \= \["multiSchema", "postgresqlExtensions"\] +} + +datasource db { + provider \= "postgresql" + url \= env("DATABASE\_URL") + extensions \= \[vector, pg\_bigm\] // 确保启用插件 + // 添加新的 schema + schemas \= \[..., "capability", "ekb\_schema"\] +} + +### **步骤 2: 定义模型** + +将 04-数据模型设计.md 中的模型放入: + +model EkbKnowledgeBase { + // ... 字段定义 + @@schema("ekb\_schema") // 👈 关键点 +} + +model EkbDocument { + // ... 字段定义 + kbId String + kb EkbKnowledgeBase @relation(fields: \[kbId\], references: \[id\]) + @@schema("ekb\_schema") +} +// ... EkbChunk 同理 + +### **步骤 3: 跨 Schema 关联 (注意事项)** + +如果业务表(如 pkb\_schema.UserPkbConfig)需要关联知识库: + +// 在 pkb\_schema 中 +model UserPkbConfig { + id String @id + kbId String + kb EkbKnowledgeBase @relation(fields: \[kbId\], references: \[id\]) // 👈 跨 Schema 关联支持良好 + @@schema("pkb") +} + +## **7\. 常见问题 (FAQ)** + +Q: 以后如果要把 EKB 拆成独立微服务怎么办? +A: 正因为我们现在用了独立的 Schema,拆分微服务时只需要把这个 Schema 导出,部署到新数据库即可。如果混在 pkb 里,拆分反而极其痛苦。 +Q: 为什么不放在 capability Schema? +A: capability 目前主要存 Prompt 模板,数据量极小。而 ekb 未来会有大量向量数据,体量差异过大,建议物理上保持逻辑距离。 +**结论:** 创建第 13 个 Schema 是符合我们“Postgres-Only \+ 模块化”架构原则的最佳实践,既保证了性能,又为未来的运维和扩展留足了空间。 \ No newline at end of file diff --git a/extraction_service/.dockerignore b/extraction_service/.dockerignore index 606148bc..e1a65005 100644 --- a/extraction_service/.dockerignore +++ b/extraction_service/.dockerignore @@ -79,6 +79,9 @@ models/ + + + diff --git a/extraction_service/main.py b/extraction_service/main.py index 6076c5f7..fbbaaf31 100644 --- a/extraction_service/main.py +++ b/extraction_service/main.py @@ -56,11 +56,17 @@ TEMP_DIR.mkdir(parents=True, exist_ok=True) from services.pdf_extractor import extract_pdf_pymupdf from services.pdf_processor import extract_pdf, get_pdf_processing_strategy from services.language_detector import detect_language, detect_language_detailed -from services.nougat_extractor import check_nougat_available, get_nougat_info from services.file_utils import detect_file_type, cleanup_temp_file from services.docx_extractor import extract_docx_mammoth, validate_docx_file from services.txt_extractor import extract_txt, validate_txt_file from services.dc_executor import validate_code, execute_pandas_code +# 新增:统一文档处理器(RAG 引擎使用) +from services.document_processor import DocumentProcessor, convert_to_markdown +from services.pdf_markdown_processor import PdfMarkdownProcessor, extract_pdf_to_markdown + +# 兼容:nougat 相关(已废弃,保留空实现避免报错) +def check_nougat_available(): return False +def get_nougat_info(): return {"available": False, "reason": "已废弃,使用 pymupdf4llm 替代"} # ✨ 导入预写的数据操作函数 from operations.filter import apply_filter @@ -661,6 +667,72 @@ async def extract_document( ) +# ==================== RAG 引擎 - 文档转 Markdown 接口 ==================== + +@app.post("/api/document/to-markdown") +async def document_to_markdown( + file: UploadFile = File(...), + file_type: Optional[str] = None +): + """ + RAG 引擎 - 文档转 Markdown 接口 + + 将各种格式的文档(PDF、Word、TXT 等)转换为 LLM 友好的 Markdown 格式。 + 这是知识库引擎的核心文档处理接口。 + + Args: + file: 上传的文件 + file_type: 可选,指定文件类型 ('pdf' | 'docx' | 'txt' | 'md') + + Returns: + { + "success": true, + "text": "# 文档标题\\n\\n文档内容...", + "format": "markdown", + "metadata": { + "original_file_type": "pdf", + "char_count": 12345, + "filename": "example.pdf" + } + } + + Raises: + 400: 不支持的文件格式 + 500: 处理失败 + """ + temp_path = None + try: + # 保存上传的文件到临时目录 + temp_path = TEMP_DIR / file.filename + with open(temp_path, "wb") as f: + content = await file.read() + f.write(content) + + logger.info(f"RAG 文档处理: {file.filename}, 大小: {len(content)} bytes") + + # 调用统一文档处理器 + result = await convert_to_markdown(str(temp_path), file_type) + + # 补充文件名到 metadata + if result.get("metadata"): + result["metadata"]["filename"] = file.filename + else: + result["metadata"] = {"filename": file.filename} + + return JSONResponse(content=result) + + except ValueError as e: + logger.warning(f"文档格式不支持: {file.filename}, 错误: {e}") + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"文档转 Markdown 失败: {file.filename}, 错误: {e}") + raise HTTPException(status_code=500, detail=f"处理失败: {str(e)}") + finally: + # 清理临时文件 + if temp_path and temp_path.exists(): + cleanup_temp_file(str(temp_path)) + + # ==================== DC工具C - 代码执行接口 ==================== @app.post("/api/dc/validate") diff --git a/extraction_service/operations/__init__.py b/extraction_service/operations/__init__.py index aff87dc6..b39bf070 100644 --- a/extraction_service/operations/__init__.py +++ b/extraction_service/operations/__init__.py @@ -67,6 +67,9 @@ __version__ = '1.0.0' + + + diff --git a/extraction_service/operations/dropna.py b/extraction_service/operations/dropna.py index 4ba958c6..8629e9f4 100644 --- a/extraction_service/operations/dropna.py +++ b/extraction_service/operations/dropna.py @@ -200,6 +200,9 @@ def get_missing_summary(df: pd.DataFrame) -> dict: + + + diff --git a/extraction_service/operations/filter.py b/extraction_service/operations/filter.py index 2339be30..c0118b22 100644 --- a/extraction_service/operations/filter.py +++ b/extraction_service/operations/filter.py @@ -160,6 +160,9 @@ def apply_filter( + + + diff --git a/extraction_service/operations/unpivot.py b/extraction_service/operations/unpivot.py index f612c672..2e67db0c 100644 --- a/extraction_service/operations/unpivot.py +++ b/extraction_service/operations/unpivot.py @@ -324,6 +324,9 @@ def get_unpivot_preview( + + + diff --git a/extraction_service/requirements.txt b/extraction_service/requirements.txt index cf136361..df18e880 100644 --- a/extraction_service/requirements.txt +++ b/extraction_service/requirements.txt @@ -3,25 +3,31 @@ fastapi==0.104.1 uvicorn[standard]==0.24.0 python-multipart==0.0.6 -# PDF处理 -PyMuPDF>=1.24.0 # 使用更新版本,有预编译wheel -pdfplumber==0.10.3 -nougat-ocr==0.1.17 # 学术PDF高质量提取(英文) -albumentations==1.3.1 # Nougat兼容版本(不要升级到2.x) +# PDF处理 - 使用 pymupdf4llm(替代 nougat,更轻量) +pymupdf4llm>=0.0.17 # PDF → Markdown,自动包含 pymupdf +pdfplumber==0.10.3 # 备用 PDF 处理 -# Docx处理(Day 3需要) -mammoth==1.6.0 -python-docx==1.1.0 +# Word处理 +mammoth==1.6.0 # Docx → Markdown +python-docx==1.1.0 # Docx 读取 -# 语言检测(Day 2需要) +# Excel/CSV处理 +pandas>=2.0.0 # 表格处理 +openpyxl>=3.1.2 # Excel 读取 +tabulate>=0.9.0 # DataFrame → Markdown + +# PPT处理 +python-pptx>=0.6.23 # PPT 读取 + +# 语言检测 langdetect==1.0.9 -# 编码检测(Day 3需要) +# 编码检测 chardet==5.2.0 # 工具 python-dotenv==1.0.0 -pydantic>=2.10.0 # 使用更新版本,有预编译wheel +pydantic>=2.10.0 # 日志 loguru==0.7.2 diff --git a/extraction_service/services/document_processor.py b/extraction_service/services/document_processor.py new file mode 100644 index 00000000..04c36ded --- /dev/null +++ b/extraction_service/services/document_processor.py @@ -0,0 +1,355 @@ +""" +统一文档处理入口 - DocumentProcessor + +功能: +- 自动检测文件类型 +- 调用对应的处理器 +- 统一输出 Markdown 格式 + +支持格式: +- PDF (.pdf) → pymupdf4llm +- Word (.docx) → mammoth +- Excel (.xlsx) → pandas +- CSV (.csv) → pandas +- PPT (.pptx) → python-pptx +- 纯文本 (.txt, .md) → 直接读取 +""" + +from pathlib import Path +from typing import Dict, Any, Optional +from loguru import logger +import chardet + + +class DocumentProcessor: + """统一文档处理器""" + + # 支持的文件类型 + SUPPORTED_TYPES = { + '.pdf': 'pdf', + '.docx': 'word', + '.doc': 'word', + '.xlsx': 'excel', + '.xls': 'excel', + '.csv': 'csv', + '.pptx': 'ppt', + '.ppt': 'ppt', + '.txt': 'text', + '.md': 'text', + '.markdown': 'text', + } + + def to_markdown(self, file_path: str) -> Dict[str, Any]: + """ + 将文档转换为 Markdown + + Args: + file_path: 文件路径 + + Returns: + { + "success": True, + "markdown": "Markdown 内容", + "file_type": "pdf", + "metadata": { ... } + } + """ + path = Path(file_path) + filename = path.name + suffix = path.suffix.lower() + + # 检查文件类型 + if suffix not in self.SUPPORTED_TYPES: + return { + "success": False, + "error": f"不支持的文件类型: {suffix}", + "supported_types": list(self.SUPPORTED_TYPES.keys()) + } + + file_type = self.SUPPORTED_TYPES[suffix] + logger.info(f"处理文档: {filename}, 类型: {file_type}") + + try: + # 根据类型调用对应处理器 + if file_type == 'pdf': + result = self._process_pdf(file_path) + elif file_type == 'word': + result = self._process_word(file_path) + elif file_type == 'excel': + result = self._process_excel(file_path) + elif file_type == 'csv': + result = self._process_csv(file_path) + elif file_type == 'ppt': + result = self._process_ppt(file_path) + elif file_type == 'text': + result = self._process_text(file_path) + else: + result = { + "success": False, + "error": f"未实现的处理器: {file_type}" + } + + # 添加通用信息 + if result.get("success"): + result["file_type"] = file_type + result["filename"] = filename + + return result + + except Exception as e: + logger.error(f"文档处理失败: {filename}, 错误: {e}") + return { + "success": False, + "error": str(e), + "file_type": file_type, + "filename": filename + } + + def _process_pdf(self, file_path: str) -> Dict[str, Any]: + """处理 PDF""" + from .pdf_markdown_processor import PdfMarkdownProcessor + + processor = PdfMarkdownProcessor() + return processor.to_markdown(file_path) + + def _process_word(self, file_path: str) -> Dict[str, Any]: + """处理 Word 文档""" + import mammoth + + try: + with open(file_path, "rb") as f: + result = mammoth.convert_to_markdown(f) + markdown = result.value + messages = result.messages + + # 添加文件名上下文 + filename = Path(file_path).name + markdown_with_context = f"## 文档: {filename}\n\n{markdown}" + + return { + "success": True, + "markdown": markdown_with_context, + "metadata": { + "char_count": len(markdown), + "warnings": [str(m) for m in messages] if messages else [] + } + } + except Exception as e: + return { + "success": False, + "error": str(e), + "markdown": f"> **系统提示**:Word 文档解析失败: {str(e)}" + } + + def _process_excel(self, file_path: str) -> Dict[str, Any]: + """处理 Excel""" + import pandas as pd + + try: + filename = Path(file_path).name + xlsx = pd.ExcelFile(file_path, engine='openpyxl') + + md_parts = [] + total_rows = 0 + + for sheet_name in xlsx.sheet_names: + df = pd.read_excel(xlsx, sheet_name=sheet_name) + rows = len(df) + total_rows += rows + + # 添加 Sheet 信息 + md_parts.append(f"## 数据: {filename} - {sheet_name}") + md_parts.append(f"- **行列**: {rows} 行 × {len(df.columns)} 列\n") + + # 截断大数据 + max_rows = 200 + if rows > max_rows: + md_parts.append(f"> ⚠️ 数据量较大,仅显示前 {max_rows} 行(共 {rows} 行)\n") + df = df.head(max_rows) + + # 转换为 Markdown 表格 + df = df.fillna('') + md_parts.append(df.to_markdown(index=False)) + md_parts.append("\n---\n") + + return { + "success": True, + "markdown": "\n".join(md_parts), + "metadata": { + "sheet_count": len(xlsx.sheet_names), + "total_rows": total_rows, + "sheets": xlsx.sheet_names + } + } + except Exception as e: + return { + "success": False, + "error": str(e), + "markdown": f"> **系统提示**:Excel 文档解析失败: {str(e)}" + } + + def _process_csv(self, file_path: str) -> Dict[str, Any]: + """处理 CSV""" + import pandas as pd + + try: + filename = Path(file_path).name + + # 自动检测编码 + with open(file_path, 'rb') as f: + raw = f.read(10000) + detected = chardet.detect(raw) + encoding = detected.get('encoding', 'utf-8') + + df = pd.read_csv(file_path, encoding=encoding) + rows = len(df) + + md_parts = [] + md_parts.append(f"## 数据: {filename}") + md_parts.append(f"- **行列**: {rows} 行 × {len(df.columns)} 列") + md_parts.append(f"- **编码**: {encoding}\n") + + # 截断大数据 + max_rows = 200 + if rows > max_rows: + md_parts.append(f"> ⚠️ 数据量较大,仅显示前 {max_rows} 行(共 {rows} 行)\n") + df = df.head(max_rows) + + df = df.fillna('') + md_parts.append(df.to_markdown(index=False)) + + return { + "success": True, + "markdown": "\n".join(md_parts), + "metadata": { + "row_count": rows, + "column_count": len(df.columns), + "encoding": encoding + } + } + except Exception as e: + return { + "success": False, + "error": str(e), + "markdown": f"> **系统提示**:CSV 文件解析失败: {str(e)}" + } + + def _process_ppt(self, file_path: str) -> Dict[str, Any]: + """处理 PPT""" + from pptx import Presentation + + try: + filename = Path(file_path).name + prs = Presentation(file_path) + + md_parts = [] + md_parts.append(f"## 演示文稿: {filename}\n") + + for slide_num, slide in enumerate(prs.slides, 1): + md_parts.append(f"### 幻灯片 {slide_num}") + + # 获取标题 + if slide.shapes.title: + md_parts.append(f"**{slide.shapes.title.text}**\n") + + # 获取所有文本 + for shape in slide.shapes: + if shape.has_text_frame: + for para in shape.text_frame.paragraphs: + text = para.text.strip() + if text: + md_parts.append(f"- {text}") + + md_parts.append("") + + return { + "success": True, + "markdown": "\n".join(md_parts), + "metadata": { + "slide_count": len(prs.slides) + } + } + except Exception as e: + return { + "success": False, + "error": str(e), + "markdown": f"> **系统提示**:PPT 文档解析失败: {str(e)}" + } + + def _process_text(self, file_path: str) -> Dict[str, Any]: + """处理纯文本""" + try: + filename = Path(file_path).name + + # 自动检测编码 + with open(file_path, 'rb') as f: + raw = f.read() + detected = chardet.detect(raw) + encoding = detected.get('encoding', 'utf-8') + + with open(file_path, 'r', encoding=encoding) as f: + content = f.read() + + # 如果是 .md 文件,直接返回 + if file_path.endswith('.md') or file_path.endswith('.markdown'): + markdown = content + else: + # 纯文本添加文件名上下文 + markdown = f"## 文档: {filename}\n\n{content}" + + return { + "success": True, + "markdown": markdown, + "metadata": { + "char_count": len(content), + "encoding": encoding + } + } + except Exception as e: + return { + "success": False, + "error": str(e), + "markdown": f"> **系统提示**:文本文件读取失败: {str(e)}" + } + + +# 便捷函数 +async def convert_to_markdown(file_path: str, file_type: Optional[str] = None) -> Dict[str, Any]: + """ + 将文档转换为 Markdown(便捷函数,异步版本) + + Args: + file_path: 文件路径 + file_type: 可选,指定文件类型(如不指定则自动检测) + + Returns: + 处理结果字典,格式: + { + "success": True, + "text": "Markdown 内容", + "format": "markdown", + "metadata": { ... } + } + """ + processor = DocumentProcessor() + result = processor.to_markdown(file_path) + + # 转换输出格式以匹配 API 预期 + if result.get("success"): + return { + "success": True, + "text": result.get("markdown", ""), + "format": "markdown", + "metadata": { + "original_file_type": result.get("file_type"), + "char_count": len(result.get("markdown", "")), + **result.get("metadata", {}) + } + } + else: + return { + "success": False, + "error": result.get("error", "处理失败"), + "metadata": result.get("metadata", {}) + } + diff --git a/extraction_service/services/pdf_markdown_processor.py b/extraction_service/services/pdf_markdown_processor.py new file mode 100644 index 00000000..15bab791 --- /dev/null +++ b/extraction_service/services/pdf_markdown_processor.py @@ -0,0 +1,146 @@ +""" +PDF Markdown 处理器 - 基于 pymupdf4llm + +特点: +- 输出 LLM 友好的 Markdown 格式 +- 完整保留表格结构 +- 自动检测扫描件并返回友好提示 +- 零 OCR,只处理电子版 PDF +""" + +import pymupdf4llm +from pathlib import Path +from typing import Dict, Any, Optional, List +from loguru import logger + + +class PdfMarkdownProcessor: + """PDF → Markdown 处理器""" + + # 扫描件检测阈值:提取文本少于此字符数视为扫描件 + MIN_TEXT_THRESHOLD = 50 + + def __init__(self, image_dir: str = "./images"): + self.image_dir = image_dir + + def to_markdown( + self, + pdf_path: str, + page_chunks: bool = False, + extract_images: bool = False, + dpi: int = 150 + ) -> Dict[str, Any]: + """ + PDF 转 Markdown(仅支持电子版) + + Args: + pdf_path: PDF 文件路径 + page_chunks: 是否按页分块 + extract_images: 是否提取图片(默认关闭,节省空间) + dpi: 图片分辨率 + + Returns: + { + "success": True, + "markdown": "Markdown 文本", + "metadata": { "page_count": 10, "char_count": 5000 }, + "is_scanned": False + } + """ + filename = Path(pdf_path).name + + try: + logger.info(f"开始使用 pymupdf4llm 处理: {filename}") + + # 调用 pymupdf4llm + md_text = pymupdf4llm.to_markdown( + pdf_path, + page_chunks=page_chunks, + write_images=extract_images, + image_path=self.image_dir if extract_images else None, + dpi=dpi, + show_progress=False + ) + + # 如果返回的是列表(page_chunks=True),合并为字符串 + if isinstance(md_text, list): + md_text = "\n\n---\n\n".join([ + f"## Page {i+1}\n\n{page.get('text', '')}" + for i, page in enumerate(md_text) + ]) + + char_count = len(md_text.strip()) + + # 质量检查:检测是否为扫描件 + if char_count < self.MIN_TEXT_THRESHOLD: + logger.warning(f"PDF 文本过少 ({char_count} 字符),可能为扫描件: {filename}") + return { + "success": True, + "markdown": self._scan_pdf_hint(filename, char_count), + "metadata": { + "page_count": self._get_page_count(pdf_path), + "char_count": char_count, + "is_scanned": True + }, + "is_scanned": True + } + + # 获取页数 + page_count = self._get_page_count(pdf_path) + + logger.info(f"PDF 处理完成: {page_count} 页, {char_count} 字符") + + return { + "success": True, + "markdown": md_text, + "metadata": { + "page_count": page_count, + "char_count": char_count, + "is_scanned": False + }, + "is_scanned": False + } + + except Exception as e: + logger.error(f"PDF 解析失败: {filename}, 错误: {e}") + return { + "success": False, + "error": str(e), + "markdown": f"> **系统提示**:文档 `{filename}` 解析失败: {str(e)}" + } + + def _get_page_count(self, pdf_path: str) -> int: + """获取 PDF 页数""" + try: + import fitz # pymupdf + doc = fitz.open(pdf_path) + count = len(doc) + doc.close() + return count + except: + return 0 + + def _scan_pdf_hint(self, filename: str, char_count: int) -> str: + """生成扫描件友好提示""" + return f"""> **系统提示**:文档 `{filename}` 似乎是扫描件(图片型 PDF)。 +> +> - 提取文本量:{char_count} 字符 +> - 本系统暂不支持扫描版 PDF 的文字识别 +> - 建议:请上传电子版 PDF,或将扫描件转换为可编辑格式后重新上传""" + + +# 便捷函数 +def extract_pdf_to_markdown(pdf_path: str) -> Dict[str, Any]: + """ + PDF 转 Markdown(便捷函数) + + Args: + pdf_path: PDF 文件路径 + + Returns: + 处理结果字典 + """ + processor = PdfMarkdownProcessor() + return processor.to_markdown(pdf_path) + + diff --git a/extraction_service/services/pdf_processor.py b/extraction_service/services/pdf_processor.py index 9754c99f..e1eba3b4 100644 --- a/extraction_service/services/pdf_processor.py +++ b/extraction_service/services/pdf_processor.py @@ -1,17 +1,17 @@ """ PDF处理主服务 -实现顺序降级策略: -1. 检测语言 -2. 中文PDF → PyMuPDF(快速) -3. 英文PDF → Nougat → 失败降级PyMuPDF +策略: +- 所有 PDF 统一使用 PyMuPDF 处理(快速、稳定) +- RAG 引擎推荐使用 pymupdf4llm(见 pdf_markdown_processor.py) + +注意:Nougat 已废弃,不再使用 """ from typing import Dict, Any, Optional from loguru import logger from .language_detector import detect_language -from .nougat_extractor import extract_pdf_nougat, check_nougat_available from .pdf_extractor import extract_pdf_pymupdf @@ -20,22 +20,24 @@ def extract_pdf( force_method: Optional[str] = None ) -> Dict[str, Any]: """ - PDF提取主函数(顺序降级策略) + PDF提取主函数 处理流程: - 1. 检测语言 - 2. 中文 → 直接PyMuPDF - 3. 英文 → 尝试Nougat → 失败降级PyMuPDF + 1. 检测语言(仅用于元数据) + 2. 使用 PyMuPDF 提取文本 + + 注意:对于 RAG 引擎,推荐使用 /api/document/to-markdown 接口, + 它使用 pymupdf4llm 提供更好的表格和结构支持。 Args: file_path: PDF文件路径 - force_method: 强制使用的方法 ('nougat' | 'pymupdf') + force_method: 保留参数(已废弃,仅支持 'pymupdf') Returns: { "success": True, - "method": "nougat" | "pymupdf", - "reason": "chinese_pdf" | "english_pdf" | "nougat_failed" | "nougat_low_quality", + "method": "pymupdf", + "reason": "...", "text": "提取的文本", "metadata": {...} } @@ -43,97 +45,31 @@ def extract_pdf( try: logger.info(f"开始处理PDF: {file_path}") - # Step 1: 语言检测 + # Step 1: 语言检测(仅用于元数据) logger.info("[Step 1] 检测PDF语言...") language = detect_language(file_path) logger.info(f"检测结果: {language}") - # 如果强制指定方法 - if force_method: - logger.info(f"强制使用方法: {force_method}") - - if force_method == 'nougat': - return extract_pdf_nougat(file_path) - elif force_method == 'pymupdf': - result = extract_pdf_pymupdf(file_path) - result['reason'] = 'force_pymupdf' - return result - - # Step 2: 中文PDF → 直接PyMuPDF - if language == 'chinese': - logger.info("[Step 2] 中文PDF,使用PyMuPDF快速处理") - - result = extract_pdf_pymupdf(file_path) - - if result['success']: - result['reason'] = 'chinese_pdf' - result['detected_language'] = language - logger.info("✅ PyMuPDF处理成功(中文PDF)") - return result - else: - logger.error("❌ PyMuPDF处理失败") - return result - - # Step 3: 英文PDF → 尝试Nougat - logger.info("[Step 3] 英文PDF,尝试Nougat高质量解析") - - # 检查Nougat是否可用 - if not check_nougat_available(): - logger.warning("⚠️ Nougat不可用,降级到PyMuPDF") - - result = extract_pdf_pymupdf(file_path) - if result['success']: - result['reason'] = 'nougat_unavailable' - result['detected_language'] = language - return result - - # 尝试Nougat - try: - nougat_result = extract_pdf_nougat(file_path) - - if not nougat_result['success']: - logger.warning("⚠️ Nougat提取失败,降级到PyMuPDF") - raise Exception(nougat_result.get('error', 'Nougat failed')) - - # 质量检查 - quality_score = nougat_result['metadata'].get('quality_score', 0) - - logger.info(f"Nougat质量评分: {quality_score:.2f}") - - # 质量阈值:0.7 - if quality_score >= 0.7: - logger.info("✅ Nougat处理成功(质量合格)") - nougat_result['reason'] = 'english_pdf_high_quality' - nougat_result['detected_language'] = language - return nougat_result - else: - logger.warning(f"⚠️ Nougat质量不足: {quality_score:.2f},降级到PyMuPDF") - raise Exception(f"Quality too low: {quality_score}") - - except Exception as e: - logger.warning(f"Nougat处理失败: {str(e)},降级到PyMuPDF") - - # Step 4: 降级到PyMuPDF - logger.info("[Step 4] 降级使用PyMuPDF") + # Step 2: 使用 PyMuPDF 提取 + logger.info("[Step 2] 使用PyMuPDF处理") result = extract_pdf_pymupdf(file_path) if result['success']: - result['reason'] = 'nougat_failed_or_low_quality' + result['reason'] = 'pymupdf_standard' result['detected_language'] = language - result['fallback'] = True - logger.info("✅ PyMuPDF处理成功(降级方案)") + logger.info("✅ PyMuPDF处理成功") else: - logger.error("❌ PyMuPDF处理也失败了") + logger.error("❌ PyMuPDF处理失败") return result except Exception as e: - logger.error(f"PDF处理完全失败: {str(e)}") + logger.error(f"PDF处理失败: {str(e)}") return { "success": False, "error": str(e), - "method": "unknown" + "method": "pymupdf" } @@ -149,34 +85,20 @@ def get_pdf_processing_strategy(file_path: str) -> Dict[str, Any]: Returns: { "detected_language": "chinese" | "english", - "recommended_method": "nougat" | "pymupdf", + "recommended_method": "pymupdf", "reason": "...", - "nougat_available": True | False + "nougat_available": False # 已废弃 } """ try: # 检测语言 language = detect_language(file_path) - # 检查Nougat可用性 - nougat_available = check_nougat_available() - - # 决定策略 - if language == 'chinese': - recommended_method = 'pymupdf' - reason = '中文PDF,推荐使用PyMuPDF快速处理' - elif nougat_available: - recommended_method = 'nougat' - reason = '英文PDF,推荐使用Nougat高质量解析' - else: - recommended_method = 'pymupdf' - reason = 'Nougat不可用,使用PyMuPDF' - return { "detected_language": language, - "recommended_method": recommended_method, - "reason": reason, - "nougat_available": nougat_available + "recommended_method": "pymupdf", + "reason": "统一使用 PyMuPDF 处理(RAG 引擎推荐使用 /api/document/to-markdown)", + "nougat_available": False # 已废弃 } except Exception as e: diff --git a/extraction_service/test_dc_api.py b/extraction_service/test_dc_api.py index 4e34d1da..8ef0f2eb 100644 --- a/extraction_service/test_dc_api.py +++ b/extraction_service/test_dc_api.py @@ -334,6 +334,9 @@ if __name__ == "__main__": + + + diff --git a/extraction_service/test_execute_simple.py b/extraction_service/test_execute_simple.py index 584d3de6..290f92f8 100644 --- a/extraction_service/test_execute_simple.py +++ b/extraction_service/test_execute_simple.py @@ -100,6 +100,9 @@ except Exception as e: + + + diff --git a/extraction_service/test_module.py b/extraction_service/test_module.py index 124cd553..bf8cffeb 100644 --- a/extraction_service/test_module.py +++ b/extraction_service/test_module.py @@ -80,6 +80,9 @@ except Exception as e: + + + diff --git a/frontend-v2/.dockerignore b/frontend-v2/.dockerignore index 185b36b0..bc498da7 100644 --- a/frontend-v2/.dockerignore +++ b/frontend-v2/.dockerignore @@ -99,6 +99,9 @@ vite.config.*.timestamp-* + + + diff --git a/frontend-v2/docker-entrypoint.sh b/frontend-v2/docker-entrypoint.sh index 90c55e50..cf29cd82 100644 --- a/frontend-v2/docker-entrypoint.sh +++ b/frontend-v2/docker-entrypoint.sh @@ -66,6 +66,9 @@ exec nginx -g 'daemon off;' + + + diff --git a/frontend-v2/nginx.conf b/frontend-v2/nginx.conf index e26b9be1..80b936a1 100644 --- a/frontend-v2/nginx.conf +++ b/frontend-v2/nginx.conf @@ -222,6 +222,9 @@ http { + + + diff --git a/frontend-v2/src/common/api/axios.ts b/frontend-v2/src/common/api/axios.ts index ca683536..2f080152 100644 --- a/frontend-v2/src/common/api/axios.ts +++ b/frontend-v2/src/common/api/axios.ts @@ -53,3 +53,6 @@ export default apiClient; + + + diff --git a/frontend-v2/src/framework/auth/api.ts b/frontend-v2/src/framework/auth/api.ts index 16081fea..ca5b84ed 100644 --- a/frontend-v2/src/framework/auth/api.ts +++ b/frontend-v2/src/framework/auth/api.ts @@ -252,3 +252,6 @@ export async function logout(): Promise { + + + diff --git a/frontend-v2/src/framework/auth/index.ts b/frontend-v2/src/framework/auth/index.ts index 6393e042..5e625fbb 100644 --- a/frontend-v2/src/framework/auth/index.ts +++ b/frontend-v2/src/framework/auth/index.ts @@ -18,3 +18,6 @@ export * from './api'; + + + diff --git a/frontend-v2/src/framework/auth/moduleApi.ts b/frontend-v2/src/framework/auth/moduleApi.ts index f22a1c03..ab1231d2 100644 --- a/frontend-v2/src/framework/auth/moduleApi.ts +++ b/frontend-v2/src/framework/auth/moduleApi.ts @@ -42,3 +42,6 @@ export async function fetchUserModules(): Promise { + + + diff --git a/frontend-v2/src/modules/admin/components/ModulePermissionModal.tsx b/frontend-v2/src/modules/admin/components/ModulePermissionModal.tsx index 7ee51e2c..f48f8005 100644 --- a/frontend-v2/src/modules/admin/components/ModulePermissionModal.tsx +++ b/frontend-v2/src/modules/admin/components/ModulePermissionModal.tsx @@ -122,3 +122,6 @@ export default ModulePermissionModal; + + + diff --git a/frontend-v2/src/modules/admin/index.tsx b/frontend-v2/src/modules/admin/index.tsx index e22897e8..0c42f315 100644 --- a/frontend-v2/src/modules/admin/index.tsx +++ b/frontend-v2/src/modules/admin/index.tsx @@ -33,3 +33,6 @@ export default AdminModule; + + + diff --git a/frontend-v2/src/modules/admin/types/user.ts b/frontend-v2/src/modules/admin/types/user.ts index 5a7c94d5..57babdc9 100644 --- a/frontend-v2/src/modules/admin/types/user.ts +++ b/frontend-v2/src/modules/admin/types/user.ts @@ -198,3 +198,6 @@ export const TENANT_TYPE_NAMES: Record = { + + + diff --git a/frontend-v2/src/modules/aia/components/AgentCard.tsx b/frontend-v2/src/modules/aia/components/AgentCard.tsx index d752ecbd..6d810ddd 100644 --- a/frontend-v2/src/modules/aia/components/AgentCard.tsx +++ b/frontend-v2/src/modules/aia/components/AgentCard.tsx @@ -82,3 +82,6 @@ export default AgentCard; + + + diff --git a/frontend-v2/src/modules/aia/components/index.ts b/frontend-v2/src/modules/aia/components/index.ts index 7b7d44db..36f4c508 100644 --- a/frontend-v2/src/modules/aia/components/index.ts +++ b/frontend-v2/src/modules/aia/components/index.ts @@ -12,3 +12,6 @@ export { ChatWorkspace } from './ChatWorkspace'; + + + diff --git a/frontend-v2/src/modules/aia/constants.ts b/frontend-v2/src/modules/aia/constants.ts index c64aec94..29ea7750 100644 --- a/frontend-v2/src/modules/aia/constants.ts +++ b/frontend-v2/src/modules/aia/constants.ts @@ -176,3 +176,6 @@ export const BRAND_COLORS = { + + + diff --git a/frontend-v2/src/modules/aia/styles/agent-card.css b/frontend-v2/src/modules/aia/styles/agent-card.css index ae1b1c6e..5bbbf841 100644 --- a/frontend-v2/src/modules/aia/styles/agent-card.css +++ b/frontend-v2/src/modules/aia/styles/agent-card.css @@ -214,3 +214,6 @@ + + + diff --git a/frontend-v2/src/modules/asl/components/FulltextDetailDrawer.tsx b/frontend-v2/src/modules/asl/components/FulltextDetailDrawer.tsx index 1a181333..258984a8 100644 --- a/frontend-v2/src/modules/asl/components/FulltextDetailDrawer.tsx +++ b/frontend-v2/src/modules/asl/components/FulltextDetailDrawer.tsx @@ -569,6 +569,9 @@ export default FulltextDetailDrawer; + + + diff --git a/frontend-v2/src/modules/dc/api/toolC.ts b/frontend-v2/src/modules/dc/api/toolC.ts index 5821f2a2..91dceda4 100644 --- a/frontend-v2/src/modules/dc/api/toolC.ts +++ b/frontend-v2/src/modules/dc/api/toolC.ts @@ -1,15 +1,40 @@ /** * Tool C API封装 * - * 提供6个核心API方法: + * 提供核心API方法: * - Session管理:上传、获取、预览、心跳 * - AI功能:生成代码、执行代码、一步到位处理、获取历史 + * - 快速操作:筛选、映射、分箱、条件、删NA、计算、Pivot */ import apiClient from '../../../common/api/axios'; +import { getAccessToken } from '../../../framework/auth/api'; const BASE_URL = '/api/v1/dc/tool-c'; +// ==================== 认证辅助函数 ==================== + +/** + * 获取带认证的请求头(供原生 fetch 使用) + * + * @example + * const response = await fetch(url, { + * method: 'POST', + * headers: getAuthHeaders(), + * body: JSON.stringify(data), + * }); + */ +export function getAuthHeaders(): HeadersInit { + const headers: Record = { + 'Content-Type': 'application/json', + }; + const token = getAccessToken(); + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + return headers; +} + // ==================== 类型定义 ==================== export interface UploadResponse { @@ -242,3 +267,37 @@ export const getSessionStatus = async ( return response.data; }; +// ==================== 快速操作(功能按钮) ==================== + +export interface QuickActionParams { + sessionId: string; + action: string; + params: Record; +} + +export interface QuickActionResponse { + success: boolean; + message?: string; + error?: string; + data?: { + newDataPreview: Record[]; + [key: string]: any; + }; +} + +/** + * 执行快速操作(筛选、映射、分箱、条件、删NA、计算、Pivot等) + */ +export const quickAction = async (params: QuickActionParams): Promise => { + const response = await apiClient.post(`${BASE_URL}/quick-action`, params); + return response.data; +}; + +/** + * 预览快速操作结果 + */ +export const quickActionPreview = async (params: QuickActionParams): Promise => { + const response = await apiClient.post(`${BASE_URL}/quick-action/preview`, params); + return response.data; +}; + diff --git a/frontend-v2/src/modules/dc/hooks/useAssets.ts b/frontend-v2/src/modules/dc/hooks/useAssets.ts index 0d9cbedb..06adc1d5 100644 --- a/frontend-v2/src/modules/dc/hooks/useAssets.ts +++ b/frontend-v2/src/modules/dc/hooks/useAssets.ts @@ -162,6 +162,9 @@ export const useAssets = (activeTab: AssetTabType) => { + + + diff --git a/frontend-v2/src/modules/dc/hooks/useRecentTasks.ts b/frontend-v2/src/modules/dc/hooks/useRecentTasks.ts index 858b194b..8074a8f2 100644 --- a/frontend-v2/src/modules/dc/hooks/useRecentTasks.ts +++ b/frontend-v2/src/modules/dc/hooks/useRecentTasks.ts @@ -152,6 +152,9 @@ export const useRecentTasks = () => { + + + diff --git a/frontend-v2/src/modules/dc/pages/tool-c/components/BinningDialog.tsx b/frontend-v2/src/modules/dc/pages/tool-c/components/BinningDialog.tsx index 09e4ae22..e88d02fc 100644 --- a/frontend-v2/src/modules/dc/pages/tool-c/components/BinningDialog.tsx +++ b/frontend-v2/src/modules/dc/pages/tool-c/components/BinningDialog.tsx @@ -10,6 +10,7 @@ import React, { useState } from 'react'; import { Modal, Select, Input, Button, Radio, Space, Tag, App, Alert } from 'antd'; import { Info } from 'lucide-react'; +import { getAuthHeaders } from '../../../api/toolC'; interface BinningDialogProps { visible: boolean; @@ -135,7 +136,7 @@ const BinningDialog: React.FC = ({ try { const response = await fetch('/api/v1/dc/tool-c/quick-action', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: getAuthHeaders(), body: JSON.stringify({ sessionId, action: 'binning', diff --git a/frontend-v2/src/modules/dc/pages/tool-c/components/BinningDialog_improved.tsx b/frontend-v2/src/modules/dc/pages/tool-c/components/BinningDialog_improved.tsx index c6376d9b..7cb6a082 100644 --- a/frontend-v2/src/modules/dc/pages/tool-c/components/BinningDialog_improved.tsx +++ b/frontend-v2/src/modules/dc/pages/tool-c/components/BinningDialog_improved.tsx @@ -10,6 +10,7 @@ import React, { useState } from 'react'; import { Modal, Select, Input, Button, Radio, Space, Tag, App, Alert } from 'antd'; import { Info } from 'lucide-react'; +import { getAuthHeaders } from '../../../api/toolC'; interface BinningDialogProps { visible: boolean; @@ -113,7 +114,7 @@ const BinningDialog: React.FC = ({ try { const response = await fetch('/api/v1/dc/tool-c/quick-action', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: getAuthHeaders(), body: JSON.stringify({ sessionId, action: 'binning', diff --git a/frontend-v2/src/modules/dc/pages/tool-c/components/ComputeDialog.tsx b/frontend-v2/src/modules/dc/pages/tool-c/components/ComputeDialog.tsx index 00619cb6..2bd0cc51 100644 --- a/frontend-v2/src/modules/dc/pages/tool-c/components/ComputeDialog.tsx +++ b/frontend-v2/src/modules/dc/pages/tool-c/components/ComputeDialog.tsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; import { Modal, Input, Button, Alert, Collapse, Tag, App } from 'antd'; import { Calculator, Lightbulb, BookOpen } from 'lucide-react'; +import { getAuthHeaders } from '../../../api/toolC'; interface Props { visible: boolean; @@ -92,7 +93,7 @@ const ComputeDialog: React.FC = ({ try { const response = await fetch('/api/v1/dc/tool-c/quick-action', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: getAuthHeaders(), body: JSON.stringify({ sessionId, action: 'compute', diff --git a/frontend-v2/src/modules/dc/pages/tool-c/components/ConditionalDialog.tsx b/frontend-v2/src/modules/dc/pages/tool-c/components/ConditionalDialog.tsx index be0e7dca..2469b912 100644 --- a/frontend-v2/src/modules/dc/pages/tool-c/components/ConditionalDialog.tsx +++ b/frontend-v2/src/modules/dc/pages/tool-c/components/ConditionalDialog.tsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; import { Modal, Input, Button, Select, Alert, App, Card, Tag } from 'antd'; import { PlusCircle, Trash2, AlertCircle } from 'lucide-react'; +import { getAuthHeaders } from '../../../api/toolC'; interface Condition { column: string; @@ -180,7 +181,7 @@ const ConditionalDialog: React.FC = ({ // 调用API const response = await fetch('/api/v1/dc/tool-c/quick-action', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: getAuthHeaders(), body: JSON.stringify({ sessionId, action: 'conditional', diff --git a/frontend-v2/src/modules/dc/pages/tool-c/components/DropnaDialog.tsx b/frontend-v2/src/modules/dc/pages/tool-c/components/DropnaDialog.tsx index d8b8292f..ad950986 100644 --- a/frontend-v2/src/modules/dc/pages/tool-c/components/DropnaDialog.tsx +++ b/frontend-v2/src/modules/dc/pages/tool-c/components/DropnaDialog.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react'; import { Modal, Radio, Button, Slider, Alert, App, Statistic, Row, Col, Checkbox } from 'antd'; import { Trash2, AlertTriangle } from 'lucide-react'; +import { getAuthHeaders } from '../../../api/toolC'; interface Props { visible: boolean; @@ -79,7 +80,7 @@ const DropnaDialog: React.FC = ({ try { const response = await fetch('/api/v1/dc/tool-c/quick-action', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: getAuthHeaders(), body: JSON.stringify({ sessionId, action: 'dropna', @@ -353,5 +354,6 @@ export default DropnaDialog; + diff --git a/frontend-v2/src/modules/dc/pages/tool-c/components/FilterDialog.tsx b/frontend-v2/src/modules/dc/pages/tool-c/components/FilterDialog.tsx index a9ba35a3..5e87b03a 100644 --- a/frontend-v2/src/modules/dc/pages/tool-c/components/FilterDialog.tsx +++ b/frontend-v2/src/modules/dc/pages/tool-c/components/FilterDialog.tsx @@ -10,6 +10,7 @@ import React, { useState } from 'react'; import { Modal, Select, Input, Button, Radio, App } from 'antd'; import { Plus, Trash2 } from 'lucide-react'; +import { getAuthHeaders } from '../../../api/toolC'; interface FilterCondition { column: string; @@ -98,7 +99,7 @@ const FilterDialog: React.FC = ({ try { const response = await fetch('/api/v1/dc/tool-c/quick-action/preview', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: getAuthHeaders(), body: JSON.stringify({ sessionId, action: 'filter', @@ -156,7 +157,7 @@ const FilterDialog: React.FC = ({ try { const response = await fetch('/api/v1/dc/tool-c/quick-action', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: getAuthHeaders(), body: JSON.stringify({ sessionId, action: 'filter', diff --git a/frontend-v2/src/modules/dc/pages/tool-c/components/MetricTimePanel.tsx b/frontend-v2/src/modules/dc/pages/tool-c/components/MetricTimePanel.tsx index 08f89812..064fba08 100644 --- a/frontend-v2/src/modules/dc/pages/tool-c/components/MetricTimePanel.tsx +++ b/frontend-v2/src/modules/dc/pages/tool-c/components/MetricTimePanel.tsx @@ -8,6 +8,7 @@ import React, { useState, useEffect } from 'react'; import { Button, Alert, Checkbox, App, Input, Spin, Tag } from 'antd'; import { ArrowLeftRight, Info, Sparkles } from 'lucide-react'; +import { getAuthHeaders } from '../../../api/toolC'; interface Props { columns: Array<{ id: string; name: string }>; @@ -60,7 +61,7 @@ const MetricTimePanel: React.FC = ({ try { const response = await fetch('/api/v1/dc/tool-c/metric-time/detect', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: getAuthHeaders(), body: JSON.stringify({ sessionId, valueVars, @@ -131,7 +132,7 @@ const MetricTimePanel: React.FC = ({ try { const response = await fetch('/api/v1/dc/tool-c/quick-action', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: getAuthHeaders(), body: JSON.stringify({ sessionId, action: 'metric_time', @@ -438,5 +439,6 @@ export default MetricTimePanel; + diff --git a/frontend-v2/src/modules/dc/pages/tool-c/components/MissingValueDialog.tsx b/frontend-v2/src/modules/dc/pages/tool-c/components/MissingValueDialog.tsx index b2805304..706ea738 100644 --- a/frontend-v2/src/modules/dc/pages/tool-c/components/MissingValueDialog.tsx +++ b/frontend-v2/src/modules/dc/pages/tool-c/components/MissingValueDialog.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from 'react'; import { Modal, Tabs, Radio, Select, Input, Checkbox, Alert, App, Row, Col, InputNumber, Space, Collapse } from 'antd'; +import { getAuthHeaders } from '../../../api/toolC'; interface Props { visible: boolean; @@ -46,7 +47,7 @@ const MissingValueDialog: React.FC = ({ try { const response = await fetch('/api/v1/dc/tool-c/fillna/stats', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: getAuthHeaders(), body: JSON.stringify({ sessionId, column: selectedColumn @@ -87,7 +88,7 @@ const MissingValueDialog: React.FC = ({ try { const response = await fetch('/api/v1/dc/tool-c/quick-action', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: getAuthHeaders(), body: JSON.stringify({ sessionId, action: 'dropna', @@ -137,7 +138,7 @@ const MissingValueDialog: React.FC = ({ try { const response = await fetch('/api/v1/dc/tool-c/fillna/simple', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: getAuthHeaders(), body: JSON.stringify({ sessionId, column: selectedColumn, @@ -180,7 +181,7 @@ const MissingValueDialog: React.FC = ({ try { const response = await fetch('/api/v1/dc/tool-c/fillna/mice', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: getAuthHeaders(), body: JSON.stringify({ sessionId, columns: miceColumns, diff --git a/frontend-v2/src/modules/dc/pages/tool-c/components/MultiMetricPanel.tsx b/frontend-v2/src/modules/dc/pages/tool-c/components/MultiMetricPanel.tsx index 9735bbb4..d3f1c7d7 100644 --- a/frontend-v2/src/modules/dc/pages/tool-c/components/MultiMetricPanel.tsx +++ b/frontend-v2/src/modules/dc/pages/tool-c/components/MultiMetricPanel.tsx @@ -9,6 +9,7 @@ import React, { useState, useEffect } from 'react'; import { Form, Select, Button, Alert, Table, Spin, Divider, Space, Card, Tag, message, Radio } from 'antd'; +import { getAuthHeaders } from '../../../api/toolC'; const { Option } = Select; @@ -70,7 +71,7 @@ export const MultiMetricPanel: React.FC = ({ const response = await fetch('/api/v1/dc/tool-c/multi-metric/detect', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: getAuthHeaders(), body: JSON.stringify({ sessionId, valueVars, @@ -134,7 +135,7 @@ export const MultiMetricPanel: React.FC = ({ // 调用preview API const response = await fetch('/api/v1/dc/tool-c/quick-action/preview', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: getAuthHeaders(), body: JSON.stringify({ sessionId, action, @@ -202,7 +203,7 @@ export const MultiMetricPanel: React.FC = ({ // 调用快速操作API执行转换 const response = await fetch('/api/v1/dc/tool-c/quick-action', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: getAuthHeaders(), body: JSON.stringify({ sessionId, action, diff --git a/frontend-v2/src/modules/dc/pages/tool-c/components/PivotDialog.tsx b/frontend-v2/src/modules/dc/pages/tool-c/components/PivotDialog.tsx index 7d99726e..355680e1 100644 --- a/frontend-v2/src/modules/dc/pages/tool-c/components/PivotDialog.tsx +++ b/frontend-v2/src/modules/dc/pages/tool-c/components/PivotDialog.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react'; import { Modal, Select, Button, Alert, Checkbox, Radio, App } from 'antd'; import { ArrowLeftRight, Info } from 'lucide-react'; +import { getAuthHeaders } from '../../../api/toolC'; interface Props { visible: boolean; @@ -67,7 +68,7 @@ const PivotDialog: React.FC = ({ try { const response = await fetch('/api/v1/dc/tool-c/quick-action', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: getAuthHeaders(), body: JSON.stringify({ sessionId, action: 'pivot', diff --git a/frontend-v2/src/modules/dc/pages/tool-c/components/PivotPanel.tsx b/frontend-v2/src/modules/dc/pages/tool-c/components/PivotPanel.tsx index 8724de0a..4f031d58 100644 --- a/frontend-v2/src/modules/dc/pages/tool-c/components/PivotPanel.tsx +++ b/frontend-v2/src/modules/dc/pages/tool-c/components/PivotPanel.tsx @@ -6,6 +6,7 @@ import React, { useState } from 'react'; import { Select, Button, Alert, Checkbox, Radio, App } from 'antd'; import { ArrowLeftRight, Info } from 'lucide-react'; +import { getAuthHeaders } from '../../../api/toolC'; interface Props { columns: Array<{ id: string; name: string }>; @@ -57,7 +58,7 @@ const PivotPanel: React.FC = ({ try { const response = await fetch('/api/v1/dc/tool-c/quick-action', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: getAuthHeaders(), body: JSON.stringify({ sessionId, action: 'pivot', @@ -324,5 +325,6 @@ export default PivotPanel; + diff --git a/frontend-v2/src/modules/dc/pages/tool-c/components/RecodeDialog.tsx b/frontend-v2/src/modules/dc/pages/tool-c/components/RecodeDialog.tsx index e9de5bcf..d49d4b4e 100644 --- a/frontend-v2/src/modules/dc/pages/tool-c/components/RecodeDialog.tsx +++ b/frontend-v2/src/modules/dc/pages/tool-c/components/RecodeDialog.tsx @@ -9,6 +9,7 @@ import React, { useState, useEffect } from 'react'; import { Modal, Select, Input, Button, Checkbox, Table, Spin, App } from 'antd'; +import { getAuthHeaders } from '../../../api/toolC'; interface RecodeDialogProps { visible: boolean; @@ -59,7 +60,11 @@ const RecodeDialog: React.FC = ({ try { // ✨ 调用后端API获取唯一值(从完整数据中提取,不受前端50行限制) const response = await fetch( - `/api/v1/dc/tool-c/sessions/${sessionId}/unique-values?column=${encodeURIComponent(selectedColumn)}` + `/api/v1/dc/tool-c/sessions/${sessionId}/unique-values?column=${encodeURIComponent(selectedColumn)}`, + { + method: 'GET', + headers: getAuthHeaders(), + } ); const result = await response.json(); @@ -188,7 +193,7 @@ const RecodeDialog: React.FC = ({ try { const response = await fetch('/api/v1/dc/tool-c/quick-action', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: getAuthHeaders(), body: JSON.stringify({ sessionId, action: 'recode', diff --git a/frontend-v2/src/modules/dc/pages/tool-c/components/Sidebar.tsx b/frontend-v2/src/modules/dc/pages/tool-c/components/Sidebar.tsx index 0428b9cf..9341909f 100644 --- a/frontend-v2/src/modules/dc/pages/tool-c/components/Sidebar.tsx +++ b/frontend-v2/src/modules/dc/pages/tool-c/components/Sidebar.tsx @@ -9,6 +9,7 @@ import React, { useState, useCallback } from 'react'; import { MessageSquare, X, Upload } from 'lucide-react'; import { StreamingSteps, StreamStep } from './StreamingSteps'; import { App } from 'antd'; +import { getAuthHeaders } from '../../../api/toolC'; interface SidebarProps { isOpen: boolean; @@ -51,7 +52,7 @@ const Sidebar: React.FC = ({ try { const response = await fetch('/api/v1/dc/tool-c/ai/stream-process', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: getAuthHeaders(), body: JSON.stringify({ sessionId, message: userMessage, diff --git a/frontend-v2/src/modules/dc/pages/tool-c/components/UnpivotPanel.tsx b/frontend-v2/src/modules/dc/pages/tool-c/components/UnpivotPanel.tsx index 1a13e060..c8f491d1 100644 --- a/frontend-v2/src/modules/dc/pages/tool-c/components/UnpivotPanel.tsx +++ b/frontend-v2/src/modules/dc/pages/tool-c/components/UnpivotPanel.tsx @@ -6,6 +6,7 @@ import React, { useState } from 'react'; import { Button, Alert, Checkbox, App, Input, Collapse, Radio } from 'antd'; import { ArrowLeftRight, Info } from 'lucide-react'; +import { getAuthHeaders } from '../../../api/toolC'; interface Props { columns: Array<{ id: string; name: string }>; @@ -89,7 +90,7 @@ const UnpivotPanel: React.FC = ({ try { const response = await fetch('/api/v1/dc/tool-c/quick-action', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: getAuthHeaders(), body: JSON.stringify({ sessionId, action: 'unpivot', diff --git a/frontend-v2/src/modules/dc/pages/tool-c/hooks/useSessionStatus.ts b/frontend-v2/src/modules/dc/pages/tool-c/hooks/useSessionStatus.ts index 5f18e383..5b76f05c 100644 --- a/frontend-v2/src/modules/dc/pages/tool-c/hooks/useSessionStatus.ts +++ b/frontend-v2/src/modules/dc/pages/tool-c/hooks/useSessionStatus.ts @@ -122,6 +122,9 @@ export function useSessionStatus({ + + + diff --git a/frontend-v2/src/modules/dc/pages/tool-c/index.tsx b/frontend-v2/src/modules/dc/pages/tool-c/index.tsx index 3e988cfd..7eef9a0e 100644 --- a/frontend-v2/src/modules/dc/pages/tool-c/index.tsx +++ b/frontend-v2/src/modules/dc/pages/tool-c/index.tsx @@ -19,6 +19,7 @@ import ComputeDialog from './components/ComputeDialog'; import TransformDialog from './components/TransformDialog'; import { useSessionStatus } from './hooks/useSessionStatus'; import * as api from '../../api/toolC'; +import { getAuthHeaders } from '../../api/toolC'; // ==================== 类型定义 ==================== @@ -302,7 +303,10 @@ const ToolC = () => { try { // ✅ 从后端读取完整数据(AI处理后的数据已保存到OSS) - const response = await fetch(`/api/v1/dc/tool-c/sessions/${state.sessionId}/export`); + const response = await fetch(`/api/v1/dc/tool-c/sessions/${state.sessionId}/export`, { + method: 'GET', + headers: getAuthHeaders(), + }); if (!response.ok) { throw new Error('导出失败'); diff --git a/frontend-v2/src/modules/dc/pages/tool-c/types/index.ts b/frontend-v2/src/modules/dc/pages/tool-c/types/index.ts index df77aea6..0ad11709 100644 --- a/frontend-v2/src/modules/dc/pages/tool-c/types/index.ts +++ b/frontend-v2/src/modules/dc/pages/tool-c/types/index.ts @@ -114,6 +114,9 @@ export interface DataStats { + + + diff --git a/frontend-v2/src/modules/dc/types/portal.ts b/frontend-v2/src/modules/dc/types/portal.ts index c8caa357..6c60c8fa 100644 --- a/frontend-v2/src/modules/dc/types/portal.ts +++ b/frontend-v2/src/modules/dc/types/portal.ts @@ -110,6 +110,9 @@ export type AssetTabType = 'all' | 'processed' | 'raw'; + + + diff --git a/frontend-v2/src/modules/pkb/pages/KnowledgePage.tsx b/frontend-v2/src/modules/pkb/pages/KnowledgePage.tsx index f0e20180..9ce95f96 100644 --- a/frontend-v2/src/modules/pkb/pages/KnowledgePage.tsx +++ b/frontend-v2/src/modules/pkb/pages/KnowledgePage.tsx @@ -298,6 +298,9 @@ export default KnowledgePage; + + + diff --git a/frontend-v2/src/modules/pkb/types/workspace.ts b/frontend-v2/src/modules/pkb/types/workspace.ts index 3efa6a51..45e6d64b 100644 --- a/frontend-v2/src/modules/pkb/types/workspace.ts +++ b/frontend-v2/src/modules/pkb/types/workspace.ts @@ -53,6 +53,9 @@ export interface BatchTemplate { + + + diff --git a/frontend-v2/src/modules/rvw/components/AgentModal.tsx b/frontend-v2/src/modules/rvw/components/AgentModal.tsx index 480b4458..769ababd 100644 --- a/frontend-v2/src/modules/rvw/components/AgentModal.tsx +++ b/frontend-v2/src/modules/rvw/components/AgentModal.tsx @@ -134,3 +134,6 @@ export default function AgentModal({ visible, taskCount, onClose, onConfirm }: A + + + diff --git a/frontend-v2/src/modules/rvw/components/BatchToolbar.tsx b/frontend-v2/src/modules/rvw/components/BatchToolbar.tsx index a82c28b4..db9c81f2 100644 --- a/frontend-v2/src/modules/rvw/components/BatchToolbar.tsx +++ b/frontend-v2/src/modules/rvw/components/BatchToolbar.tsx @@ -54,3 +54,6 @@ export default function BatchToolbar({ selectedCount, onRunBatch, onClearSelecti + + + diff --git a/frontend-v2/src/modules/rvw/components/FilterChips.tsx b/frontend-v2/src/modules/rvw/components/FilterChips.tsx index 5e22b3eb..a1d87440 100644 --- a/frontend-v2/src/modules/rvw/components/FilterChips.tsx +++ b/frontend-v2/src/modules/rvw/components/FilterChips.tsx @@ -77,3 +77,6 @@ export default function FilterChips({ filters, counts, onFilterChange }: FilterC + + + diff --git a/frontend-v2/src/modules/rvw/components/Header.tsx b/frontend-v2/src/modules/rvw/components/Header.tsx index 859a5c91..9423f738 100644 --- a/frontend-v2/src/modules/rvw/components/Header.tsx +++ b/frontend-v2/src/modules/rvw/components/Header.tsx @@ -67,3 +67,6 @@ export default function Header({ onUpload }: HeaderProps) { + + + diff --git a/frontend-v2/src/modules/rvw/components/ReportDetail.tsx b/frontend-v2/src/modules/rvw/components/ReportDetail.tsx index f41d377a..2ed05e16 100644 --- a/frontend-v2/src/modules/rvw/components/ReportDetail.tsx +++ b/frontend-v2/src/modules/rvw/components/ReportDetail.tsx @@ -121,3 +121,6 @@ export default function ReportDetail({ report, onBack }: ReportDetailProps) { + + + diff --git a/frontend-v2/src/modules/rvw/components/ScoreRing.tsx b/frontend-v2/src/modules/rvw/components/ScoreRing.tsx index 6e227627..05b4195d 100644 --- a/frontend-v2/src/modules/rvw/components/ScoreRing.tsx +++ b/frontend-v2/src/modules/rvw/components/ScoreRing.tsx @@ -49,3 +49,6 @@ export default function ScoreRing({ score, size = 'medium', showLabel = true }: + + + diff --git a/frontend-v2/src/modules/rvw/components/Sidebar.tsx b/frontend-v2/src/modules/rvw/components/Sidebar.tsx index 4e4b7bbc..e42e106f 100644 --- a/frontend-v2/src/modules/rvw/components/Sidebar.tsx +++ b/frontend-v2/src/modules/rvw/components/Sidebar.tsx @@ -84,3 +84,6 @@ export default function Sidebar({ currentView, onViewChange, onSettingsClick }: + + + diff --git a/frontend-v2/src/modules/rvw/components/index.ts b/frontend-v2/src/modules/rvw/components/index.ts index db697d2a..efa447ed 100644 --- a/frontend-v2/src/modules/rvw/components/index.ts +++ b/frontend-v2/src/modules/rvw/components/index.ts @@ -26,3 +26,6 @@ export { default as TaskDetail } from './TaskDetail'; + + + diff --git a/frontend-v2/src/modules/rvw/pages/Dashboard.tsx b/frontend-v2/src/modules/rvw/pages/Dashboard.tsx index 225b7802..9fdc5514 100644 --- a/frontend-v2/src/modules/rvw/pages/Dashboard.tsx +++ b/frontend-v2/src/modules/rvw/pages/Dashboard.tsx @@ -295,3 +295,6 @@ export default function Dashboard() { + + + diff --git a/frontend-v2/src/modules/rvw/styles/index.css b/frontend-v2/src/modules/rvw/styles/index.css index db7e3a98..49d0faf6 100644 --- a/frontend-v2/src/modules/rvw/styles/index.css +++ b/frontend-v2/src/modules/rvw/styles/index.css @@ -244,3 +244,6 @@ + + + diff --git a/frontend-v2/src/pages/admin/tenants/TenantListPage.tsx b/frontend-v2/src/pages/admin/tenants/TenantListPage.tsx index fe91e18c..59fea505 100644 --- a/frontend-v2/src/pages/admin/tenants/TenantListPage.tsx +++ b/frontend-v2/src/pages/admin/tenants/TenantListPage.tsx @@ -345,3 +345,6 @@ export default TenantListPage; + + + diff --git a/frontend-v2/src/pages/admin/tenants/api/tenantApi.ts b/frontend-v2/src/pages/admin/tenants/api/tenantApi.ts index e6b8bd19..41667208 100644 --- a/frontend-v2/src/pages/admin/tenants/api/tenantApi.ts +++ b/frontend-v2/src/pages/admin/tenants/api/tenantApi.ts @@ -254,3 +254,6 @@ export async function fetchModuleList(): Promise { + + + diff --git a/frontend-v2/src/shared/components/Chat/AIStreamChat.tsx b/frontend-v2/src/shared/components/Chat/AIStreamChat.tsx index ea2b37db..123920df 100644 --- a/frontend-v2/src/shared/components/Chat/AIStreamChat.tsx +++ b/frontend-v2/src/shared/components/Chat/AIStreamChat.tsx @@ -473,3 +473,6 @@ export default AIStreamChat; + + + diff --git a/frontend-v2/src/shared/components/Chat/ConversationList.tsx b/frontend-v2/src/shared/components/Chat/ConversationList.tsx index 66679aa7..b460fbfd 100644 --- a/frontend-v2/src/shared/components/Chat/ConversationList.tsx +++ b/frontend-v2/src/shared/components/Chat/ConversationList.tsx @@ -173,3 +173,6 @@ export default ConversationList; + + + diff --git a/frontend-v2/src/shared/components/Chat/hooks/index.ts b/frontend-v2/src/shared/components/Chat/hooks/index.ts index ae03c6f1..4313d7ee 100644 --- a/frontend-v2/src/shared/components/Chat/hooks/index.ts +++ b/frontend-v2/src/shared/components/Chat/hooks/index.ts @@ -25,3 +25,6 @@ export type { + + + diff --git a/frontend-v2/src/shared/components/Chat/hooks/useAIStream.ts b/frontend-v2/src/shared/components/Chat/hooks/useAIStream.ts index 55996cf8..2972ad2e 100644 --- a/frontend-v2/src/shared/components/Chat/hooks/useAIStream.ts +++ b/frontend-v2/src/shared/components/Chat/hooks/useAIStream.ts @@ -317,3 +317,6 @@ export default useAIStream; + + + diff --git a/frontend-v2/src/shared/components/Chat/hooks/useConversations.ts b/frontend-v2/src/shared/components/Chat/hooks/useConversations.ts index e809f8e1..98980246 100644 --- a/frontend-v2/src/shared/components/Chat/hooks/useConversations.ts +++ b/frontend-v2/src/shared/components/Chat/hooks/useConversations.ts @@ -246,3 +246,6 @@ export default useConversations; + + + diff --git a/frontend-v2/src/shared/components/Chat/styles/ai-stream-chat.css b/frontend-v2/src/shared/components/Chat/styles/ai-stream-chat.css index f9d64d0f..dd407657 100644 --- a/frontend-v2/src/shared/components/Chat/styles/ai-stream-chat.css +++ b/frontend-v2/src/shared/components/Chat/styles/ai-stream-chat.css @@ -281,3 +281,6 @@ + + + diff --git a/frontend-v2/src/shared/components/Chat/styles/conversation-list.css b/frontend-v2/src/shared/components/Chat/styles/conversation-list.css index bba9eeac..2e861bec 100644 --- a/frontend-v2/src/shared/components/Chat/styles/conversation-list.css +++ b/frontend-v2/src/shared/components/Chat/styles/conversation-list.css @@ -217,3 +217,6 @@ + + + diff --git a/frontend-v2/src/shared/components/Chat/styles/thinking.css b/frontend-v2/src/shared/components/Chat/styles/thinking.css index 00fdcebc..33997f21 100644 --- a/frontend-v2/src/shared/components/Chat/styles/thinking.css +++ b/frontend-v2/src/shared/components/Chat/styles/thinking.css @@ -154,3 +154,6 @@ + + + diff --git a/frontend-v2/src/shared/components/index.ts b/frontend-v2/src/shared/components/index.ts index 2233bd51..c70cad03 100644 --- a/frontend-v2/src/shared/components/index.ts +++ b/frontend-v2/src/shared/components/index.ts @@ -65,6 +65,9 @@ export { default as Placeholder } from './Placeholder'; + + + diff --git a/frontend-v2/src/vite-env.d.ts b/frontend-v2/src/vite-env.d.ts index 6fe6fabf..f3fc0a50 100644 --- a/frontend-v2/src/vite-env.d.ts +++ b/frontend-v2/src/vite-env.d.ts @@ -45,6 +45,9 @@ interface ImportMeta { + + + diff --git a/frontend/src/pages/rvw/components/BatchToolbar.tsx b/frontend/src/pages/rvw/components/BatchToolbar.tsx index 17028a29..a3d938bd 100644 --- a/frontend/src/pages/rvw/components/BatchToolbar.tsx +++ b/frontend/src/pages/rvw/components/BatchToolbar.tsx @@ -56,3 +56,6 @@ export default function BatchToolbar({ selectedCount, onRunBatch, onClearSelecti + + + diff --git a/frontend/src/pages/rvw/components/EditorialReport.tsx b/frontend/src/pages/rvw/components/EditorialReport.tsx index 4a3b0fdf..2f1df6e0 100644 --- a/frontend/src/pages/rvw/components/EditorialReport.tsx +++ b/frontend/src/pages/rvw/components/EditorialReport.tsx @@ -121,3 +121,6 @@ export default function EditorialReport({ data }: EditorialReportProps) { + + + diff --git a/frontend/src/pages/rvw/components/FilterChips.tsx b/frontend/src/pages/rvw/components/FilterChips.tsx index deda01d8..6a9c32b6 100644 --- a/frontend/src/pages/rvw/components/FilterChips.tsx +++ b/frontend/src/pages/rvw/components/FilterChips.tsx @@ -79,3 +79,6 @@ export default function FilterChips({ filters, counts, onFilterChange }: FilterC + + + diff --git a/frontend/src/pages/rvw/components/Header.tsx b/frontend/src/pages/rvw/components/Header.tsx index 7c687797..40caa353 100644 --- a/frontend/src/pages/rvw/components/Header.tsx +++ b/frontend/src/pages/rvw/components/Header.tsx @@ -69,3 +69,6 @@ export default function Header({ onUpload }: HeaderProps) { + + + diff --git a/frontend/src/pages/rvw/components/ReportDetail.tsx b/frontend/src/pages/rvw/components/ReportDetail.tsx index fcdbfca1..441860d4 100644 --- a/frontend/src/pages/rvw/components/ReportDetail.tsx +++ b/frontend/src/pages/rvw/components/ReportDetail.tsx @@ -123,3 +123,6 @@ export default function ReportDetail({ report, onBack }: ReportDetailProps) { + + + diff --git a/frontend/src/pages/rvw/components/ScoreRing.tsx b/frontend/src/pages/rvw/components/ScoreRing.tsx index 88d6ca57..3c1d8df7 100644 --- a/frontend/src/pages/rvw/components/ScoreRing.tsx +++ b/frontend/src/pages/rvw/components/ScoreRing.tsx @@ -51,3 +51,6 @@ export default function ScoreRing({ score, size = 'medium', showLabel = true }: + + + diff --git a/frontend/src/pages/rvw/components/Sidebar.tsx b/frontend/src/pages/rvw/components/Sidebar.tsx index 792101f8..2bac850e 100644 --- a/frontend/src/pages/rvw/components/Sidebar.tsx +++ b/frontend/src/pages/rvw/components/Sidebar.tsx @@ -86,3 +86,6 @@ export default function Sidebar({ currentView, onViewChange, onSettingsClick }: + + + diff --git a/frontend/src/pages/rvw/index.ts b/frontend/src/pages/rvw/index.ts index e230d24a..3dc8e6b9 100644 --- a/frontend/src/pages/rvw/index.ts +++ b/frontend/src/pages/rvw/index.ts @@ -20,3 +20,6 @@ export * from './api'; + + + diff --git a/frontend/src/pages/rvw/styles.css b/frontend/src/pages/rvw/styles.css index 092440d2..9a865655 100644 --- a/frontend/src/pages/rvw/styles.css +++ b/frontend/src/pages/rvw/styles.css @@ -246,3 +246,6 @@ + + + diff --git a/git-cleanup-redcap.ps1 b/git-cleanup-redcap.ps1 index 7b19fcb0..c3ec4822 100644 --- a/git-cleanup-redcap.ps1 +++ b/git-cleanup-redcap.ps1 @@ -38,6 +38,9 @@ Write-Host "Next step: Run the commit command" -ForegroundColor Cyan + + + diff --git a/git-commit-day1.ps1 b/git-commit-day1.ps1 index 0097d645..0e79eed7 100644 --- a/git-commit-day1.ps1 +++ b/git-commit-day1.ps1 @@ -94,6 +94,9 @@ Write-Host "Git commit and push completed!" -ForegroundColor Green + + + diff --git a/git-fix-lock.ps1 b/git-fix-lock.ps1 index d96fe31d..62a12039 100644 --- a/git-fix-lock.ps1 +++ b/git-fix-lock.ps1 @@ -42,6 +42,9 @@ Write-Host "Now you can run git commands again." -ForegroundColor Cyan + + + diff --git a/python-microservice/operations/__init__.py b/python-microservice/operations/__init__.py index aff87dc6..b39bf070 100644 --- a/python-microservice/operations/__init__.py +++ b/python-microservice/operations/__init__.py @@ -67,6 +67,9 @@ __version__ = '1.0.0' + + + diff --git a/python-microservice/operations/binning.py b/python-microservice/operations/binning.py index 9919ec7a..0282df59 100644 --- a/python-microservice/operations/binning.py +++ b/python-microservice/operations/binning.py @@ -174,6 +174,9 @@ def apply_binning( + + + diff --git a/python-microservice/operations/filter.py b/python-microservice/operations/filter.py index 2339be30..c0118b22 100644 --- a/python-microservice/operations/filter.py +++ b/python-microservice/operations/filter.py @@ -160,6 +160,9 @@ def apply_filter( + + + diff --git a/python-microservice/operations/recode.py b/python-microservice/operations/recode.py index 9b7ee9dd..7a6278cd 100644 --- a/python-microservice/operations/recode.py +++ b/python-microservice/operations/recode.py @@ -130,6 +130,9 @@ def apply_recode( + + + diff --git a/recover_dc_code.py b/recover_dc_code.py index 0b862cd1..6d4b79ae 100644 --- a/recover_dc_code.py +++ b/recover_dc_code.py @@ -274,6 +274,9 @@ if __name__ == "__main__": + + + diff --git a/redcap-docker-dev/.gitattributes b/redcap-docker-dev/.gitattributes index 93e94698..505773ee 100644 --- a/redcap-docker-dev/.gitattributes +++ b/redcap-docker-dev/.gitattributes @@ -54,6 +54,9 @@ + + + diff --git a/redcap-docker-dev/.gitignore b/redcap-docker-dev/.gitignore index 17ed4d5d..d4475ef7 100644 --- a/redcap-docker-dev/.gitignore +++ b/redcap-docker-dev/.gitignore @@ -85,6 +85,9 @@ Desktop.ini + + + diff --git a/redcap-docker-dev/README.md b/redcap-docker-dev/README.md index 4f8779a6..32419337 100644 --- a/redcap-docker-dev/README.md +++ b/redcap-docker-dev/README.md @@ -386,6 +386,9 @@ docker-compose -f docker-compose.prod.yml up -d + + + diff --git a/redcap-docker-dev/docker-compose.prod.yml b/redcap-docker-dev/docker-compose.prod.yml index 1cf5786e..b26b62a6 100644 --- a/redcap-docker-dev/docker-compose.prod.yml +++ b/redcap-docker-dev/docker-compose.prod.yml @@ -147,6 +147,9 @@ volumes: + + + diff --git a/redcap-docker-dev/docker-compose.yml b/redcap-docker-dev/docker-compose.yml index b02380ee..f14f93a9 100644 --- a/redcap-docker-dev/docker-compose.yml +++ b/redcap-docker-dev/docker-compose.yml @@ -145,6 +145,9 @@ volumes: + + + diff --git a/redcap-docker-dev/env.template b/redcap-docker-dev/env.template index 659791ef..0646ed59 100644 --- a/redcap-docker-dev/env.template +++ b/redcap-docker-dev/env.template @@ -81,6 +81,9 @@ PMA_UPLOAD_LIMIT=50M + + + diff --git a/redcap-docker-dev/scripts/clean-redcap.ps1 b/redcap-docker-dev/scripts/clean-redcap.ps1 index 03af114d..b721c27b 100644 --- a/redcap-docker-dev/scripts/clean-redcap.ps1 +++ b/redcap-docker-dev/scripts/clean-redcap.ps1 @@ -89,6 +89,9 @@ Write-Host "" + + + diff --git a/redcap-docker-dev/scripts/create-redcap-password.php b/redcap-docker-dev/scripts/create-redcap-password.php index e35e2c15..32be9cae 100644 --- a/redcap-docker-dev/scripts/create-redcap-password.php +++ b/redcap-docker-dev/scripts/create-redcap-password.php @@ -67,6 +67,9 @@ try { + + + diff --git a/redcap-docker-dev/scripts/logs-redcap.ps1 b/redcap-docker-dev/scripts/logs-redcap.ps1 index c4f45cb7..f4f7a1fc 100644 --- a/redcap-docker-dev/scripts/logs-redcap.ps1 +++ b/redcap-docker-dev/scripts/logs-redcap.ps1 @@ -80,6 +80,9 @@ Write-Host "" + + + diff --git a/redcap-docker-dev/scripts/reset-admin-password.php b/redcap-docker-dev/scripts/reset-admin-password.php index 22b4c563..3fba784d 100644 --- a/redcap-docker-dev/scripts/reset-admin-password.php +++ b/redcap-docker-dev/scripts/reset-admin-password.php @@ -43,6 +43,9 @@ if ($result) { + + + diff --git a/redcap-docker-dev/scripts/start-redcap.ps1 b/redcap-docker-dev/scripts/start-redcap.ps1 index 77179aa5..6d5bc5c9 100644 --- a/redcap-docker-dev/scripts/start-redcap.ps1 +++ b/redcap-docker-dev/scripts/start-redcap.ps1 @@ -65,6 +65,9 @@ if ($LASTEXITCODE -eq 0) { + + + diff --git a/redcap-docker-dev/scripts/stop-redcap.ps1 b/redcap-docker-dev/scripts/stop-redcap.ps1 index 53051b9a..1c9e87cb 100644 --- a/redcap-docker-dev/scripts/stop-redcap.ps1 +++ b/redcap-docker-dev/scripts/stop-redcap.ps1 @@ -51,6 +51,9 @@ if ($LASTEXITCODE -eq 0) { + + + diff --git a/run_recovery.ps1 b/run_recovery.ps1 index d21943a9..aae6499c 100644 --- a/run_recovery.ps1 +++ b/run_recovery.ps1 @@ -98,6 +98,9 @@ Write-Host "==================================================================== + + + diff --git a/tests/QUICKSTART_快速开始.md b/tests/QUICKSTART_快速开始.md index 7fba61be..77c0d2da 100644 --- a/tests/QUICKSTART_快速开始.md +++ b/tests/QUICKSTART_快速开始.md @@ -145,6 +145,9 @@ INFO: Uvicorn running on http://0.0.0.0:8001 + + + diff --git a/tests/README_测试说明.md b/tests/README_测试说明.md index 1aa53050..28c39c53 100644 --- a/tests/README_测试说明.md +++ b/tests/README_测试说明.md @@ -301,6 +301,9 @@ df_numeric.to_excel('test_data/numeric_test.xlsx', index=False) + + + diff --git a/tests/run_tests.bat b/tests/run_tests.bat index d8bea01f..fa801d23 100644 --- a/tests/run_tests.bat +++ b/tests/run_tests.bat @@ -96,6 +96,9 @@ pause + + + diff --git a/tests/run_tests.sh b/tests/run_tests.sh index b273226a..927301ee 100644 --- a/tests/run_tests.sh +++ b/tests/run_tests.sh @@ -92,6 +92,9 @@ echo "========================================" + + + diff --git a/快速部署到SAE.md b/快速部署到SAE.md index 304bfab1..dd35c32d 100644 --- a/快速部署到SAE.md +++ b/快速部署到SAE.md @@ -357,6 +357,9 @@ OSS AccessKeySecret:_______________ + + + diff --git a/部署检查清单.md b/部署检查清单.md index 26a6b21f..f4b6ab1b 100644 --- a/部署检查清单.md +++ b/部署检查清单.md @@ -393,6 +393,9 @@ OSS配置: + + +