Browse Source

feat: 实现 Ollama LLM 模式的 NER 提取

新增功能:
- 添加 ollama_service.py 实现本地 LLM NER 提取
- 支持长文本自动分块处理(默认 2000 字符/块)
- 分块重叠避免实体被截断
- 精心设计的 NER Prompt,输出结构化 JSON

配置项:
- NER_MODEL=ollama 启用 Ollama 模式
- OLLAMA_URL: Ollama 服务地址
- OLLAMA_MODEL: 推荐 qwen2.5:7b(中文能力最强)
- CHUNK_SIZE/CHUNK_OVERLAP: 分块参数

使用方式:
NER_MODEL=ollama OLLAMA_MODEL=qwen2.5:7b \
  uvicorn app.main:app --host 0.0.0.0 --port 8001
何文松 1 month ago
parent
commit
61eb71bc19

+ 58 - 0
python-services/ner-service/.env.example

@@ -0,0 +1,58 @@
+# NER 服务环境配置
+
+# ============================================
+# NER 模型配置
+# ============================================
+# 可选值: rule / ollama / spacy / transformers / api
+# - rule: 基于规则的简单 NER(开发测试用,速度快但准确率低)
+# - ollama: 使用本地 Ollama LLM(推荐生产环境,准确率高)
+# - api: 使用远程 API(如百炼、DeepSeek)
+NER_MODEL=ollama
+
+# ============================================
+# Ollama 配置(当 NER_MODEL=ollama 时使用)
+# ============================================
+# Ollama 服务地址
+OLLAMA_URL=http://localhost:11434
+
+# 使用的模型(推荐中文 NER)
+# - qwen2.5:7b(推荐,中文能力最强)
+# - qwen2.5:14b(更强,需要更多显存)
+# - llama3.1:8b(英文较好)
+OLLAMA_MODEL=qwen2.5:7b
+
+# 请求超时时间(秒)
+OLLAMA_TIMEOUT=120
+
+# ============================================
+# 文本分块配置(长文本处理)
+# ============================================
+# 每个分块的最大字符数
+CHUNK_SIZE=2000
+
+# 分块重叠字符数(避免实体被截断)
+CHUNK_OVERLAP=200
+
+# ============================================
+# API 配置(当 NER_MODEL=api 时使用)
+# ============================================
+# API 基础 URL
+# API_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
+
+# API 密钥
+# API_KEY=your-api-key
+
+# API 模型
+# API_MODEL=qwen-plus
+
+# ============================================
+# 日志配置
+# ============================================
+LOG_LEVEL=INFO
+
+# ============================================
+# 服务配置
+# ============================================
+HOST=0.0.0.0
+PORT=8001
+DEBUG=false

+ 70 - 9
python-services/ner-service/README.md

@@ -6,18 +6,52 @@
 
 - 实体提取:从文本中识别人名、机构、地点、日期、数值、设备等实体
 - 关系抽取:从实体间抽取语义关系
-- 多模式支持:规则模式(开发)、spaCy、Transformers、API
+- 多模式支持:
+  - **rule**:基于规则的简单 NER(开发测试用,速度快但准确率低)
+  - **ollama**:使用本地 Ollama LLM(推荐生产环境,准确率高)
+  - **api**:使用远程 API(如百炼、DeepSeek)
+- 长文本自动分块处理
 
 ## 快速开始
 
-### 本地运行
+### 1. 安装依赖
 
 ```bash
-# 安装依赖
+cd python-services/ner-service
 pip install -r requirements.txt
+```
+
+### 2. 配置环境
+
+```bash
+# 复制配置文件
+cp .env.example .env
 
-# 启动服务
-uvicorn app.main:app --host 0.0.0.0 --port 8001 --reload
+# 编辑配置(选择 NER 模式)
+vim .env
+```
+
+### 3. 启动服务
+
+```bash
+# 规则模式(默认,用于开发测试)
+NER_MODEL=rule uvicorn app.main:app --host 0.0.0.0 --port 8001
+
+# Ollama LLM 模式(推荐生产环境)
+NER_MODEL=ollama OLLAMA_MODEL=qwen2.5:7b uvicorn app.main:app --host 0.0.0.0 --port 8001
+```
+
+### 4. 确保 Ollama 服务可用(如使用 ollama 模式)
+
+```bash
+# 安装 Ollama(如未安装)
+curl -fsSL https://ollama.com/install.sh | sh
+
+# 拉取推荐模型
+ollama pull qwen2.5:7b
+
+# 确认服务运行
+curl http://localhost:11434/api/tags
 ```
 
 ## API 接口
