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. 修订记录
### 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- 第二轮评审修复
**Major(已修复):**
@@ -1069,6 +1085,6 @@ pm2 save
---
*文档版本:v1.2*
*文档版本:v1.3*
*创建时间:2026-06-26*
*更新时间:2026-06-26*
*更新时间:2026-06-28*
+5 -3
View File
@@ -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,
+2 -3
View File
@@ -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
+46
View File
@@ -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:
+81 -9
View File
@@ -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:
"""使缓存失效"""
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_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,))
# === 状态查询 ===
+3
View File
@@ -12,6 +12,9 @@ addopts =
--cov-report=term-missing
--cov-report=html
# asyncio 模式 - 自动处理异步 fixtures 和测试
asyncio_mode = auto
# 标记定义
markers =
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"""
content = """---
name: complex-page
description: Multi-line description
with multiple lines
description: |
Multi-line description
with multiple lines
tags:
- tag1
- tag2