cad.net 索引:倒序索引
DWG内容搜索引擎
概念性文章,不做任何运行保证,只做原理设计.
通过id找词叫正序索引,而反之就是倒序索引.
录入阶段
通过分词器把句子分词,map的key就是每个词,map的value就是文档号集合.
文档号是什么?
由于CAD的每个 单行文字/多行文字 的句柄不是多DWG唯一,
因此我们需要做一个数据表进行自增id保证唯一性,
句子的句柄在ES或者Lucene上面称为文档,本文的文档都是指它,而不是cad文档.
例如:
"卷积神经网络的特点是什么?"
"卷积" "神经" "网络" "的" "特点" "是" "什么" "?"
搜索阶段
把想搜索的句子也进行分词,相当于得到了多个key,
把每个分词并行给map,得到多个文档id,然后聚合,把文档id中命中数量高的提供,
再通过hashset进行消重文档id,就是命中的句子(单行文字/多行文字)了.
如果某个词的文档ids巨大,岂不是会卡死?
甚至有可能一个词上面拥有亿级别的数量?IO次数如何控制?
当搜索"卷积神经网络"时:
"卷积" → [doc1, doc3, doc5...] (1亿条)
"神经" → [doc2, doc3, doc5...] (8千万条)
"网络" → [doc1, doc3...] (9千万条)
直接合并这些海量ID列表会导致内存爆炸.
因此采用 RoaringBitmap稀疏表示+跳表+分段+增量编码 进行优化,只是读取区间表示.
由于是亿级别搜索,会把词位于句子位置加入进行判断相似度,也就是空间位置加权,
它的作用是筛选,用户只是关心关联度高的前十页内容,而不是全部句子.
例如:
"卷积神经网络的特点" (两词相邻)→ 高分
"卷积计算在神经网络中的应用" (间隔3个词)→ 中分
"神经网络的卷积实现" (词序颠倒)→ 低分
算法就是近似度计算: score += 1.0 / (distance + 1)
万一某个词出现在每个地方?
例如"的" "是",会在123456789...每个位置,这样岂不是这个算法缺陷?
因此这种叫停用词.
(不过在CAD检索系统似乎每每都是处理全词的所有数据库罢了)
理论就是如此简单,是所有搜索引擎的基础.
这就是人们常说的用空间换时间的方式,二次索引都是这样做.
其他的什么前缀树压缩,句子id连续性稀疏表示,分词均匀放入不同服务器...
我们就可以不用学那么深入了,可以自行了解
ES数据库原理视频
Lucene查询原理及解析
由于我们是单机或者少量公司内部联机,推荐使用Lucene,它比较小巧,而且也提供了上面的功能了.
LuceneNet
它里面自带分词器和储存层的,但是默认的中文分词器并不OK,会是单字.
结构如下
方便大家产生联想没有考虑持久化,因此不要作为最终效果.
// 倒排索引存储结构
ConcurrentDictionary<string, List<DocumentRef>> invertedIndex;
// 文档引用记录
class DocumentRef {
public string DocumentId { get; set; } // 文件路径/唯一ID
public string Handle { get; set; } // CAD句柄(如ObjectID)
public int Position { get; set; } // 词在文本中的位置(可选)
public DateTime LastUpdated { get; set; } // 最后修改时间
}
分词器
分词器在检索时候也用同一个,不然切割方式不一样就糟糕了.
.NET分词器很多的,只需要选择其中一款就好了,并且需要支持中文.
https://www.cnblogs.com/linezero/p/jiebanetcore.html
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
var segmenter = new JiebaSegmenter();
var segments = segmenter.Cut("我来到广州华南理工大学", cutAll: true);
Console.WriteLine("【全模式】:{0}", string.Join("/ ", segments));
结巴分词的默认配置可能不适合专业术语,如"GB50017-2017"会被拆分为单字,
需自定义词典或改用其他分词器,如IKAnalyzer
CAD事件
这个网站好看点:
http://mac.bb-mac.com/help/books/.net/AutoCAD.net/files/WS1a9193826455f5ff-e569a0121d1945c08-2024.htm
架构
我们的目的是:在全部磁盘的百万个DWG里面找到"2004年建筑规范",
最好了解一些Everything原理来避免遍历磁盘文件.
项目采用三级架构:
总库搜索引擎及持久化-项目库持久化-CAD搜索引擎.
没有打开的DWG是用总库的搜索引擎.
打开编辑中的DWG是当前CAD内部的搜索引擎.
项目库持久化:
为了复制剪切项目带有本项目索引,每个项目要有一个储存倒序索引的持久化文件.
sqliteDB或者Lucene文件.
粘贴之后会通过主动或者监控方式加入总库引擎中,移除项目也是同理.
总库持久化:
因为用户可能意外关闭图纸,再立即打开图纸,因此需要实现惰性写入总库.
为了防止意外退出CAD进程,因此需要看门狗机制.
看门狗会进行心跳检测,检测当前CAD是否已经关闭当前项目所有图纸,
并且只会在10分钟之后才会只读模式读取项目持久化文件.
CAD搜索引擎:
CAD内部你必须要用单线程方案,否则并行遍历无法重置迭代器.
1,打开DWG图纸后遍历全部文本,
通过分词器分词,写入字典,key是词,value是文字的id集合(一词多行).
2,通过不同的事件进行维护索引,
事件有两种,一种是图元事件,一种是数据库事件,
用后者比较简单,不需要更改每个图元.
数据库加入事件/数据库移除事件/数据库更改事件.
撤回和重做本质只是删除和更改!!也会触发对应的事件的!!
3,储存到项目持久化文件中,
不要储存到总库持久化文件中,它会自己监控项目持久化文件的.
编辑中的文档是不会实时保存,并且存在意外退出.
如果save才建立索引的话,那么我改了句子岂不是搜索不出来?
因此我们CAD内部是需要实时更改的一个纯内存map,
包括需要处理undo,redo,也就是维护索引.
如此一来,总库就可以单独一个服务进程.
CAD插件并行使用两个搜索引擎.
可以快速重建整个项目的索引.
通过查看索引文件的明码,还可以看见乱码发生在什么地方.
代码
此代码没有经过本人测试,不知道事件顺序,
所以可能根本跑不起来,只做原理说明而已
理想的 Lucene 代码
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.RegularExpressions;
using Lucene.Net.Analysis; // 文本分析(分词)相关
using Lucene.Net.Analysis.Standard; // 标准分词器
using Lucene.Net.Documents; // 文档数据结构
using Lucene.Net.Index; // 索引读写
using Lucene.Net.QueryParsers; // 查询解析
using Lucene.Net.Search; // 搜索功能
using Lucene.Net.Store; // 索引存储
using Lucene.Net.Util; // 基础工具
using NetDxf; // DWG文件解析
using NetDxf.Entities; // CAD实体
namespace DwgLuceneSearch
{
public class DwgTextIndexer
{
// Lucene版本常量,不同版本API可能不同
private const LuceneVersion AppLuceneVersion = LuceneVersion.LUCENE_48;
private readonly string _indexPath; // 索引文件存储路径
private readonly Analyzer _analyzer; // 分词器(将文本拆分为词元)
public DwgTextIndexer(string indexPath)
{
_indexPath = indexPath;
// 使用标准分词器(英文处理效果好,中文需要额外插件)
_analyzer = new StandardAnalyzer(AppLuceneVersion);
Directory.CreateDirectory(indexPath); // 确保目录存在
}
public void IndexDwgFile(string dwgFilePath)
{
// 加载DWG文件,这里得更换成Acad的才行,此处示意..
DxfDocument dxf = DxfDocument.Load(dwgFilePath);
if (dxf == null)
{
Console.WriteLine($"无法加载DWG文件: {dwgFilePath}");
return;
}
// 打开索引目录(FSDirectory表示文件系统存储)
var dir = FSDirectory.Open(_indexPath);
// 索引写入配置
var indexConfig = new IndexWriterConfig(AppLuceneVersion, _analyzer)
{
OpenMode = OpenMode.CREATE_OR_APPEND // 存在则追加,不存在则创建
};
// 创建索引写入器(核心写入组件)
using var writer = new IndexWriter(dir, indexConfig);
// 先删除该文件旧索引(避免重复)
writer.DeleteDocuments(new Term("filepath", dwgFilePath));
// 遍历所有单行文字实体
foreach (var text in dxf.Entities.Texts)
{
if (string.IsNullOrWhiteSpace(text.Value)) continue;
// 创建文档对象(相当于数据库中的一行记录)
var doc = new Document
{
// 字段说明:
// StringField - 不分词,完整存储,适合ID/路径等
// TextField - 要分词的文本内容
// StoredField - 只存储不索引(如坐标值)
new StringField("handle", text.Handle, Field.Store.YES), // CAD实体句柄
new TextField("content", text.Value, Field.Store.YES), // 文字内容(分词)
new StringField("layer", text.Layer.Name, Field.Store.YES), // 图层名
new StoredField("x", text.Position.X), // X坐标(只存储)
new StoredField("y", text.Position.Y), // Y坐标
new StoredField("z", text.Position.Z), // Z坐标
new StringField("filepath", dwgFilePath, Field.Store.YES), // 文件完整路径
new StringField("filename", Path.GetFileName(dwgFilePath), Field.Store.YES), // 文件名
// 复合唯一ID(路径+句柄)
new StringField("compositeId",
$"{Path.GetFullPath(dwgFilePath).ToUpperInvariant()}|{text.Handle}",
Field.Store.NO) // 不存储(因为可通过其他字段计算)
};
writer.AddDocument(doc); // 添加到索引
}
writer.Commit(); // 提交写入(确保数据持久化)
Console.WriteLine($"已索引: {dwgFilePath} (文本数: {dxf.Entities.Texts.Count})");
}
/// <summary>
/// 解析搜索输入,支持Google风格的site:语法
/// 示例: "钢筋 site:project1.dwg" 或 "site:*.dwg 混凝土"
/// </summary>
/// <param name="input">用户输入的搜索字符串</param>
/// <returns>元组(搜索词, 文件过滤条件)</returns>
private (string searchText, string fileFilter) ParseSearchInput(string input)
{
string searchText = input;
string fileFilter = null;
// 使用正则表达式匹配site:语法
var siteMatch = Regex.Match(input, @"site:\s*([^\s]+)", RegexOptions.IgnoreCase);
if (siteMatch.Success)
{
// 提取site:后面的文件名模式
fileFilter = siteMatch.Groups[1].Value;
// 从原始输入中移除site:部分
searchText = Regex.Replace(input, @"site:\s*[^\s]+", "", RegexOptions.IgnoreCase).Trim();
// 如果用户输入类似"site:*.dwg"这样的单独条件,保留*作为通配符
if (string.IsNullOrWhiteSpace(searchText))
{
searchText = "*"; // 搜索所有内容
}
}
return (searchText, fileFilter);
}
public List<Document> SearchDocuments(string input, int limit = 1000)
{
var results = new List<Document>();
// 解析输入,提取搜索词和文件过滤条件
var (searchText, fileFilter) = ParseSearchInput(input);
// 打开索引目录和读取器
using var dir = FSDirectory.Open(_indexPath);
using var reader = DirectoryReader.Open(dir); // 索引读取器
var searcher = new IndexSearcher(reader); // 搜索器
// 构建主查询(搜索文字内容)
BooleanQuery query = new BooleanQuery();
// 处理通配符搜索(当用户输入*时搜索所有内容)
if (searchText != "*")
{
query.Add(
new QueryParser(AppLuceneVersion, "content", _analyzer)
.Parse(QueryParser.Escape(searchText)), // 转义特殊字符
Occur.MUST); // MUST表示必须满足
}
// 添加文件路径过滤条件
if (!string.IsNullOrWhiteSpace(fileFilter))
{
// 如果用户没有输入通配符,自动添加*
if (!fileFilter.Contains("*") && !fileFilter.Contains("?"))
{
fileFilter = $"*{fileFilter}*";
}
query.Add(
new WildcardQuery(new Term("filename", fileFilter.ToLower())), // 统一小写匹配
Occur.MUST);
}
// 关键改进:使用复合ID分组去重
var groupSearch = new GroupingSearch("compositeId")
{
GroupSort = new Sort(new SortField("score", SortFieldType.SCORE, true)), // 按相关性降序
FillSortFields = true // 填充排序字段
};
// 执行搜索(0表示跳过前N个结果)
var topGroups = groupSearch.Search(searcher, query, 0, limit).Groups;
// 处理分组结果
foreach (var group in topGroups)
{
// 每组取最高分的文档
results.Add(searcher.Doc(group.Docs[0].Doc));
}
return results;
}
public void PrintResults(List<Document> results)
{
Console.WriteLine($"找到 {results.Count} 个匹配:");
foreach (var doc in results)
{
Console.WriteLine($"内容: {doc.Get("content")}");
Console.WriteLine($"文件: {doc.Get("filename")}");
Console.WriteLine($"图层: {doc.Get("layer")}");
Console.WriteLine($"句柄: {doc.Get("handle")}");
Console.WriteLine($"位置: ({doc.GetField("x")?.GetDoubleValue()}, " +
$"{doc.GetField("y")?.GetDoubleValue()}, " +
$"{doc.GetField("z")?.GetDoubleValue()})");
Console.WriteLine("----------------------------------");
}
}
}
class Program
{
static void Main(string[] args)
{
// 索引存放路径(当前程序目录下的LuceneIndex文件夹)
string indexPath = Path.Combine(Environment.CurrentDirectory, "LuceneIndex");
var indexer = new DwgTextIndexer(indexPath);
// 示例文件列表(实际使用时替换为真实DWG路径)
string[] sampleFiles = {
@"C:\CAD\project1.dwg",
@"C:\CAD\project2.dwg"
};
// 索引示例文件
foreach (var file in sampleFiles)
{
if (File.Exists(file))
{
indexer.IndexDwgFile(file);
}
}
// 交互式搜索界面
while (true)
{
Console.WriteLine("\n输入搜索命令(支持Google风格site:语法)");
Console.WriteLine("示例:");
Console.WriteLine(" 钢筋 搜索所有包含'钢筋'的文本");
Console.WriteLine(" 混凝土 site:proj1 搜索'混凝土'且文件名包含'proj1'");
Console.WriteLine(" site:*.dwg 列出所有DWG文件中的文本");
Console.Write("> ");
string input = Console.ReadLine()?.Trim();
// 退出条件
if (string.IsNullOrEmpty(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase))
break;
// 执行搜索并显示结果
try
{
var results = indexer.SearchDocuments(input);
indexer.PrintResults(results);
}
catch (Exception ex)
{
Console.WriteLine($"搜索错误: {ex.Message}");
}
}
}
}
}
CAD单线程方案维护索引
public class SddSetCommands {
HashSet<SingleDatabaseDictionary> SddSet = new();
[IFoxInitialize]
public void Initialize() {
// 启动的两种模式都要做:
// 1,通过注册表启动,必须用文档管理器加载文档事件,
// 等待界面完成,直到文档出现后触发事件,然后扫掠全部文档.
// 2,通过netload加载启动,需要尝试直接加入.
var dm = Acap.DocumentManager;
if (dm is null) return;
if (dm.Count != 0) {
foreach (Document doc in dm) {
HashAdd(doc);
}
}
// dm的是全局事件可以不卸载,doc和db事件则需要注意卸载
dm.DocumentCreated += DmCreated;
dm.DocumentToBeDestroyed -= DmDestroyed;
}
// 打开文档,如果数据库已经存盘就会加入,否则跳过
void DmCreated(object sender, DocumentCollectionEventArgs e) {
Env.Printl("DmCreated");
HashAdd((Document)sender);
}
// 文档关闭就释放对应的字典
void DmDestroyed(object sender, DocumentCollectionEventArgs e) {
Env.Printl("DmDestroyed");
var doc = (Document)sender;
LoadCommandEnded(doc, false);
SddSet.Remove(new(doc.Database));
}
void HashAdd(Document doc) {
if (doc is null)
throw new ArgumentNullException("doc is null");
if (doc.IsReadOnly) return;
// 未保存不加入
var file = Path.Combine(Path.GetDirectoryName(doc.Database.OriginalFileName), doc.Name);
var originEx = Path.GetExtension(file).ToLower();
if (originEx != ".dwg" || originEx != ".dxf") return;
var dict = new SingleDatabaseDictionary(doc.Database);
if (!SddSet.Contains(dict)) {
SddSet.Add(dict);
dict.Builder();
}
LoadCommandEnded(doc);
}
// todo 命令事件改为保存事件
bool LoadCommandEnded(Document doc, bool isload = true) {
if(isload) doc.CommandEnded += DocCommandEnded;
else doc.CommandEnded -= DocCommandEnded;
}
/// <summary>
/// 命令完成后(内锁文档)
/// </summary>
void DocCommandEnded(object sender, CommandEventArgs e) {
// 过滤噪声
if (string.IsNullOrEmpty(e.GlobalCommandName)
|| e.GlobalCommandName == "#")
return;
Env.Printl("DocCommandEnded");
Env.Printl(e.GlobalCommandName.ToUpper());
var doc = (Document)sender;
switch (e.GlobalCommandName.ToUpper()) {
case "SAVEAS":
// var num = Acap.GetSystemVariable("DBMode");
// if (num == ?)
// 保存数据库,就加入分析.
// 如果是关闭时候保存,就不加入啊.(不知道如何实现)
// 反正重复加入不进去HashSet
HashAdd(doc);
break;
}
}
// 查询
public void Find(string txt, Action<Database, HashSet<ObjectId>> action) {
var segments = SingleDatabaseDictionary.Segmenter.Cut(txt, cutAll: true); // 分词
Env.Printl("查询器分词:" + string.Join(" ", segments));
// 遍历每个数据库的字典(可以改为并行)
foreach (var dict in SddSet) {
// 命中词汇的行(文本ObjectId)
HashSet<ObjectId> set = new();
// 遍历每个词,多个词可能在同一行,通过hashset过滤
// 搜索引擎还可以根据出现数量来作为关联度,以此置顶.
foreach (var se in segments) {
if (dict.Words.TryGetValue(se, out var ids))
set.UnionWith(ids);
}
// Env.Printl("数据库:{dict.DwgFile}");
// Env.Printl("id是:{string.Join(" ", set)}");
action.Invoke(dict.Database, set); // 数据库用于事务,set用来修改
}
}
}
public class SingleDatabaseDictionary {
// 公开字段用于序列化
public static JiebaSegmenter Segmenter = new(); // 分词器
public Database Database;
public string DwgFile => Database.Filename; // 不缓存,保存之后会变
public Dictionary<string, HashSet<ObjectId>> Words; // 倒序索引
public override int GetHashCode() {
return DwgFile.GetHashCode();
}
public override bool Equals(object obj) {
if (obj is SingleDatabaseDictionary other)
return DwgFile == other.DwgFile;
return false;
}
public SingleDatabaseDictionary(Database db) {
if (db is null)
throw new ArgumentNullException("db is null");
Database = db;
Words = new();
}
// 直接打开图纸后遍历构建倒序索引
public void Builder() {
// 通过数据库事件,就不用附加事件到图元了
Database.ObjectAppended += DbAppended; // todo没有注销
Database.ObjectErased += DbErased; // 撤回时候删除
Database.ObjectModified += DBModified; // 撤回时候更改
Database.ObjectUnappended += DbUnappended;
Database.ObjectReappended += DbReappended;
using DBTrans tr = new(Database);
foreach (var bid in tr.BlockTable) {
if (!bid.IsOk()) continue;
using var btr = tr.GetObject<BlockTableRecord>(bid, OpenMode.ForRead);
foreach (var eid in btr) {
if (!eid.IsOk()) continue;
using var ent = tr.GetObject<Entity>(eid, OpenMode.ForRead);
if (ent is DBText dbtext) {
// ent.Modified += EntModified; // todo没有注销
// 和下面冲突 ent.ModifyUndone += EntOpenedForModify;
// ent.OpenedForModify += EntOpenedForModify;
AddNewly(dbtext.ObjectId, dbtext.TextString);
}
else if (ent is MText mtext) {
// ent.Modified += EntModified;
// 和下面冲突 ent.ModifyUndone += EntOpenedForModify;
// ent.OpenedForModify += EntOpenedForModify;
AddNewly(mtext.ObjectId, mtext.Text);
}
}
}
}
// 数据库内容新增
private void DbAppended(object sender, ObjectEventArgs e) {
Env.Printl("DbAppended");
if (e.DBObject is DBText dbtext) {
AddNewly(dbtext.ObjectId, dbtext.TextString);
}
else if (e.DBObject is MText mtext) {
AddNewly(mtext.ObjectId, mtext.Text);
}
}
/// <summary>
/// 撤回事件(获取删除对象)
/// 它会获取有修改步骤的图元id
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void DbErased(object sender, ObjectErasedEventArgs e) {
Env.Printl("DbErased");
// if (!State.IsRun) return;
if (e.Erased) return; // 跳过,否则无法读取图元信息
if (e.DBObject is DBText dbtext) {
RemoveOld(dbtext.ObjectId, dbtext.TextString);
}
else if (e.DBObject is MText mtext) {
RemoveOld(mtext.ObjectId, mtext.Text);
}
}
/// <summary>
/// 撤回事件(更改时触发)
/// 它会获取有修改步骤的图元id
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void DBModified(object sender, ObjectEventArgs e) {
Env.Printl("DBModified");
// if (!State.IsRun) return;
if (!e.DBObject.IsUndoing || e.DBObject.IsErased) return;
DbAppended(sender, e);
}
// 撤回事件,拉伸填充没有此事件,可能不需要.
// 只需要上面两个
private void DbUnappended(object sender, ObjectEventArgs e) {
Env.Printl("DbUnappended");
// DbErased(sender, e);
}
// 撤回后重做,拉伸填充没有此事件,可能不需要.
// 重做不过也是删除和修改,可能不需要
private void DbReappended(object sender, ObjectEventArgs e) {
Env.Printl("DbReappended");
// DbAppended(sender, e);
}
// 图元修改前,用数据库事件代替
//private void EntOpenedForModify(object sender, EventArgs e) {
// DbErased(sender, e);
//}
//// 图元修改后,用数据库事件代替
//private void EntModified(object sender, EventArgs e) {
// DbAppended(sender, e);
//}
private void RemoveOld(ObjectId id, string txt) {
var segments = Segmenter.Cut(txt, cutAll: true);
foreach (var item in segments) {
if (Words.ContainsKey(item)) {
Words[item].Remove(id);
if (Words[item].Count == 0)
Words.Remove(item);
}
}
}
private void AddNewly(ObjectId id, string txt) {
var segments = Segmenter.Cut(txt, cutAll: true);
foreach (var item in segments) {
if (!Words.ContainsKey(item))
Words.Add(item, new HashSet<ObjectId> { id });
else
Words[item].Add(id);
}
}
}
(完)
浙公网安备 33010602011771号