@@ -82,22 +116,49 @@ Content-Type: application/json
 
 ## 配置说明
 
+### 基础配置
+
 | 配置项 | 说明 | 默认值 |
 |--------|------|--------|
-| NER_MODEL | NER 模型类型 | rule |
-| USE_GPU | 是否使用 GPU | false |
-| MAX_TEXT_LENGTH | 最大文本长度 | 50000 |
+| NER_MODEL | NER 模型类型 (rule/ollama/api) | rule |
 | LOG_LEVEL | 日志级别 | INFO |
+| MAX_TEXT_LENGTH | 最大文本长度 | 50000 |
+
+### Ollama 模式配置
+
+| 配置项 | 说明 | 默认值 |
+|--------|------|--------|
+| OLLAMA_URL | Ollama 服务地址 | http://localhost:11434 |
+| OLLAMA_MODEL | 使用的模型 | qwen2.5:7b |
+| OLLAMA_TIMEOUT | 请求超时(秒) | 120 |
+| CHUNK_SIZE | 分块大小(字符) | 2000 |
+| CHUNK_OVERLAP | 分块重叠(字符) | 200 |
+
+### 推荐模型
+
+| 模型 | 大小 | 中文能力 | 推荐场景 |
+|------|------|----------|----------|
+| qwen2.5:7b | ~4.7GB | ⭐⭐⭐⭐⭐ | 推荐,中文最强 |
+| qwen2.5:14b | ~9GB | ⭐⭐⭐⭐⭐ | 显存充足时 |
+| llama3.1:8b | ~4.7GB | ⭐⭐⭐ | 英文为主 |
 
 ## 测试
 
 ```bash
+# 运行测试
 pytest tests/ -v
+
+# 手动测试 NER
+curl -X POST http://localhost:8001/ner/extract \
+  -H "Content-Type: application/json" \
+  -d '{"documentId":"test", "text":"2024年5月15日,成都检测公司完成了环境监测项目", "extractRelations": true}'
 ```
 
 ## 开发计划
 
+- [x] 基于规则的 NER
+- [x] Ollama LLM 模式(支持长文本分块)
 - [ ] 集成 spaCy 中文模型
 - [ ] 集成 Transformers NER 模型
