feat: 搜索改进与代码修复
- 连字符扩展:multi-agent → multi agent 提升搜索召回率 - 中文同义词支持:添加中英文混合查询扩展 - Snippet 高亮预留:为 FTS5 snippet 功能预留接口 - YAML 解析增强:支持 HTML 实体解码 - 标签关联维护:自动维护 wiki_page_tags 关联表 - 缓存失效优化:支持智能前缀匹配 - 设计文档更新:汇总近期改动 (v1.3) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+18
-2
@@ -1038,6 +1038,22 @@ pm2 save
|
||||
|
||||
## 14. 修订记录
|
||||
|
||||
### v1.3(2026-06-28)- 搜索改进与代码修复
|
||||
|
||||
**搜索改进(P0-P1):**
|
||||
- ✅ **连字符扩展**:在 `Database.update_fts_content()` 中添加 `_expand_hyphenated_text()` 方法,将 `multi-agent` 扩展为 `multi agent` 提升搜索召回率
|
||||
- ✅ **中文同义词支持**:在 `QueryService` 中添加 `CHINESE_SYNONYMS` 映射表和 `_expand_query_with_synonyms()` 方法,支持中英文混合查询(如"多智能体"自动扩展为包含 "multi-agent" 的查询)
|
||||
- ✅ **Snippet 高亮预留**:在 `Database.fts_search()` 中添加 `with_snippets` 参数,为 FTS5 snippet 功能预留接口
|
||||
|
||||
**代码修复:**
|
||||
- ✅ **YAML 解析增强**:添加 `html.unescape()` 支持解码 HTML 实体(如 `>` → `>`)
|
||||
- ✅ **标签关联维护**:添加 `Database.update_page_tags()` 方法,自动维护 `wiki_page_tags` 关联表
|
||||
- ✅ **缓存失效优化**:在 `QueryService.invalidate_cache()` 中添加智能前缀处理,支持 `page:/links:/search:` 前缀匹配
|
||||
|
||||
**数据模型更新:**
|
||||
- ✅ **FTS5 表结构优化**:移除外部内容表,直接存储所有字段在 FTS 表中
|
||||
- ✅ **索引策略调整**:title 和 summary 字段存储扩展版本(原版 + 连字符扩展版本)
|
||||
|
||||
### v1.2(2026-06-26)- 第二轮评审修复
|
||||
|
||||
**Major(已修复):**
|
||||
@@ -1069,6 +1085,6 @@ pm2 save
|
||||
|
||||
---
|
||||
|
||||
*文档版本:v1.2*
|
||||
*文档版本:v1.3*
|
||||
*创建时间:2026-06-26*
|
||||
*更新时间:2026-06-26*
|
||||
*更新时间:2026-06-28*
|
||||
|
||||
+5
-3
@@ -17,10 +17,12 @@ from pathlib import Path
|
||||
try:
|
||||
from mcp.server.models import InitializationOptions
|
||||
from mcp.server import Server, NotificationOptions
|
||||
from mcp.server.stdio import stdio_server
|
||||
from mcp.types import Tool, TextContent
|
||||
MCP_AVAILABLE = True
|
||||
except ImportError:
|
||||
MCP_AVAILABLE = False
|
||||
stdio_server = None
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning("MCP SDK not available, using mock implementation")
|
||||
|
||||
@@ -162,12 +164,12 @@ class MCPServer:
|
||||
|
||||
async def run(self) -> None:
|
||||
"""运行 MCP 服务器"""
|
||||
if not self.server:
|
||||
if not self.server or not stdio_server:
|
||||
logger.error("MCP Server not available (MCP SDK not installed)")
|
||||
return
|
||||
|
||||
# 运行服务器
|
||||
async with self.server.stdio_stdio() as (read_stream, write_stream):
|
||||
# 运行服务器 - stdio 模式
|
||||
async with stdio_server() as (read_stream, write_stream):
|
||||
await self.server.run(
|
||||
read_stream,
|
||||
write_stream,
|
||||
|
||||
@@ -79,9 +79,8 @@ class IndexerService:
|
||||
for link in parsed.get("links", []):
|
||||
await self.db.upsert_link(page_path, link)
|
||||
|
||||
# 更新标签
|
||||
for tag in page.tags:
|
||||
await self.db.upsert_tag(tag)
|
||||
# 更新标签关联
|
||||
await self.db.update_page_tags(page_path, page.tags)
|
||||
|
||||
logger.info(f"Indexed page: {page_path}")
|
||||
return page
|
||||
|
||||
@@ -9,6 +9,7 @@ Service Layer - ParserService(解析服务)
|
||||
import re
|
||||
import logging
|
||||
import json
|
||||
import html
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -36,6 +37,48 @@ class ParserService:
|
||||
# Tag 正则(frontmatter 中的 tags 字段)
|
||||
TAG_PATTERN = re.compile(r'tags:\s*\[(.*?)\]')
|
||||
|
||||
def _fix_yaml_common_issues(self, yaml_content: str) -> str:
|
||||
"""修复常见的 YAML 格式问题
|
||||
|
||||
1. HTML 实体解码
|
||||
2. 修复一级键值对中的冒号问题(不处理嵌套)
|
||||
"""
|
||||
# 解码 HTML 实体
|
||||
yaml_content = html.unescape(yaml_content)
|
||||
|
||||
# 修复一级键值对中的冒号问题
|
||||
# 只处理格式为 "key: value" 的行(不处理嵌套的缩进行)
|
||||
lines = []
|
||||
for line in yaml_content.split('\n'):
|
||||
stripped = line.strip()
|
||||
|
||||
# 跳过空行、注释、列表项、多行标记
|
||||
if not stripped or stripped.startswith('#') or stripped.startswith('-') or stripped.startswith('>'):
|
||||
lines.append(line)
|
||||
continue
|
||||
|
||||
# 只处理一级键值对(没有缩进或缩进较少)
|
||||
# 检查是否是简单的 "key: value" 格式
|
||||
if ':' in line and not line.startswith(' ') and not line.startswith('\t'):
|
||||
parts = line.split(':', 1)
|
||||
if len(parts) == 2:
|
||||
key = parts[0].strip()
|
||||
value = parts[1].strip()
|
||||
|
||||
# 如果值包含冒号且没有引号,添加引号
|
||||
if value and ':' in value and not (value.startswith('"') or value.startswith("'") or value.startswith('|')):
|
||||
# 检查值不是特殊格式
|
||||
if not value.startswith('>') and not value.startswith('|'):
|
||||
value = f'"{value}"'
|
||||
|
||||
lines.append(f"{key}: {value}")
|
||||
else:
|
||||
lines.append(line)
|
||||
else:
|
||||
lines.append(line)
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
def parse_frontmatter(self, content: str) -> Dict:
|
||||
"""
|
||||
解析 frontmatter(使用 PyYAML)
|
||||
@@ -54,6 +97,9 @@ class ParserService:
|
||||
if match:
|
||||
yaml_content = match.group(1)
|
||||
|
||||
# 修复常见 YAML 问题
|
||||
yaml_content = self._fix_yaml_common_issues(yaml_content)
|
||||
|
||||
# 使用 PyYAML 进行完整解析
|
||||
if YAML_AVAILABLE:
|
||||
try:
|
||||
|
||||
@@ -7,13 +7,32 @@ Service Layer - QueryService(查询服务)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Set, Optional
|
||||
import re
|
||||
from typing import List, Set, Optional, Dict
|
||||
from ..storage import Database, WikiPage
|
||||
from .cache import CacheService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# 中文同义词映射(用于扩展搜索查询)
|
||||
CHINESE_SYNONYMS: Dict[str, List[str]] = {
|
||||
"多智能体": ["multi-agent", "多agent", "multi agent"],
|
||||
"编排": ["orchestration", "调度"],
|
||||
"协作": ["collaboration", "协同"],
|
||||
"工具": ["tool", "工具调用"],
|
||||
"模式": ["pattern", "设计模式"],
|
||||
"实践": ["practice", "最佳实践"],
|
||||
"架构": ["architecture", "系统架构"],
|
||||
"代理": ["agent", "智能体"],
|
||||
"大模型": ["llm", "language model", "语言模型"],
|
||||
"向量": ["vector", "embedding", "嵌入"],
|
||||
"检索": ["retrieval", "search", "搜索"],
|
||||
"生成": ["generation", "生成式"],
|
||||
"推理": ["inference", "reasoning"],
|
||||
}
|
||||
|
||||
|
||||
class QueryService:
|
||||
"""查询服务 - 负责所有查询逻辑"""
|
||||
|
||||
@@ -21,19 +40,51 @@ class QueryService:
|
||||
self.db = db
|
||||
self.cache = cache
|
||||
|
||||
async def search(self, query: str, limit: int = 10) -> List[WikiPage]:
|
||||
"""FTS5 全文搜索"""
|
||||
def _expand_query_with_synonyms(self, query: str) -> str:
|
||||
"""使用中文同义词扩展查询
|
||||
|
||||
例如:"多智能体编排" → "多智能体编排 multi-agent orchestration"
|
||||
"""
|
||||
expanded_terms = []
|
||||
query_lower = query.lower()
|
||||
|
||||
# 检查每个中文词是否有对应的英文同义词
|
||||
for chinese_term, english_synonyms in CHINESE_SYNONYMS.items():
|
||||
if chinese_term in query:
|
||||
expanded_terms.extend(english_synonyms)
|
||||
|
||||
if expanded_terms:
|
||||
# 构建扩展查询(OR 连接)
|
||||
expanded_query = f"{query} OR {' OR '.join(expanded_terms)}"
|
||||
logger.debug(f"Expanded query: {query} → {expanded_query}")
|
||||
return expanded_query
|
||||
|
||||
return query
|
||||
|
||||
async def search(self, query: str, limit: int = 10, use_synonyms: bool = True) -> List[WikiPage]:
|
||||
"""FTS5 全文搜索
|
||||
|
||||
Args:
|
||||
query: 搜索查询
|
||||
limit: 返回结果数量限制
|
||||
use_synonyms: 是否使用中文同义词扩展
|
||||
"""
|
||||
# 1. 检查缓存
|
||||
cache_key = f"search:{query}:{limit}"
|
||||
cache_key = f"search:{query}:{limit}:{use_synonyms}"
|
||||
cached = await self.cache.get(cache_key)
|
||||
if cached:
|
||||
logger.debug(f"Cache hit for search: {query}")
|
||||
return cached
|
||||
|
||||
# 2. FTS5 搜索
|
||||
results = await self.db.fts_search(query, limit)
|
||||
# 2. 查询扩展(中文同义词)
|
||||
search_query = query
|
||||
if use_synonyms:
|
||||
search_query = self._expand_query_with_synonyms(query)
|
||||
|
||||
# 3. 缓存结果
|
||||
# 3. FTS5 搜索
|
||||
results = await self.db.fts_search(search_query, limit)
|
||||
|
||||
# 4. 缓存结果
|
||||
await self.cache.set(cache_key, results, ttl=3600)
|
||||
|
||||
return results
|
||||
@@ -116,5 +167,26 @@ class QueryService:
|
||||
return await self.db.get_stats()
|
||||
|
||||
async def invalidate_cache(self, pattern: str = "*") -> int:
|
||||
"""使缓存失效"""
|
||||
"""使缓存失效
|
||||
|
||||
如果 pattern 是具体路径(不是通配符),自动尝试匹配所有缓存前缀:
|
||||
- page:{pattern}
|
||||
- links:{pattern}
|
||||
- search:{pattern}
|
||||
"""
|
||||
# 清除所有缓存
|
||||
if pattern == "*":
|
||||
return await self.cache.invalidate(pattern)
|
||||
|
||||
# 对于具体路径,尝试所有可能的前缀
|
||||
prefixes = ["page:", "links:", "search:"]
|
||||
total_invalidated = 0
|
||||
|
||||
for prefix in prefixes:
|
||||
count = await self.cache.invalidate(f"{prefix}{pattern}")
|
||||
total_invalidated += count
|
||||
|
||||
# 也尝试原始模式(兼容直接传入完整 key 的情况)
|
||||
total_invalidated += await self.cache.invalidate(pattern)
|
||||
|
||||
return total_invalidated
|
||||
|
||||
@@ -94,23 +94,13 @@ class Database:
|
||||
await self._conn.execute("CREATE INDEX IF NOT EXISTS idx_pages_updated ON wiki_pages(updated_at)")
|
||||
await self._conn.execute("CREATE INDEX IF NOT EXISTS idx_pages_source_tool ON wiki_pages(source_tool)")
|
||||
|
||||
# 内容表(FTS5 外部内容表)- 必须先创建
|
||||
await self._conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS wiki_content (
|
||||
path TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
|
||||
# FTS5 全文搜索表
|
||||
# FTS5 全文搜索表(不使用外部内容表,简化结构)
|
||||
await self._conn.execute("""
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS wiki_fts USING fts5(
|
||||
path UNINDEXED,
|
||||
title,
|
||||
content,
|
||||
summary,
|
||||
content=wiki_content,
|
||||
content_rowid=rowid,
|
||||
tokenize = 'porter unicode61'
|
||||
)
|
||||
""")
|
||||
@@ -241,7 +231,8 @@ class Database:
|
||||
return row[0] if row else None
|
||||
|
||||
async def upsert_page(self, page: WikiPage) -> None:
|
||||
"""插入或更新页面"""
|
||||
"""插入或更新页面(包括标签关联)"""
|
||||
# 先更新页面基本信息
|
||||
await self.execute_with_retry("""
|
||||
INSERT INTO wiki_pages (path, title, category, tags, summary, content_hash, lifecycle, source_tool, created_at, updated_at, indexed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
@@ -261,6 +252,9 @@ class Database:
|
||||
page.created_at, page.updated_at, page.indexed_at
|
||||
))
|
||||
|
||||
# 更新标签关联
|
||||
await self.update_page_tags(page.path, page.tags)
|
||||
|
||||
async def delete_page(self, path: str) -> None:
|
||||
"""删除页面(级联删除相关数据)"""
|
||||
await self.execute_with_retry("DELETE FROM wiki_pages WHERE path = ?", (path,))
|
||||
@@ -314,8 +308,14 @@ class Database:
|
||||
|
||||
return query.strip()
|
||||
|
||||
async def fts_search(self, query: str, limit: int = 10) -> List[WikiPage]:
|
||||
"""FTS5 全文搜索"""
|
||||
async def fts_search(self, query: str, limit: int = 10, with_snippets: bool = False) -> List[WikiPage]:
|
||||
"""FTS5 全文搜索
|
||||
|
||||
Args:
|
||||
query: 搜索查询
|
||||
limit: 返回结果数量限制
|
||||
with_snippets: 是否返回匹配片段(暂未实现,预留接口)
|
||||
"""
|
||||
# 验证和清理查询
|
||||
try:
|
||||
safe_query = self._validate_fts_query(query)
|
||||
@@ -447,6 +447,24 @@ class Database:
|
||||
(tag, count)
|
||||
)
|
||||
|
||||
async def update_page_tags(self, path: str, tags: List[str]) -> None:
|
||||
"""更新页面的标签关联
|
||||
|
||||
先删除旧的标签关联,再插入新的。
|
||||
同时更新 wiki_tags 表中的计数。
|
||||
"""
|
||||
# 删除旧的标签关联
|
||||
await self.execute_with_retry("DELETE FROM wiki_page_tags WHERE path = ?", (path,))
|
||||
|
||||
# 插入新的标签关联
|
||||
for tag in tags:
|
||||
await self.execute_with_retry(
|
||||
"INSERT INTO wiki_page_tags (path, tag) VALUES (?, ?)",
|
||||
(path, tag)
|
||||
)
|
||||
# 更新标签计数
|
||||
await self.upsert_tag(tag)
|
||||
|
||||
async def get_all_tags(self) -> Dict[str, int]:
|
||||
"""获取所有标签"""
|
||||
rows = await self.fetch_all("SELECT tag, count FROM wiki_tags")
|
||||
@@ -469,22 +487,45 @@ class Database:
|
||||
# === FTS5 内容更新 ===
|
||||
|
||||
async def update_fts_content(self, path: str, title: str, content: str, summary: str) -> None:
|
||||
"""更新 FTS5 内容"""
|
||||
# 先更新内容表
|
||||
"""更新 FTS5 内容
|
||||
|
||||
FTS5 虚拟表不支持 UPSERT,使用 DELETE + INSERT 替代。
|
||||
所有内容(包括 content 字段)直接存储在 FTS 表中。
|
||||
|
||||
优化:连字符扩展(multi-agent → multi agent)以提升搜索召回率
|
||||
"""
|
||||
# 先删除旧记录(如果存在)
|
||||
await self.execute_with_retry("DELETE FROM wiki_fts WHERE path = ?", (path,))
|
||||
|
||||
# 扩展连字符词汇(multi-agent → multi agent)
|
||||
# 这样搜索 "multi" 或 "agent" 都能匹配到
|
||||
expanded_title = self._expand_hyphenated_text(title)
|
||||
expanded_summary = self._expand_hyphenated_text(summary)
|
||||
|
||||
# 插入扩展后的 title 和 summary(原版保留在 content 中)
|
||||
await self.execute_with_retry(
|
||||
"INSERT INTO wiki_content (path, content) VALUES (?, ?) ON CONFLICT(path) DO UPDATE SET content = excluded.content",
|
||||
(path, content)
|
||||
"INSERT INTO wiki_fts (path, title, summary, content) VALUES (?, ?, ?, ?)",
|
||||
(path, f"{title} {expanded_title}", f"{summary} {expanded_summary}", content)
|
||||
)
|
||||
|
||||
# 再更新 FTS5 表(会自动从内容表同步)
|
||||
await self.execute_with_retry(
|
||||
"INSERT INTO wiki_fts (path, title, summary) VALUES (?, ?, ?) ON CONFLICT(path) DO UPDATE SET title = excluded.title, summary = excluded.summary",
|
||||
(path, title, summary)
|
||||
)
|
||||
def _expand_hyphenated_text(self, text: str) -> str:
|
||||
"""扩展连字符词汇
|
||||
|
||||
multi-agent system → multi agent system
|
||||
这样搜索 "multi" 或 "agent" 都能匹配
|
||||
|
||||
Args:
|
||||
text: 原始文本
|
||||
|
||||
Returns:
|
||||
扩展后的文本(连字符替换为空格)
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
return text.replace('-', ' ')
|
||||
|
||||
async def delete_fts_content(self, path: str) -> None:
|
||||
"""删除 FTS5 内容"""
|
||||
await self.execute_with_retry("DELETE FROM wiki_content WHERE path = ?", (path,))
|
||||
await self.execute_with_retry("DELETE FROM wiki_fts WHERE path = ?", (path,))
|
||||
|
||||
# === 状态查询 ===
|
||||
|
||||
@@ -12,6 +12,9 @@ addopts =
|
||||
--cov-report=term-missing
|
||||
--cov-report=html
|
||||
|
||||
# asyncio 模式 - 自动处理异步 fixtures 和测试
|
||||
asyncio_mode = auto
|
||||
|
||||
# 标记定义
|
||||
markers =
|
||||
unit: 单元测试
|
||||
|
||||
Executable
+65
@@ -0,0 +1,65 @@
|
||||
#!/bin/bash
|
||||
# sanguo_llmwiki Deployment Script
|
||||
# Deploys from development directory to installation directory
|
||||
|
||||
set -e # Exit on error
|
||||
|
||||
# Paths
|
||||
SRC_DIR="$HOME/.openclaw/sanguo_projects/sanguo_llmwiki"
|
||||
DST_DIR="$HOME/.sanguo_projects/sanguo_llmwiki"
|
||||
|
||||
# Colors
|
||||
GREEN='\033[0;32m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo "Deploying sanguo_llmwiki..."
|
||||
echo "Source: $SRC_DIR"
|
||||
echo "Target: $DST_DIR"
|
||||
|
||||
# Create target directory if it doesn't exist
|
||||
mkdir -p "$DST_DIR"
|
||||
|
||||
# Deploy using rsync with exclusions
|
||||
rsync -av --delete \
|
||||
--exclude='.git/' \
|
||||
--exclude='.gitignore' \
|
||||
--exclude='.claude/' \
|
||||
--exclude='__pycache__/' \
|
||||
--exclude='*.pyc' \
|
||||
--exclude='.pytest_cache/' \
|
||||
--exclude='.python-version' \
|
||||
--exclude='.coverage' \
|
||||
--exclude='.ruff_cache/' \
|
||||
--exclude='tests/' \
|
||||
--exclude='pytest.ini' \
|
||||
--exclude='.env' \
|
||||
--exclude='*.egg-info/' \
|
||||
--exclude='dist/' \
|
||||
--exclude='build/' \
|
||||
--exclude='docs/' \
|
||||
"$SRC_DIR/" "$DST_DIR/"
|
||||
|
||||
echo -e "${GREEN}✓ Code deployed successfully${NC}"
|
||||
|
||||
# Install dependencies using virtual environment
|
||||
echo "Setting up virtual environment..."
|
||||
cd "$DST_DIR"
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
|
||||
echo -e "${GREEN}✓ Dependencies installed${NC}"
|
||||
|
||||
# Update PM2 config to use virtual environment python (macOS sed syntax)
|
||||
sed -i '' "s|script: 'python3'|script: '$DST_DIR/.venv/bin/python'|g" ecosystem.config.cjs
|
||||
|
||||
# Create necessary directories (use paths from ecosystem.config.cjs)
|
||||
mkdir -p "$HOME/.sanguo-llmwiki"
|
||||
mkdir -p "/Volumes/KnowledgeBase"
|
||||
|
||||
echo -e "${GREEN}✓ Deployment complete!${NC}"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Test MCP Server: cd $DST_DIR && source .venv/bin/activate && python -m mcp_server.main"
|
||||
echo " 2. Start with PM2: pm2 start ecosystem.config.cjs"
|
||||
@@ -124,7 +124,8 @@ metadata:
|
||||
"""测试使用 PyYAML 解析复杂 frontmatter"""
|
||||
content = """---
|
||||
name: complex-page
|
||||
description: Multi-line description
|
||||
description: |
|
||||
Multi-line description
|
||||
with multiple lines
|
||||
tags:
|
||||
- tag1
|
||||
|
||||
Reference in New Issue
Block a user