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:
2026-06-28 07:47:28 +08:00
parent b2c3ca02f0
commit 421b3ada3b
10 changed files with 289 additions and 44 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
## 状态 ## 状态
🚧 开发中 - Phase 3: 编码实现 ✅ Phase 5 完成 - 部署就绪
## 项目概述 ## 项目概述
+18 -2
View File
@@ -1038,6 +1038,22 @@ pm2 save
## 14. 修订记录 ## 14. 修订记录
### v1.32026-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 实体(如 `&gt;``>`
-**标签关联维护**:添加 `Database.update_page_tags()` 方法,自动维护 `wiki_page_tags` 关联表
-**缓存失效优化**:在 `QueryService.invalidate_cache()` 中添加智能前缀处理,支持 `page:/links:/search:` 前缀匹配
**数据模型更新:**
-**FTS5 表结构优化**:移除外部内容表,直接存储所有字段在 FTS 表中
-**索引策略调整**title 和 summary 字段存储扩展版本(原版 + 连字符扩展版本)
### v1.22026-06-26- 第二轮评审修复 ### v1.22026-06-26- 第二轮评审修复
**Major(已修复):** **Major(已修复):**
@@ -1069,6 +1085,6 @@ pm2 save
--- ---
*文档版本:v1.2* *文档版本:v1.3*
*创建时间:2026-06-26* *创建时间:2026-06-26*
*更新时间:2026-06-26* *更新时间:2026-06-28*
+5 -3
View File
@@ -17,10 +17,12 @@ from pathlib import Path
try: try:
from mcp.server.models import InitializationOptions from mcp.server.models import InitializationOptions
from mcp.server import Server, NotificationOptions from mcp.server import Server, NotificationOptions
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent from mcp.types import Tool, TextContent
MCP_AVAILABLE = True MCP_AVAILABLE = True
except ImportError: except ImportError:
MCP_AVAILABLE = False MCP_AVAILABLE = False
stdio_server = None
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.warning("MCP SDK not available, using mock implementation") logger.warning("MCP SDK not available, using mock implementation")
@@ -162,12 +164,12 @@ class MCPServer:
async def run(self) -> None: async def run(self) -> None:
"""运行 MCP 服务器""" """运行 MCP 服务器"""
if not self.server: if not self.server or not stdio_server:
logger.error("MCP Server not available (MCP SDK not installed)") logger.error("MCP Server not available (MCP SDK not installed)")
return return
# 运行服务器 # 运行服务器 - stdio 模式
async with self.server.stdio_stdio() as (read_stream, write_stream): async with stdio_server() as (read_stream, write_stream):
await self.server.run( await self.server.run(
read_stream, read_stream,
write_stream, write_stream,
+2 -3
View File
@@ -79,9 +79,8 @@ class IndexerService:
for link in parsed.get("links", []): for link in parsed.get("links", []):
await self.db.upsert_link(page_path, link) await self.db.upsert_link(page_path, link)
# 更新标签 # 更新标签关联
for tag in page.tags: await self.db.update_page_tags(page_path, page.tags)
await self.db.upsert_tag(tag)
logger.info(f"Indexed page: {page_path}") logger.info(f"Indexed page: {page_path}")
return page return page
+46
View File
@@ -9,6 +9,7 @@ Service Layer - ParserService(解析服务)
import re import re
import logging import logging
import json import json
import html
from typing import List, Dict, Optional, Tuple from typing import List, Dict, Optional, Tuple
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -36,6 +37,48 @@ class ParserService:
# Tag 正则(frontmatter 中的 tags 字段) # Tag 正则(frontmatter 中的 tags 字段)
TAG_PATTERN = re.compile(r'tags:\s*\[(.*?)\]') 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: def parse_frontmatter(self, content: str) -> Dict:
""" """
解析 frontmatter(使用 PyYAML 解析 frontmatter(使用 PyYAML
@@ -54,6 +97,9 @@ class ParserService:
if match: if match:
yaml_content = match.group(1) yaml_content = match.group(1)
# 修复常见 YAML 问题
yaml_content = self._fix_yaml_common_issues(yaml_content)
# 使用 PyYAML 进行完整解析 # 使用 PyYAML 进行完整解析
if YAML_AVAILABLE: if YAML_AVAILABLE:
try: try:
+81 -9
View File
@@ -7,13 +7,32 @@ Service Layer - QueryService(查询服务)
""" """
import logging import logging
from typing import List, Set, Optional import re
from typing import List, Set, Optional, Dict
from ..storage import Database, WikiPage from ..storage import Database, WikiPage
from .cache import CacheService from .cache import CacheService
logger = logging.getLogger(__name__) 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: class QueryService:
"""查询服务 - 负责所有查询逻辑""" """查询服务 - 负责所有查询逻辑"""
@@ -21,19 +40,51 @@ class QueryService:
self.db = db self.db = db
self.cache = cache self.cache = cache
async def search(self, query: str, limit: int = 10) -> List[WikiPage]: def _expand_query_with_synonyms(self, query: str) -> str:
"""FTS5 全文搜索""" """使用中文同义词扩展查询
例如:"多智能体编排""多智能体编排 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. 检查缓存 # 1. 检查缓存
cache_key = f"search:{query}:{limit}" cache_key = f"search:{query}:{limit}:{use_synonyms}"
cached = await self.cache.get(cache_key) cached = await self.cache.get(cache_key)
if cached: if cached:
logger.debug(f"Cache hit for search: {query}") logger.debug(f"Cache hit for search: {query}")
return cached return cached
# 2. FTS5 搜索 # 2. 查询扩展(中文同义词)
results = await self.db.fts_search(query, limit) 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) await self.cache.set(cache_key, results, ttl=3600)
return results return results
@@ -116,5 +167,26 @@ class QueryService:
return await self.db.get_stats() return await self.db.get_stats()
async def invalidate_cache(self, pattern: str = "*") -> int: async def invalidate_cache(self, pattern: str = "*") -> int:
"""使缓存失效""" """使缓存失效
return await self.cache.invalidate(pattern)
如果 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
+65 -24
View File
@@ -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_updated ON wiki_pages(updated_at)")
await self._conn.execute("CREATE INDEX IF NOT EXISTS idx_pages_source_tool ON wiki_pages(source_tool)") await self._conn.execute("CREATE INDEX IF NOT EXISTS idx_pages_source_tool ON wiki_pages(source_tool)")
# 内容表(FTS5 外部内容表)- 必须先创建 # FTS5 全文搜索表(不使用外部内容表,简化结构)
await self._conn.execute("""
CREATE TABLE IF NOT EXISTS wiki_content (
path TEXT PRIMARY KEY,
content TEXT NOT NULL
)
""")
# FTS5 全文搜索表
await self._conn.execute(""" await self._conn.execute("""
CREATE VIRTUAL TABLE IF NOT EXISTS wiki_fts USING fts5( CREATE VIRTUAL TABLE IF NOT EXISTS wiki_fts USING fts5(
path UNINDEXED, path UNINDEXED,
title, title,
content, content,
summary, summary,
content=wiki_content,
content_rowid=rowid,
tokenize = 'porter unicode61' tokenize = 'porter unicode61'
) )
""") """)
@@ -241,7 +231,8 @@ class Database:
return row[0] if row else None return row[0] if row else None
async def upsert_page(self, page: WikiPage) -> None: async def upsert_page(self, page: WikiPage) -> None:
"""插入或更新页面""" """插入或更新页面(包括标签关联)"""
# 先更新页面基本信息
await self.execute_with_retry(""" 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) INSERT INTO wiki_pages (path, title, category, tags, summary, content_hash, lifecycle, source_tool, created_at, updated_at, indexed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -261,6 +252,9 @@ class Database:
page.created_at, page.updated_at, page.indexed_at 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: async def delete_page(self, path: str) -> None:
"""删除页面(级联删除相关数据)""" """删除页面(级联删除相关数据)"""
await self.execute_with_retry("DELETE FROM wiki_pages WHERE path = ?", (path,)) await self.execute_with_retry("DELETE FROM wiki_pages WHERE path = ?", (path,))
@@ -314,8 +308,14 @@ class Database:
return query.strip() return query.strip()
async def fts_search(self, query: str, limit: int = 10) -> List[WikiPage]: async def fts_search(self, query: str, limit: int = 10, with_snippets: bool = False) -> List[WikiPage]:
"""FTS5 全文搜索""" """FTS5 全文搜索
Args:
query: 搜索查询
limit: 返回结果数量限制
with_snippets: 是否返回匹配片段(暂未实现,预留接口)
"""
# 验证和清理查询 # 验证和清理查询
try: try:
safe_query = self._validate_fts_query(query) safe_query = self._validate_fts_query(query)
@@ -447,6 +447,24 @@ class Database:
(tag, count) (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]: async def get_all_tags(self) -> Dict[str, int]:
"""获取所有标签""" """获取所有标签"""
rows = await self.fetch_all("SELECT tag, count FROM wiki_tags") rows = await self.fetch_all("SELECT tag, count FROM wiki_tags")
@@ -469,22 +487,45 @@ class Database:
# === FTS5 内容更新 === # === FTS5 内容更新 ===
async def update_fts_content(self, path: str, title: str, content: str, summary: str) -> None: 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( await self.execute_with_retry(
"INSERT INTO wiki_content (path, content) VALUES (?, ?) ON CONFLICT(path) DO UPDATE SET content = excluded.content", "INSERT INTO wiki_fts (path, title, summary, content) VALUES (?, ?, ?, ?)",
(path, content) (path, f"{title} {expanded_title}", f"{summary} {expanded_summary}", content)
) )
# 再更新 FTS5 表(会自动从内容表同步) def _expand_hyphenated_text(self, text: str) -> str:
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) 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: async def delete_fts_content(self, path: str) -> None:
"""删除 FTS5 内容""" """删除 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,)) await self.execute_with_retry("DELETE FROM wiki_fts WHERE path = ?", (path,))
# === 状态查询 === # === 状态查询 ===
+3
View File
@@ -12,6 +12,9 @@ addopts =
--cov-report=term-missing --cov-report=term-missing
--cov-report=html --cov-report=html
# asyncio 模式 - 自动处理异步 fixtures 和测试
asyncio_mode = auto
# 标记定义 # 标记定义
markers = markers =
unit: 单元测试 unit: 单元测试
+65
View File
@@ -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"
+3 -2
View File
@@ -124,8 +124,9 @@ metadata:
"""测试使用 PyYAML 解析复杂 frontmatter""" """测试使用 PyYAML 解析复杂 frontmatter"""
content = """--- content = """---
name: complex-page name: complex-page
description: Multi-line description description: |
with multiple lines Multi-line description
with multiple lines
tags: tags:
- tag1 - tag1
- tag2 - tag2