-- [ ] 实现 API 模式(DeepSeek/Qwen)
+- [ ] 实现远程 API 模式(百炼
 - [ ] 优化关系抽取准确率

+ 22 - 4
python-services/ner-service/app/config.py

@@ -17,19 +17,37 @@ class Settings(BaseSettings):
     port: int = 8001
     
     # NER 模型配置
-    ner_model: str = "rule"  # rule / spacy / transformers / api
-    ner_model_name: Optional[str] = None  # 具体模型名称
+    # rule: 基于规则的简单 NER(开发测试用)
+    # ollama: 使用本地 Ollama LLM(推荐生产环境)
+    # api: 使用远程 API(如百炼)
+    ner_model: str = "rule"
     use_gpu: bool = False
     max_text_length: int = 50000
     
-    # API 配置(用于 API 模式的后备方案)
+    # Ollama 配置(用于 ollama 模式)
+    ollama_url: str = "http://localhost:11434"
+    ollama_model: str = "qwen2.5:7b"  # 推荐中文 NER 使用 qwen2.5
+    ollama_timeout: int = 120  # 秒
+    
+    # 文本分块配置(用于长文本处理)
+    chunk_size: int = 2000  # 每个分块的最大字符数
+    chunk_overlap: int = 200  # 分块重叠字符数
+    
+    # API 配置(用于 api 模式)
     api_base_url: Optional[str] = None
     api_key: Optional[str] = None
     api_model: str = "qwen-plus"
     
     # 实体类型配置
     entity_types: List[str] = [
-        "PERSON", "ORG", "LOC", "DATE", "NUMBER", "DEVICE", "TERM", "PROJECT", "COMPANY"
+        "PERSON",   # 人名
+        "ORG",      # 机构/组织
+        "LOC",      # 地点
+        "DATE",     # 日期
+        "NUMBER",   # 数值
+        "DEVICE",   # 设备
+        "PROJECT",  # 项目
+        "METHOD",   # 方法/标准
     ]
     
     # 日志配置

+ 18 - 0
python-services/ner-service/app/services/ner_service.py

@@ -43,6 +43,8 @@ class NerService:
         
         if self.model_type == "rule":
             return await self._extract_by_rules(text, entity_types)
+        elif self.model_type == "ollama":
+            return await self._extract_by_ollama(text, entity_types)
         elif self.model_type == "spacy":
             return await self._extract_by_spacy(text, entity_types)
         elif self.model_type == "transformers":
@@ -186,6 +188,22 @@ class NerService:
         logger.info(f"规则 NER 提取完成: entity_count={len(entities)}")
         return entities
     
+    async def _extract_by_ollama(
+        self, 
+        text: str, 
+        entity_types: Optional[List[str]] = None
+    ) -> List[EntityInfo]:
+        """
+        使用本地 Ollama LLM 进行 NER 提取
+        支持长文本自动分块
+        """
+        try:
+            from .ollama_service import ollama_service
+            return await ollama_service.extract_entities(text, entity_types)
+        except Exception as e:
+            logger.error(f"Ollama NER 失败: {e},回退到规则模式")
+            return await self._extract_by_rules(text, entity_types)
+    
     async def _extract_by_spacy(
         self, 
         text: str, 

+ 254 - 0
python-services/ner-service/app/services/ollama_service.py

@@ -0,0 +1,254 @@
+"""
+Ollama LLM 服务
+用于调用本地 Ollama 模型进行 NER 提取
+"""
+import json
+import re
+import uuid
+import httpx
+from typing import List, Optional, Dict, Any
+from loguru import logger
+
+from ..config import settings
+from ..models import EntityInfo, PositionInfo
+
+
+class OllamaService:
+    """Ollama LLM 服务"""
+    
+    def __init__(self):
+        self.base_url = settings.ollama_url
+        self.model = settings.ollama_model
+        self.timeout = settings.ollama_timeout
+        self.chunk_size = settings.chunk_size
+        self.chunk_overlap = settings.chunk_overlap
+        logger.info(f"初始化 Ollama 服务: url={self.base_url}, model={self.model}")
+    
+    def _split_text(self, text: str) -> List[Dict[str, Any]]:
+        """
+        将长文本分割成多个块
+        
+        Args:
+            text: 原始文本
+            
+        Returns:
+            分块列表,每个块包含 text, start_pos, end_pos
+        """
+        if len(text) <= self.chunk_size:
+            return [{"text": text, "start_pos": 0, "end_pos": len(text)}]
+        
+        chunks = []
+        start = 0
+        
+        while start < len(text):
+            end = min(start + self.chunk_size, len(text))
+            
+            # 尝试在句号、换行处分割,避免截断句子
+            if end < len(text):
+                # 向前查找最近的分隔符
+                for sep in ['\n\n', '\n', '。', ';', '!', '?', '.']:
+                    sep_pos = text.rfind(sep, start + self.chunk_size // 2, end)
+                    if sep_pos > start:
+                        end = sep_pos + len(sep)
+                        break
+            
+            chunk_text = text[start:end]
+            chunks.append({
+                "text": chunk_text,
+                "start_pos": start,
+                "end_pos": end
+            })
+            
+            # 下一个块的起始位置(考虑重叠)
+            start = end - self.chunk_overlap if end < len(text) else end
+        
+        logger.info(f"文本分割完成: 总长度={len(text)}, 分块数={len(chunks)}")
+        return chunks
+    
+    def _build_ner_prompt(self, text: str, entity_types: Optional[List[str]] = None) -> str:
+        """
+        构建 NER 提取的 Prompt
+        """
+        types = entity_types or settings.entity_types
+        types_desc = ", ".join(types)
+        
+        prompt = f"""你是一个专业的命名实体识别(NER)系统。请从以下文本中提取实体。
+
+## 任务要求
+1. 识别以下类型的实体: {types_desc}
+2. 每个实体需要包含: 名称(name)、类型(type)、在文本中的起始位置(charStart)和结束位置(charEnd)
+3. 只提取明确的、有意义的实体,避免提取过于泛化的词汇
+4. 严格按照 JSON 格式输出
+
+## 实体类型说明
+- PERSON: 人名(如:张三、李经理)
+- ORG: 机构/组织/公司(如:成都检测公司、环保局)
+- LOC: 地点/地址(如:成都市、高新区)
+- DATE: 日期时间(如:2024年5月15日、2024-05-15)
+- NUMBER: 带单位的数值(如:50分贝、100万元)
+- DEVICE: 设备仪器(如:噪音检测仪、分析仪器)
+- PROJECT: 项目/工程(如:环境监测项目、XX工程)
+- METHOD: 方法/标准(如:GB/T 12345、检测方法)
+
+## 输出格式
+请严格按以下 JSON 格式输出,不要包含其他内容:
+```json
+{{
+  "entities": [
+    {{"name": "实体名称", "type": "实体类型", "charStart": 起始位置, "charEnd": 结束位置}}
+  ]
+}}
+```
+
+## 待处理文本
+{text}
+
+## 提取结果
+"""
+        return prompt
+    
+    async def _call_ollama(self, prompt: str) -> Optional[str]:
+        """
+        调用 Ollama API
+        """
+        url = f"{self.base_url}/api/generate"
+        payload = {
+            "model": self.model,
+            "prompt": prompt,
+            "stream": False,
+            "options": {
+                "temperature": 0.1,  # 低温度,更确定性的输出
+                "num_predict": 4096,  # 最大输出 token
+            }
+        }
+        
+        try:
+            async with httpx.AsyncClient(timeout=self.timeout) as client:
+                response = await client.post(url, json=payload)
+                response.raise_for_status()
+                result = response.json()
+                return result.get("response", "")
+        except httpx.TimeoutException:
+            logger.error(f"Ollama 请求超时: timeout={self.timeout}s")
+            return None
+        except Exception as e:
+            logger.error(f"Ollama 请求失败: {e}")
+            return None
+    
+    def _parse_llm_response(self, response: str, chunk_start_pos: int = 0) -> List[EntityInfo]:
+        """
+        解析 LLM 返回的 JSON 结果
+        
+        Args:
+            response: LLM 返回的文本
+            chunk_start_pos: 当前分块在原文中的起始位置(用于位置校正)
+        """
+        entities = []
+        
+        try:
+            # 尝试提取 JSON 部分
+            json_match = re.search(r'\{[\s\S]*\}', response)
+            if not json_match:
+                logger.warning("LLM 响应中未找到 JSON")
+                return entities
+            
+            json_str = json_match.group()
+            data = json.loads(json_str)
+            
+            entity_list = data.get("entities", [])
+            
+            for item in entity_list:
+                name = item.get("name", "").strip()
+                entity_type = item.get("type", "").upper()
+                char_start = item.get("charStart", 0)
+                char_end = item.get("charEnd", 0)
+                
+                if not name or len(name) < 2:
+                    continue
+                
+                # 校正位置(加上分块的起始位置)
+                adjusted_start = char_start + chunk_start_pos
+                adjusted_end = char_end + chunk_start_pos
+                
+                entity = EntityInfo(
+                    name=name,
+                    type=entity_type,
+                    value=name,
+                    position=PositionInfo(
+                        char_start=adjusted_start,
+                        char_end=adjusted_end,
+                        line=1  # LLM 模式不计算行号
+                    ),
+                    confidence=0.9,  # LLM 模式默认较高置信度
+                    temp_id=str(uuid.uuid4())[:8]
+                )
+                entities.append(entity)
+                
+        except json.JSONDecodeError as e:
+            logger.warning(f"JSON 解析失败: {e}, response={response[:200]}...")
+        except Exception as e:
+            logger.error(f"解析 LLM 响应失败: {e}")
+        
+        return entities
+    
+    async def extract_entities(
+        self, 
+        text: str, 
+        entity_types: Optional[List[str]] = None
+    ) -> List[EntityInfo]:
+        """
+        使用 Ollama LLM 提取实体
+        
+        支持长文本自动分块处理
+        """
+        if not text or not text.strip():
+            return []
+        
+        # 分割长文本
+        chunks = self._split_text(text)
+        
+        all_entities = []
+        seen_entities = set()  # 用于去重
+        
+        for i, chunk in enumerate(chunks):
+            logger.info(f"处理分块 {i+1}/{len(chunks)}: 长度={len(chunk['text'])}")
+            
+            # 构建 prompt
+            prompt = self._build_ner_prompt(chunk["text"], entity_types)
+            
+            # 调用 Ollama
+            response = await self._call_ollama(prompt)
+            
+            if not response:
+                logger.warning(f"分块 {i+1} Ollama 返回为空")
+                continue
+            
+            # 解析结果
+            entities = self._parse_llm_response(response, chunk["start_pos"])
+            
+            # 去重
+            for entity in entities:
+                entity_key = f"{entity.type}:{entity.name}"
+                if entity_key not in seen_entities:
+                    seen_entities.add(entity_key)
+                    all_entities.append(entity)
+            
+            logger.info(f"分块 {i+1} 提取实体: {len(entities)} 个")
+        
+        logger.info(f"Ollama NER 提取完成: 总实体数={len(all_entities)}")
+        return all_entities
+    
+    async def check_health(self) -> bool:
+        """
+        检查 Ollama 服务是否可用
+        """
+        try:
+            async with httpx.AsyncClient(timeout=5) as client:
+                response = await client.get(f"{self.base_url}/api/tags")
+                return response.status_code == 200
+        except Exception:
+            return False
+
+
+# 创建单例
+ollama_service = OllamaService()