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);
        }
    }
}

(完)

posted @ 2024-12-01 18:55  惊惊  阅读(201)  评论(0)    收藏  举报