M0nkeyFl0wer/investigation-graph
GitHub: M0nkeyFl0wer/investigation-graph
一款完全本地运行的隐私优先知识图谱工具,帮助调查记者从文档中提取实体与关系并发现结构性缺口,无需任何云服务。
Stars: 0 | Forks: 0
# investigation-graph
一款隐私优先的知识图谱工具包,专为**调查记者、OSINT 调查员和研究人员**设计。导入文档,提取实体和关系,构建可搜索的图谱,并发现暗示线索的结构性缺口。完全在您的笔记本电脑上运行。
**无需云服务。无需账户。数据绝不离开您的设备。**
## 功能简介
您手头有一大堆文档——法庭卷宗、企业登记信息、泄露的电子邮件、公共记录。您需要找出其中的联系,更重要的是,找出*缺失*了什么。
这款工具包可以:
1. **导入**您的文档(PDF、文本、markdown、HTML)
2. **提取**人员、组织、交易以及它们之间的关系
3. 在您的本地机器上**构建**可搜索的知识图谱
4. **分析**图谱结构,寻找缺口、矛盾和令人惊讶的联系
5. 每天为您**简报** markdown 格式的图谱分析摘要
### 调查人员为什么需要这款工具
搜索回答的是*“‘Meridian’这个词在哪?”*。它无法回答*“是谁将 Chen 与 Meridian 联系起来,以及我缺少了哪份本该链接这两个群体的文档?”* 这些属于**关系和结构**问题——而**结构上的漏洞就是线索**。这款工具将成堆的文档转化为带有类型和来源溯源的图谱,让您可以看清案件的整体轮廓,找到隐藏的联系枢纽,并精准定位指向下一次调档请求的缺口。
我们刻意将其定位为**线索生成器和结构发现工具,而非结论来源**——在您发布之前,每一条联系都需要您对照原始文档进行核实。安全机制(它会*隔离*源文本不支持的声明,并合并重复的名称)之所以存在,是因为一个凭空捏造联系的自动化图谱会带来诽谤的风险。
## 快速开始
### 前置条件
- **Python 3.10 或更高版本**(检查:`python3 --version`)
- 已安装并运行 **[Ollama](https://ollama.com)**(在本地处理所有 AI 任务)
- **能基本适应命令行操作**(一切均在终端中运行)
### 安装设置
```
# Clone 仓库
git clone https://github.com/M0nkeyFl0wer/investigation-graph.git
cd investigation-graph
# 运行 setup(安装 Python packages —— 包括 kg-common substrate —— 并
# download 本地 AI models)
bash setup.sh
# 验证一切工作正常
python -m investigation_graph.check
```
您应该会看到:
```
investigation-graph system check
========================================
LadybugDB: 0.15.3
PyArrow: 23.0.1
spaCy: 3.8.14
spaCy model: en_core_web_sm OK
NetworkX: 3.6.1
Ripser: OK
DuckDB: 1.5.4
kg-common: 0.0.1
Ollama: OK (5 models)
Embedding model (nomic-embed-text, 768d): OK
Ontology: Ontology(8 entity types, 12 edge types)
All checks passed.
```
如果提示 NOT INSTALLED 或 MISSING,检查工具会准确告诉您该运行什么命令。
### 导入您的首批文档
请先尝试内置的示例调查(一个小型的虚构 Harbor City 贪腐案——本 README 的示例将带您逐步了解此案):
```
mkdir -p ingest
cp examples/sample-investigation/* ingest/
python scripts/ingest_folder.py
```
然后将其替换为您自己的文档:
```
# 将文档放入 ingest folder(PDF、text、markdown、HTML)
cp /path/to/your/documents/* ingest/
# 重新运行 ingestion(幂等 —— 干净地重新处理每个文档)
python scripts/ingest_folder.py
```
输出如下所示:
```
Scope: Ontology(8 entity types, 12 edge types)
Corpus: 3 document(s) in ingest/ → DuckDB chunks.duckdb
[1/3] harbor-city-expose.txt
2 chunks (2 embedded), 24 entities, 6 edges → DuckDB
[2/3] property-records.md
2 chunks (2 embedded), 20 entities, 4 edges → DuckDB
[3/3] financial-disclosure.html
2 chunks (2 embedded), 30 entities, 5 edges → DuckDB
Grounding 74 entities / 15 edges against 6 chunks...
========================================================
Ingestion complete in 61.4s.
Documents: 3
Chunks in DuckDB: 6
Entities (graph): 41 (merged 12 duplicates)
Edges (graph): 9
Quarantined: 21 entities (28%), 6 edges (40%) — failed the grounding gate
```
每个文档都会被分块并嵌入到 DuckDB 中,然后进行提取;**ground** 阶段会剔除源文本不支持的实体/边,并在构建图谱之前合并重复的名称。“Quarantined”(已隔离)这行正是验证机制在发挥作用——未能通过验证的提取声明绝不会进入图谱。(边数来自本地 LLM;如果 Ollama 不可用,您依然可以获得确定性提取的 + spaCy 实体以及一个支持关键词搜索的语料库。)
### 搜索图谱
```
# Keyword search —— 查找精确匹配
python scripts/search_cli.py -q "Acme Corp"
# Semantic search —— 即使没有 keyword 匹配也能找到相关内容
python scripts/search_cli.py -q "payments to contractors" --mode semantic
# Hybrid search —— 结合 keyword 和 semantic,取两者之长
python scripts/search_cli.py -q "financial fraud" --mode hybrid
# 查找两个实体之间的连接
python scripts/search_cli.py --path "Jane Smith" "Harbor Development LLC"
# 按实体类型 Filter
python scripts/search_cli.py -q "Chen" --type person
```
**路径搜索**展示了关系链:
```
Found 3 paths:
Path 1 (confidence: 0.36):
Chen --[EMPLOYED_BY]--> Brightpath Advisors --[FUNDED_BY]--> Meridian Holdings LLC
Path 2 (confidence: 0.22):
Chen --[EMPLOYED_BY]--> Brightpath Advisors --[FUNDED_BY]--> Harbor City
Redevelopment Authority --[OCCURRED_ON]--> Meridian Holdings LLC
```
每一跳都有一个置信度分数。路径置信度是所有跳数置信度的乘积——置信度越低,意味着连接的不确定性越高。
### 运行分析
```
python scripts/run_analysis.py
```
输出:
```
TOPOLOGY REPORT
============================================================
Entities: 80
Edges: 28
Connected components: 57
Largest component: 11 nodes
Communities (Louvain): 58
STRUCTURAL GAPS: 3
------------------------------------------------------------
[HIGH] Brightpath Advisors ↔ Harbor City Redevelopment Authority
7 entities ↔ 7 entities | cross-edges: 0
→ How do Brightpath Advisors and Harbor City Redevelopment
Authority relate? Your knowledge about these is not yet connected.
SURPRISING CONNECTIONS: 6
------------------------------------------------------------
Robert Chen (person)
Betweenness: 0.5111 | Degree: 4
→ Structurally important despite low frequency
```
**结构性缺口**是调查线索——即那些按理应该存在联系,但在您的数据中却没有关联的实体社区。那正是缺失文档所在的地方。
**令人惊讶的连接**是指具有高介数中心性(它们桥接着原本分离的网络)但低度数(它们很少出现在文档中)的实体。这些就是隐藏的联系枢纽。
### 每日简报
```
python scripts/daily_briefing.py
```
生成 `briefings/2026-04-04.md` ——一份 markdown 摘要,包含:
- 过去 24 小时内新增的实体
- 在不同来源之间发现的矛盾
- 结构性缺口(以调查问题的形式呈现)
- 令人惊讶的连接枢纽
- 需要关注的未连接实体
如果您配置了 Obsidian 仓库路径,简报会自动复制到那里。
### 本体健康度
```
python scripts/validate_ontology.py
```
显示您的本体与现实数据的匹配程度:
```
ICR (type coverage): 0.75 — warning (some declared types have no data)
CI (class imbalance): 0.34 — warning (dominant: organization at 27/80)
IPR (edge coverage): 0.57 — warning
Type distribution:
organization 27 ( 33.8%) ████████████████
transaction 25 ( 31.2%) ███████████████
event 16 ( 20.0%) ██████████
person 9 ( 11.2%) █████
Unpopulated types: asset, claim
```
- **ICR**(实例化类比例):您声明的类型中有多少具有实际数据。低于 0.8 意味着存在无效的 schema。
- **CI**(类别不平衡):如果某一种类型占据主导地位(>0.5),则您的提取过程可能对实体进行了错误分类。
- **IPR**(实例化属性比例):与 ICR 相同,但针对的是边类型。
## 七个阶段
### 1. 本体——什么才是重要的
编辑 `ONTOLOGY.md`,为您的报道领域或案件定义实体类型和关系类型。内置了一个通用的调查本体(新闻/OSINT/研究),涵盖 8 种实体类型和 12 种边类型。
系统在写入时会**拒绝不符合您本体的实体**——因此不会堆积垃圾数据。系统会对拒绝进行计数和报告,让您知道何时需要扩展本体。
每种类型都包含边界示例:
| 列名 | 用途 |
|--------|---------|
| Archetypical(原型) | 明确属于此类型 |
| Atypical(非典型) | 仍属于此类型的边缘情况 |
| Exotypical(异型) | 看起来相似但“不”属于此类型——指出它实际上应该属于哪种类型 |
Exotypical 示例可防止最常见的提取错误:将所有东西都倒进一个包罗万象的类型中。
### 2. 嵌入——语义理解
文档被分块(1000 个字符,重叠 200 个字符),并使用本地 AI 模型(Ollama + nomic-embed-text)转换为 768 维的向量。这些向量为语义搜索提供支持——根据含义查找文档,而不仅仅是关键词。
数据块、向量和全文(BM25)搜索都存储在 **DuckDB** 中(单个文件 `data/chunks.duckdb`)——这是混合搜索的基础。实体/边**图谱**存在于 LadybugDB 中,并从这些记录中重建。有关为什么采用这种双部分存储的原因,请参阅 [`docs/database-choice.md`](docs/database-choice.md);有关架构信息,请参阅 `SPEC.md`。如果 Ollama 不可用,导入过程依然会完成——数据块将被存储但不会被嵌入(关键词搜索仍然有效),而语义搜索将跳过它们。
### 3. 提取——三阶段实体提取
每个文档都会经历三个提取阶段:
**阶段 1 —— 确定性提取**(即时、免费、始终运行):
- 用于匹配日期、金额、电子邮件地址的正则表达式
- 从文档格式中进行结构提取
- 置信度:0.85-0.90(高——因为是基于模式匹配的)
**阶段 2 —— spaCy NER**(快速、本地、无需 GPU):
- 命名实体识别:人员、组织、地点
- 将 spaCy 标签映射到您的本体类型(PERSON → person,ORG → organization 等)
- 置信度:0.70(良好——NER 技术已经非常成熟)
**阶段 3 —— LLM**(较慢,通过 Ollama 在本地运行):
- 关系提取:谁与谁有联系,以及如何联系
- 类型细化:利用本体上下文纠正阶段 2 的错误分类
- 生成带类型的边(EMPLOYED_BY、FUNDED_BY、CONTRACTED_WITH 等)
- 置信度:0.60(较低——LLM 提取需要人工审核)
- 受本体约束:LLM prompt 包含所有类型和边界示例
每个实体都会记录其 `provenance`(由哪个阶段提取)和 `source_url`(来自哪份文档)。没有留下溯源轨迹的任何信息都无法进入图谱。
### 4. 质量控制——本体验证
每个实体在进入图谱之前,都会根据 `ONTOLOGY.md` 进行验证。如果提取流水线生成了一个标记为“weapon(武器)”的实体,但您的本体中不包含“weapon”,它将被拒绝,并且拒绝次数会被计入统计。
导入完成后,您将看到:
```
Ontology rejections (types not in ONTOLOGY.md):
weapon: 3 rejections
vehicle: 2 rejections
Tip: Consider adding frequently rejected types to ONTOLOGY.md
```
这会告诉您何时需要扩展本体——数据正在向您展示它实际包含的类型。
### 5. 搜索与路径——追踪联系
三种搜索模式:
| 模式 | 工作原理 | 最适用于 |
|------|-------------|----------|
| `keyword` | 对实体标签执行 Cypher `CONTAINS` 匹配 | 按名称查找特定实体 |
| `semantic` | 查询向量与实体向量之间的余弦相似度 | 在不知道名称的情况下查找相关实体 |
| `hybrid` | 倒数秩融合(RRF)——根据在两个列表中的位置进行排名,无需调整权重 | 最佳的通用搜索方式 |
**路径搜索**用于查找实体之间的类型化链条。不仅是“这些是相关的”,而是:
```
Jane Smith --[EMPLOYED_BY]--> Acme Corp --[CONTRACTED_WITH]--> Harbor Dev LLC
```
每条路径都有一个置信度得分(各边置信度的乘积)。两个实体之间存在多条路径通常意味着联系更紧密。
### 6. 拓扑——发现缺失的内容
图谱分析运行确定性算法——不涉及 AI,纯粹是对图谱结构进行数学计算:
| 算法 | 发现的内容 | 重要的原因 |
|-----------|--------------|----------------|
| 连通分量 | 图谱中分离的聚类 | 展示哪些调查是孤立的 |
| Louvain 社区 | 密集子群 | 自然的议题聚类 |
| 介数中心性 | 桥接不同社区的实体 | 隐藏的连接枢纽——连接原本分离网络的人员/组织 |
| 桥接检测 | 单点故障边 | 一旦移除就会断裂的脆弱连接 |
| 缺口检测 | 交叉边较少的社区对 | **线索**——您的调查中缺失的内容 |
| 持久同调 | 图谱中的拓扑空洞 | 高阶结构性缺口(需要 Ripser) |
**这些缺口就是故事的精髓。** 两个完全没有交叉边的大型社区意味着,您的文档涵盖了两个相关领域,但您尚未将它们联系起来。缺口问题能告诉您下一步该寻找什么。
### 7. 每日简报——图谱发现了什么
这是一份纯粹基于图谱结构生成的 markdown 文件。包含以下几个部分:
- **新实体**:过去 24 小时内添加的内容
- **矛盾**:来自不同来源声明之间的 `CONTRADICTS` 边
- **结构性缺口**:连接稀疏的社区对(以调查问题的形式呈现)
- **令人惊讶的连接**:低度数实体上的高介数中心性
- **未连接的实体**:存在超过 7 天但没有建立连接的实体(需要关注或移除)
可在 Obsidian、任何文本编辑器或终端中阅读。可选择自动复制到您的 Obsidian 仓库收件箱。
## 隐私
### 本地模式(默认)
一切都在您的机器上运行。没有网络连接。不需要 API 密钥。无需账户。
| 组件 | 运行位置 |
|-----------|--------------|
| 文档文本 | 留在本地磁盘 |
| 实体提取 | 在您的 CPU/GPU 上运行的 Ollama |
| 向量嵌入 | 在您的 CPU/GPU 上运行的 Ollama |
| 知识图谱 | 磁盘上的 LadybugDB 目录 |
| 向量搜索 | LadybugDB 原生功能(同一个数据库) |
| 分析 | 在您的 CPU 上运行的 Python(NetworkX) |
| 每日简报 | 写入本地磁盘 |
**何时使用:** 敏感消息源、泄露的文档,以及任何您不能冒险被传输出去的内容。
### 混合模式
向量嵌入保留在本地。对于非敏感文档,实体提取可以选择使用具有零数据保留(ZDR)策略的远程 LLM。在处理复杂文档时能提供更好的提取质量。
```
# 在 investigation_graph/config.py 中
PRIVACY_MODE = "hybrid"
REMOTE_API_BASE = "https://api.anthropic.com/v1"
REMOTE_MODEL = "claude-haiku-4-5-20251001"
# 将 NEWSROOM_API_KEY 设置为环境变量 —— 切勿 hardcode
```
**何时使用:** 混合使用公共记录(非敏感)和机密材料(敏感)。无论如何,图谱、嵌入和分析始终保留在本地。
### 远程模式
一切通过远程 API 完成。不建议用于敏感材料。
**何时使用:** 对纯公共数据集进行批量处理,且速度和质量比保密性更重要时。
有关详细的比较和提供商建议,请参阅 `docs/privacy-guide.md`。
### 伦理:身份模糊与消息源保护
自动化提取给任何发布调查结果的人(无论是记者、OSINT 调查员还是研究人员)带来的两个风险:
**身份模糊。** 流水线会将“John Smith”、“J. Smith”和“John S. Smith”提取为三个独立的实体它也可能将“BP US”和“British Petroleum”拆分为不同的组织。在发布任何基于图谱联系的发现之前,**请手动核实有联系的实体是否确实是同一个人或组织。** 错误归因自动化图谱中的联系可能会错误地指控个人。`config.py` 中的去重阈值(`DEDUP_THRESHOLD = 0.92`)可以通过向量相似性捕捉到一些重复项,但对于名称相似但指向不同人员的名字,这并不足够。
**三角测量风险。** 组合多个数据集(公共记录 + 泄露的内部邮件 + 机密消息源访谈)会生成一个图谱,其中实体的结构位置可能会在无意中泄露机密消息源。如果您发布了图谱的一部分——即使隐去了姓名——消息源周围独特的联系模式也可能足以让对手识别出是谁泄露了信息。在分享任何图谱可视化或导出数据之前:
- 审查结构布局是否通过独特的关系位置暴露了消息源身份
- 考虑移除或泛化可追溯到机密消息源的边
- 请记住,即使是聚合统计数据(社区成员身份、介数得分)也可以缩小候选人的范围
**这份图谱是一份情报产品。** 请像对待您的消息源名单一样,采取严格的操作安全措施来对待它。
## 配置
所有配置都位于 `investigation_graph/config.py` 中:
### 路径
```
GRAPH_DIR = Path("data/graph.lbug") # Where the graph database lives
INGEST_DIR = Path("ingest") # Where documents go for ingestion
BRIEFING_DIR = Path("briefings") # Where daily briefings are written
OBSIDIAN_VAULT = "" # Optional: Obsidian vault for briefing delivery
```
### 模型
```
EMBEDDING_MODEL = "nomic-embed-text" # Local embedding model (768 dimensions)
EMBEDDING_DIM = 768 # Must match model output dimension
LOCAL_EXTRACTION_MODEL = "llama3.2:3b" # Local LLM for entity/relationship extraction
```
默认的提取模型(`llama3.2:3b`)体积小且速度快。如果希望以牺牲速度为代价换取更好的提取质量,可以尝试 `mistral`、`llama3:8b` 或 `gemma2`。
### 提取调优
```
MIN_CONFIDENCE = 0.5 # Minimum confidence to keep an entity (0.0-1.0)
MAX_ENTITIES_PER_DOC = 200 # Safety limit per document
DEDUP_THRESHOLD = 0.92 # Cosine similarity above this = likely duplicate
```
### 分析调优
```
AUTO_ANALYSIS = False # Run analysis after every ingestion
PRUNE_AGE_DAYS = 7 # Flag unlinked entities older than this
MIN_COMMUNITY_SIZE = 5 # Minimum community size for gap analysis
MAX_CROSS_EDGES_FOR_GAP = 3 # Below this = flagged as gap
TOP_BETWEENNESS = 10 # How many high-betweenness entities to report
```
### 每日简报板块
```
BRIEFING_SECTIONS = [
"new_entities", # Entities added in last 24h
"contradictions", # CONTRADICTS edges found
"structural_gaps", # Community pairs with low cross-connection
"surprising_connections", # High betweenness on low-frequency entities
"unlinked_entities", # Entities needing attention
]
```
移除某个板块名称即可将其从简报中排除。
## 扩展本体
编辑 `ONTOLOGY.md`,为您的报道领域添加实体类型和边类型。
**经验法则:** 只有在您看到 3 个以上不适合现有类型的实例时,才添加新类型。导入后检查拒绝日志——它会告诉您您的文档需要哪些类型。
### 添加实体类型
在 `ONTOLOGY.md` 的实体类型表中添加一行:
```
| permit | A government permit, license, or approval | "Building permit #2024-087" | "Informal verbal approval" | "The permit office" → organization |
```
最后三列(archetypical、atypical、exotypical)可提高提取的准确性。exotypical 列尤为重要——它向 LLM 展示了什么*不属于*该类型。
### 添加边类型
在边类型表中添加一行:
```
| ISSUED_BY | permit → organization | Government body that issued the permit | Regulatory authority, approval chain |
```
每种边类型都应有明确的调查目的。如果您无法解释某种关系对调查为何重要,就不要添加它。
### 验证更改
```
python scripts/validate_ontology.py
```
这将检查:
- 所有类型的语法是否有效
- 更新本体后的图谱健康指标(ICR/CI/IPR)
- 哪些类型已填充数据,哪些为空
## 技术栈
全部开源。均可通过 pip 安装(Ollama 除外)。
| 工具 | 版本 | 用途 | 选择它的原因 |
|------|---------|---------|-------------|
| [DuckDB](https://duckdb.org) | 1.0+ | 数据块 + 向量嵌入 + 全文搜索(FTS)(底层基础) | 单文件列式数据库。通过 RRF 融合 BM25 全文搜索与 HNSW 向量搜索。是块文本、向量嵌入和记录集的真相来源。 |
| [LadybugDB](https://ladybugdb.com) | 0.15.3 | 图数据库(投影层) | 嵌入式图数据库,支持 Cypher 查询和带类型的边。每次导入时从 DuckDB 记录中重建。无需服务器。KuzuDB 的延续。 |
| [kg-common](https://github.com/M0nkeyFl0wer/kg-common) | 固定版本 | 共享的 KG 底层结构 | GraphWriter(边损坏防护)、Ontology 契约(基于层级/位置的评估)、实体消歧和验证机制——直接导入使用,无需重新发明。 |
| [PyArrow](https://arrow.apache.org) | 15.0+ | 批量数据加载 | `COPY FROM` Parquet 的速度远快于逐行插入;PyArrow 负责为 DuckDB 和 LadybugDB 编写 Parquet 文件。 |
| [Pandas](https://pandas.pydata.org) | 3.0+ | 数据处理 | 在导出为 Parquet 之前,使用 DataFrame 操作进行批量实体准备。 |
| [spaCy](https://spacy.io) | 3.8+ | NLP 提取(阶段 2) | 命名实体识别。使用 `en_core_web_sm` 模型——体积小、速度快,对于人员/组织/地点的识别足够好。 |
| [NetworkX](https://networkx.org) | 3.6+ | 图谱分析 | Louvain 社区发现、介数中心性、桥接检测、连通分量。在提取出的图谱上运行。 |
| [Ripser](https://ripser.scikit-tda.org) | 0.6+ | 持久同调(可选) | 寻找拓扑空洞——社区检测无法发现的更高阶结构性缺口。 |
| [Ollama](https://ollama.com) | 0.3+ | 本地 AI 模型 | 在您的硬件上运行嵌入和提取模型。无需 API 密钥。无云服务。 |
| [Obsidian](https://obsidian.md) | 任意 | 读写(可选) | 如果进行配置,每日简报将自动复制到您的 Obsidian 仓库收件箱。 |
### 为什么选择 DuckDB + LadybugDB(您无需做出选择)?
您不需要挑选数据库——它始终采用这种混合架构,因此没有什么可以配置错误的:
- **DuckDB 负责检索**:开箱即用的 BM25 全文搜索 + HNSW 向量搜索,并通过倒数秩融合(RRF)进行融合。单文件、无服务器、易于备份。
- **LadybugDB 负责结构**:一个真正的图数据库(支持 Cypher、带类型的边),用于实现调查的核心价值——路径、缺口、桥接、社区。
- **图谱是根据 DuckDB 记录重建的投影**,而不是一个增量变异的存储。这避开了 LadybugDB 的边写入损坏模式,并确保重新导入的安全性(见 `SPEC.md` §2.1)。
- **嵌入式**:两者都是 `data/` 下的文件/目录——可以将整个调查作为一个整体进行复制、备份或加密。
### 为什么不使用云端图数据库?
您的调查数据——泄露的文档、消息源身份、财务记录——不应该存放在别人的服务器上。这款工具包的设计初衷是确保在默认配置下**没有任何数据离开您的机器**。
## 架构
```
ONTOLOGY.md ← You edit this
│
▼
investigation_graph/ontology.py ← Parses types, validates at write time
│
▼
ingest/ ──► extract.py ← Three-phase extraction
│ │
│ ├─ Phase 1: Deterministic (regex)
│ ├─ Phase 2: spaCy NER
│ └─ Phase 3: LLM (Ollama)
│ │
│ ▼
│ embed.py ← Ollama nomic-embed-text
│ │
│ ▼
└────────► graph.py ← LadybugDB: entities + edges + vectors
│
▼
queries.py ← All Cypher centralized here
│
┌───────────┼───────────┐
▼ ▼ ▼
search_cli topology.py briefing.py
(keyword/ (Louvain, (daily
semantic/ gaps, markdown
hybrid/ bridges, summary)
path) homology)
```
### 数据流
1. 文档被存入 `ingest/`
2. `ingest_folder.py` 读取每个文档,运行三阶段提取
3. 实体根据 ONTOLOGY.md 进行验证——被拒绝的类型会被记录
4. 有效实体通过 Parquet 批量加载到 LadybugDB 中
5. 每个实体会获得一个由 Ollama 计算的 768 维向量嵌入,并存储在图谱中
6. 边(关系)通过参数化 Cypher 写入
7. 搜索、分析和简报都查询同一个图谱
### 查询安全
**所有 Cypher 查询都是预先构建并参数化的**,位于 `investigation_graph/queries.py` 中。代码库中没有任何地方进行动态 Cypher 生成。这意味着:
- 没有查询注入风险
- 没有 LLM 幻觉出错误的 Cypher 语法
- 系统运行的每个查询都是可审计的——阅读 `queries.py` 即可确切了解其功能
### 数据库 schema
**DuckDB (`data/chunks.duckdb`) —— 真相来源。**
```
chunk id, doc_id, source_uri, title, body, chunk_index,
entity_ids, embedding FLOAT[N], sensitivity, embedded_at (+ BM25 FTS index)
document id, path, title, ingested_at ← the full record set, so the
entity id, doc_id, entity_type, label, ... graph can be rebuilt from here
edge doc_id, source_id, target_id, edge_type, evidence, ...
```
**LadybugDB (`data/graph.lbug`) —— 重建的投影。** Schema 来自 kg-common 的 `Ontology`(因此它与写入者的验证保持一致):
```
Entity (Node) id (PK), entity_type, label, description, confidence,
source_url, provenance, extraction_source, quality_flag, ...
Document (Node) id (PK), path, title, ingested_at
RELATES_TO (Edge) edge_type, weight, confidence, evidence, provenance,
valid_at_ms, invalid_at_ms, expired_at_ms (bi-temporal trio)
MENTIONED_IN (Edge: Entity → Document)
CHUNK_OF (Edge: Chunk → Document)
```
实体不包含向量嵌入列——语义搜索是在 DuckDB 的数据块上运行的,而不是在图谱节点上(一种嵌入模型,一个位置)。边写入通过 kg-common 带有损坏防护的 `GraphWriter` 进行;图谱在每次导入时通过一次“重建并替换”的过程完成更新。
### 与 AI 助手集成(MCP / Claude Code)
如果您通过 MCP(Model Context Protocol)或类似的工具使用框架将此图谱连接到 AI 助手,请使用**渐进式披露**以避免上下文冗余:
- **不要**预先将完整的 schema、本体和查询库倾倒到系统 prompt 中。这会浪费 token 并降低推理能力。
- **务必**在初始时提供高层次的工具描述(“搜索知识图谱”,“查找实体间的路径”,“运行拓扑分析”)。
- **只有当助手决定查询图谱时**,才应让它获取所需的特定 schema(实体类型、边类型)和查询模式。
在实践中:将 `search_cli.py` 和 `run_analysis.py` 作为工具暴露出来,并提供简短描述。让助手用自然语言查询调用它们。助手不需要了解 Cypher 或完整的 ONTOLOGY.md,除非它要构建自定义查询——而 `queries.py` 的存在意味着它不需要这么做。
## 使用秘籍
### 开始新的调查
```
mkdir my-investigation && cd my-investigation
cp -r /path/to/investigation-graph/* .
bash setup.sh
# 为你的报道领域编辑 ONTOLOGY.md
mkdir ingest && cp /path/to/documents/* ingest/
python scripts/ingest_folder.py
python scripts/run_analysis.py
```
### 向现有调查中添加文档
```
# 将新文档放入 ingest/
cp new-documents/*.pdf ingest/
# 重新运行 ingestion(仅处理新文件 —— 现有 graph 被保留)
python scripts/ingest_folder.py
```
### 寻找资金流向
```
# Search 交易
python scripts/search_cli.py -q "$" --type transaction
# 查找 person 和 company 之间的路径
python scripts/search_cli.py --path "Robert Chen" "Meridian Holdings"
# 针对财务活动的 Semantic search
python scripts/search_cli.py -q "payments consulting fees" --mode semantic
```
### 检查是否存在矛盾
```
# 运行分析 —— contradictions 部分会显示冲突的声明
python scripts/run_analysis.py | grep -A5 "CONTRADICTIONS"
```
### 精简乱麻般的图谱以进行可视化
当您的图谱包含数千条边时,直接进行可视化是无法使用的——它会变成一团重叠线条组成的“乱麻”,什么都看不清。骨架提取器会按照介数中心性从高到低的顺序移除边,只保留结构骨架:
```
# 在 Python script 或 REPL 中
from investigation_graph.graph import Graph
from investigation_graph.topology import export_skeleton_json
import json
graph = Graph()
skeleton = export_skeleton_json(graph, max_edges=200)
print(f"Reduced {skeleton['original_edges']} edges to {skeleton['skeleton_edges']} "
f"({skeleton['reduction']:.0%} reduction)")
# Export 供 D3、vis-network、Gephi 等使用
with open("skeleton.json", "w") as f:
json.dump(skeleton, f, indent=2)
```
骨架保留了所有节点和具有最高介数中心性的边——这些是定义您调查轮廓的结构性桥梁。低权重、多余的边会被优先移除。
### 查询特定时间点的图谱
调查通常持续数月。证人会改变说法,早期的事实会被推翻。图谱在所有边上都存储了 `created_at` 时间戳,并保留 `expired_at` 用于软过期(在不删除的情况下将关系标记为已取代)。
```
# 10月1日的 graph 是什么样的?
# (仅包含在该日期之前创建的 edges,排除已过期的)
import time
from datetime import datetime
cutoff = int(datetime(2024, 10, 1).timestamp())
graph = Graph()
results = graph.query("""
MATCH (a:Entity)-[r:RELATES_TO]->(b:Entity)
WHERE r.created_at <= $cutoff AND r.expired_at = 0
RETURN a.label, r.edge_type, b.label, r.created_at
ORDER BY r.created_at DESC LIMIT 20
""", parameters={"cutoff": cutoff})
```
要将某个关系标记为已被取代(例如,证人翻供):
```
# Soft-expire 旧声明,添加新声明
now = int(time.time())
graph.query("""
MATCH (a:Entity {label: $claim})-[r:RELATES_TO]->(b:Entity)
SET r.expired_at = $now
""", parameters={"claim": "No payments were made", "now": now})
```
旧关系会保留在图谱中作为审计追踪记录。当前查询会根据 `expired_at = 0` 进行过滤;历史查询则会取消此过滤条件。
### 导出图谱以便分享
```
# Graph 位于 data/graph.lbug —— 复制它以分享给同事
# (敏感调查仅限 encrypted transfer)
cp -r data/graph.lbug /encrypted-usb/investigation-backup/
```
### 备份您的调查
```
# 整个调查状态位于 data/ 和 briefings/
tar czf investigation-backup-$(date +%Y%m%d).tar.gz data/ briefings/ ONTOLOGY.md
```
## 故障排除
### "Ollama: NOT RUNNING"
启动 Ollama:`ollama serve`(在后台运行)。然后拉取模型:
```
ollama pull nomic-embed-text
ollama pull llama3.2:3b
```
### "spaCy model: MISSING"
```
source .venv/bin/activate
python -m spacy download en_core_web_sm
```
### "LLM extraction failed: model not found"
配置的模型尚未在 Ollama 中拉取。检查 `investigation_graph/config.py` 中的 `LOCAL_EXTRACTION_MODEL` 并拉取它:
```
ollama pull llama3.2:3b
```
### 导入过程缓慢
大部分时间花在了 LLM 提取(阶段 3)上。可选方案:
- 使用更小的模型:在 config.py 中设置 `LOCAL_EXTRACTION_MODEL = "llama3.2:1b"`
- 在批量导入时完全跳过阶段 3,稍后再重新运行
- 如果您有 GPU,Ollama 会自动使用它
### 某种类型的实体过多
检查 `validate_ontology.py` 的输出。如果 CI(类别不平衡)高于 0.5,说明有一种类型正在包揽一切。解决方法:
1. 在 ONTOLOGY.md 中为主要类型添加更好的 exotypical 示例
2. 添加主要类型正在吸收的新类型
### PDF 导入无效
安装 `pdftotext`:
```
# Ubuntu/Debian
sudo apt install poppler-utils
# macOS
brew install poppler
```
## 文件参考
```
investigation-graph/
├── ONTOLOGY.md # Entity and edge type definitions (you edit this)
├── README.md # This file
├── LICENSE # MIT
├── requirements.txt # Python dependencies
├── setup.sh # One-command setup script
├── investigation_graph/
│ ├── __init__.py # Package init (version 0.1.0)
│ ├── config.py # All configuration (paths, models, thresholds)
│ ├── ontology.py # ONTOLOGY.md parser + write-time validator
│ ├── graph.py # LadybugDB wrapper (schema, CRUD, vector search, bulk load)
│ ├── embed.py # Ollama embedding wrapper (single + batch)
│ ├── extract.py # Three-phase extraction pipeline
│ ├── topology.py # NetworkX graph analysis
│ ├── briefing.py # Daily briefing markdown generator
│ ├── queries.py # All Cypher query patterns (centralized)
│ └── check.py # Dependency verification
├── scripts/
│ ├── ingest_folder.py # Main entry point: documents → graph
│ ├── search_cli.py # Search: keyword, semantic, hybrid, path
│ ├── run_analysis.py # Topology analysis with report output
│ ├── daily_briefing.py # Generate daily briefing markdown
│ └── validate_ontology.py # Ontology health check (ICR/CI/IPR)
├── docs/
│ └── privacy-guide.md # Detailed privacy mode comparison
├── data/ # Graph database (gitignored)
├── ingest/ # Drop documents here (gitignored)
└── briefings/ # Generated briefings (gitignored)
```
## 贡献
欢迎提交 Issues 和 PR。保持简单——这是为调查人员和研究人员提供的工具,不是为开发人员准备的框架。
## 许可证
MIT
## 联系方式
由 [Ben West](https://benwest.blog) 构建。如果您需要帮助将其部署到您的新闻编辑室、团队或调查中,请通过 [benwest.bsky.social](https://bsky.app/profile/benwest.bsky.social) 联系。
## 功能简介
您手头有一大堆文档——法庭卷宗、企业登记信息、泄露的电子邮件、公共记录。您需要找出其中的联系,更重要的是,找出*缺失*了什么。
这款工具包可以:
1. **导入**您的文档(PDF、文本、markdown、HTML)
2. **提取**人员、组织、交易以及它们之间的关系
3. 在您的本地机器上**构建**可搜索的知识图谱
4. **分析**图谱结构,寻找缺口、矛盾和令人惊讶的联系
5. 每天为您**简报** markdown 格式的图谱分析摘要
### 调查人员为什么需要这款工具
搜索回答的是*“‘Meridian’这个词在哪?”*。它无法回答*“是谁将 Chen 与 Meridian 联系起来,以及我缺少了哪份本该链接这两个群体的文档?”* 这些属于**关系和结构**问题——而**结构上的漏洞就是线索**。这款工具将成堆的文档转化为带有类型和来源溯源的图谱,让您可以看清案件的整体轮廓,找到隐藏的联系枢纽,并精准定位指向下一次调档请求的缺口。
我们刻意将其定位为**线索生成器和结构发现工具,而非结论来源**——在您发布之前,每一条联系都需要您对照原始文档进行核实。安全机制(它会*隔离*源文本不支持的声明,并合并重复的名称)之所以存在,是因为一个凭空捏造联系的自动化图谱会带来诽谤的风险。
## 快速开始
### 前置条件
- **Python 3.10 或更高版本**(检查:`python3 --version`)
- 已安装并运行 **[Ollama](https://ollama.com)**(在本地处理所有 AI 任务)
- **能基本适应命令行操作**(一切均在终端中运行)
### 安装设置
```
# Clone 仓库
git clone https://github.com/M0nkeyFl0wer/investigation-graph.git
cd investigation-graph
# 运行 setup(安装 Python packages —— 包括 kg-common substrate —— 并
# download 本地 AI models)
bash setup.sh
# 验证一切工作正常
python -m investigation_graph.check
```
您应该会看到:
```
investigation-graph system check
========================================
LadybugDB: 0.15.3
PyArrow: 23.0.1
spaCy: 3.8.14
spaCy model: en_core_web_sm OK
NetworkX: 3.6.1
Ripser: OK
DuckDB: 1.5.4
kg-common: 0.0.1
Ollama: OK (5 models)
Embedding model (nomic-embed-text, 768d): OK
Ontology: Ontology(8 entity types, 12 edge types)
All checks passed.
```
如果提示 NOT INSTALLED 或 MISSING,检查工具会准确告诉您该运行什么命令。
### 导入您的首批文档
请先尝试内置的示例调查(一个小型的虚构 Harbor City 贪腐案——本 README 的示例将带您逐步了解此案):
```
mkdir -p ingest
cp examples/sample-investigation/* ingest/
python scripts/ingest_folder.py
```
然后将其替换为您自己的文档:
```
# 将文档放入 ingest folder(PDF、text、markdown、HTML)
cp /path/to/your/documents/* ingest/
# 重新运行 ingestion(幂等 —— 干净地重新处理每个文档)
python scripts/ingest_folder.py
```
输出如下所示:
```
Scope: Ontology(8 entity types, 12 edge types)
Corpus: 3 document(s) in ingest/ → DuckDB chunks.duckdb
[1/3] harbor-city-expose.txt
2 chunks (2 embedded), 24 entities, 6 edges → DuckDB
[2/3] property-records.md
2 chunks (2 embedded), 20 entities, 4 edges → DuckDB
[3/3] financial-disclosure.html
2 chunks (2 embedded), 30 entities, 5 edges → DuckDB
Grounding 74 entities / 15 edges against 6 chunks...
========================================================
Ingestion complete in 61.4s.
Documents: 3
Chunks in DuckDB: 6
Entities (graph): 41 (merged 12 duplicates)
Edges (graph): 9
Quarantined: 21 entities (28%), 6 edges (40%) — failed the grounding gate
```
每个文档都会被分块并嵌入到 DuckDB 中,然后进行提取;**ground** 阶段会剔除源文本不支持的实体/边,并在构建图谱之前合并重复的名称。“Quarantined”(已隔离)这行正是验证机制在发挥作用——未能通过验证的提取声明绝不会进入图谱。(边数来自本地 LLM;如果 Ollama 不可用,您依然可以获得确定性提取的 + spaCy 实体以及一个支持关键词搜索的语料库。)
### 搜索图谱
```
# Keyword search —— 查找精确匹配
python scripts/search_cli.py -q "Acme Corp"
# Semantic search —— 即使没有 keyword 匹配也能找到相关内容
python scripts/search_cli.py -q "payments to contractors" --mode semantic
# Hybrid search —— 结合 keyword 和 semantic,取两者之长
python scripts/search_cli.py -q "financial fraud" --mode hybrid
# 查找两个实体之间的连接
python scripts/search_cli.py --path "Jane Smith" "Harbor Development LLC"
# 按实体类型 Filter
python scripts/search_cli.py -q "Chen" --type person
```
**路径搜索**展示了关系链:
```
Found 3 paths:
Path 1 (confidence: 0.36):
Chen --[EMPLOYED_BY]--> Brightpath Advisors --[FUNDED_BY]--> Meridian Holdings LLC
Path 2 (confidence: 0.22):
Chen --[EMPLOYED_BY]--> Brightpath Advisors --[FUNDED_BY]--> Harbor City
Redevelopment Authority --[OCCURRED_ON]--> Meridian Holdings LLC
```
每一跳都有一个置信度分数。路径置信度是所有跳数置信度的乘积——置信度越低,意味着连接的不确定性越高。
### 运行分析
```
python scripts/run_analysis.py
```
输出:
```
TOPOLOGY REPORT
============================================================
Entities: 80
Edges: 28
Connected components: 57
Largest component: 11 nodes
Communities (Louvain): 58
STRUCTURAL GAPS: 3
------------------------------------------------------------
[HIGH] Brightpath Advisors ↔ Harbor City Redevelopment Authority
7 entities ↔ 7 entities | cross-edges: 0
→ How do Brightpath Advisors and Harbor City Redevelopment
Authority relate? Your knowledge about these is not yet connected.
SURPRISING CONNECTIONS: 6
------------------------------------------------------------
Robert Chen (person)
Betweenness: 0.5111 | Degree: 4
→ Structurally important despite low frequency
```
**结构性缺口**是调查线索——即那些按理应该存在联系,但在您的数据中却没有关联的实体社区。那正是缺失文档所在的地方。
**令人惊讶的连接**是指具有高介数中心性(它们桥接着原本分离的网络)但低度数(它们很少出现在文档中)的实体。这些就是隐藏的联系枢纽。
### 每日简报
```
python scripts/daily_briefing.py
```
生成 `briefings/2026-04-04.md` ——一份 markdown 摘要,包含:
- 过去 24 小时内新增的实体
- 在不同来源之间发现的矛盾
- 结构性缺口(以调查问题的形式呈现)
- 令人惊讶的连接枢纽
- 需要关注的未连接实体
如果您配置了 Obsidian 仓库路径,简报会自动复制到那里。
### 本体健康度
```
python scripts/validate_ontology.py
```
显示您的本体与现实数据的匹配程度:
```
ICR (type coverage): 0.75 — warning (some declared types have no data)
CI (class imbalance): 0.34 — warning (dominant: organization at 27/80)
IPR (edge coverage): 0.57 — warning
Type distribution:
organization 27 ( 33.8%) ████████████████
transaction 25 ( 31.2%) ███████████████
event 16 ( 20.0%) ██████████
person 9 ( 11.2%) █████
Unpopulated types: asset, claim
```
- **ICR**(实例化类比例):您声明的类型中有多少具有实际数据。低于 0.8 意味着存在无效的 schema。
- **CI**(类别不平衡):如果某一种类型占据主导地位(>0.5),则您的提取过程可能对实体进行了错误分类。
- **IPR**(实例化属性比例):与 ICR 相同,但针对的是边类型。
## 七个阶段
### 1. 本体——什么才是重要的
编辑 `ONTOLOGY.md`,为您的报道领域或案件定义实体类型和关系类型。内置了一个通用的调查本体(新闻/OSINT/研究),涵盖 8 种实体类型和 12 种边类型。
系统在写入时会**拒绝不符合您本体的实体**——因此不会堆积垃圾数据。系统会对拒绝进行计数和报告,让您知道何时需要扩展本体。
每种类型都包含边界示例:
| 列名 | 用途 |
|--------|---------|
| Archetypical(原型) | 明确属于此类型 |
| Atypical(非典型) | 仍属于此类型的边缘情况 |
| Exotypical(异型) | 看起来相似但“不”属于此类型——指出它实际上应该属于哪种类型 |
Exotypical 示例可防止最常见的提取错误:将所有东西都倒进一个包罗万象的类型中。
### 2. 嵌入——语义理解
文档被分块(1000 个字符,重叠 200 个字符),并使用本地 AI 模型(Ollama + nomic-embed-text)转换为 768 维的向量。这些向量为语义搜索提供支持——根据含义查找文档,而不仅仅是关键词。
数据块、向量和全文(BM25)搜索都存储在 **DuckDB** 中(单个文件 `data/chunks.duckdb`)——这是混合搜索的基础。实体/边**图谱**存在于 LadybugDB 中,并从这些记录中重建。有关为什么采用这种双部分存储的原因,请参阅 [`docs/database-choice.md`](docs/database-choice.md);有关架构信息,请参阅 `SPEC.md`。如果 Ollama 不可用,导入过程依然会完成——数据块将被存储但不会被嵌入(关键词搜索仍然有效),而语义搜索将跳过它们。
### 3. 提取——三阶段实体提取
每个文档都会经历三个提取阶段:
**阶段 1 —— 确定性提取**(即时、免费、始终运行):
- 用于匹配日期、金额、电子邮件地址的正则表达式
- 从文档格式中进行结构提取
- 置信度:0.85-0.90(高——因为是基于模式匹配的)
**阶段 2 —— spaCy NER**(快速、本地、无需 GPU):
- 命名实体识别:人员、组织、地点
- 将 spaCy 标签映射到您的本体类型(PERSON → person,ORG → organization 等)
- 置信度:0.70(良好——NER 技术已经非常成熟)
**阶段 3 —— LLM**(较慢,通过 Ollama 在本地运行):
- 关系提取:谁与谁有联系,以及如何联系
- 类型细化:利用本体上下文纠正阶段 2 的错误分类
- 生成带类型的边(EMPLOYED_BY、FUNDED_BY、CONTRACTED_WITH 等)
- 置信度:0.60(较低——LLM 提取需要人工审核)
- 受本体约束:LLM prompt 包含所有类型和边界示例
每个实体都会记录其 `provenance`(由哪个阶段提取)和 `source_url`(来自哪份文档)。没有留下溯源轨迹的任何信息都无法进入图谱。
### 4. 质量控制——本体验证
每个实体在进入图谱之前,都会根据 `ONTOLOGY.md` 进行验证。如果提取流水线生成了一个标记为“weapon(武器)”的实体,但您的本体中不包含“weapon”,它将被拒绝,并且拒绝次数会被计入统计。
导入完成后,您将看到:
```
Ontology rejections (types not in ONTOLOGY.md):
weapon: 3 rejections
vehicle: 2 rejections
Tip: Consider adding frequently rejected types to ONTOLOGY.md
```
这会告诉您何时需要扩展本体——数据正在向您展示它实际包含的类型。
### 5. 搜索与路径——追踪联系
三种搜索模式:
| 模式 | 工作原理 | 最适用于 |
|------|-------------|----------|
| `keyword` | 对实体标签执行 Cypher `CONTAINS` 匹配 | 按名称查找特定实体 |
| `semantic` | 查询向量与实体向量之间的余弦相似度 | 在不知道名称的情况下查找相关实体 |
| `hybrid` | 倒数秩融合(RRF)——根据在两个列表中的位置进行排名,无需调整权重 | 最佳的通用搜索方式 |
**路径搜索**用于查找实体之间的类型化链条。不仅是“这些是相关的”,而是:
```
Jane Smith --[EMPLOYED_BY]--> Acme Corp --[CONTRACTED_WITH]--> Harbor Dev LLC
```
每条路径都有一个置信度得分(各边置信度的乘积)。两个实体之间存在多条路径通常意味着联系更紧密。
### 6. 拓扑——发现缺失的内容
图谱分析运行确定性算法——不涉及 AI,纯粹是对图谱结构进行数学计算:
| 算法 | 发现的内容 | 重要的原因 |
|-----------|--------------|----------------|
| 连通分量 | 图谱中分离的聚类 | 展示哪些调查是孤立的 |
| Louvain 社区 | 密集子群 | 自然的议题聚类 |
| 介数中心性 | 桥接不同社区的实体 | 隐藏的连接枢纽——连接原本分离网络的人员/组织 |
| 桥接检测 | 单点故障边 | 一旦移除就会断裂的脆弱连接 |
| 缺口检测 | 交叉边较少的社区对 | **线索**——您的调查中缺失的内容 |
| 持久同调 | 图谱中的拓扑空洞 | 高阶结构性缺口(需要 Ripser) |
**这些缺口就是故事的精髓。** 两个完全没有交叉边的大型社区意味着,您的文档涵盖了两个相关领域,但您尚未将它们联系起来。缺口问题能告诉您下一步该寻找什么。
### 7. 每日简报——图谱发现了什么
这是一份纯粹基于图谱结构生成的 markdown 文件。包含以下几个部分:
- **新实体**:过去 24 小时内添加的内容
- **矛盾**:来自不同来源声明之间的 `CONTRADICTS` 边
- **结构性缺口**:连接稀疏的社区对(以调查问题的形式呈现)
- **令人惊讶的连接**:低度数实体上的高介数中心性
- **未连接的实体**:存在超过 7 天但没有建立连接的实体(需要关注或移除)
可在 Obsidian、任何文本编辑器或终端中阅读。可选择自动复制到您的 Obsidian 仓库收件箱。
## 隐私
### 本地模式(默认)
一切都在您的机器上运行。没有网络连接。不需要 API 密钥。无需账户。
| 组件 | 运行位置 |
|-----------|--------------|
| 文档文本 | 留在本地磁盘 |
| 实体提取 | 在您的 CPU/GPU 上运行的 Ollama |
| 向量嵌入 | 在您的 CPU/GPU 上运行的 Ollama |
| 知识图谱 | 磁盘上的 LadybugDB 目录 |
| 向量搜索 | LadybugDB 原生功能(同一个数据库) |
| 分析 | 在您的 CPU 上运行的 Python(NetworkX) |
| 每日简报 | 写入本地磁盘 |
**何时使用:** 敏感消息源、泄露的文档,以及任何您不能冒险被传输出去的内容。
### 混合模式
向量嵌入保留在本地。对于非敏感文档,实体提取可以选择使用具有零数据保留(ZDR)策略的远程 LLM。在处理复杂文档时能提供更好的提取质量。
```
# 在 investigation_graph/config.py 中
PRIVACY_MODE = "hybrid"
REMOTE_API_BASE = "https://api.anthropic.com/v1"
REMOTE_MODEL = "claude-haiku-4-5-20251001"
# 将 NEWSROOM_API_KEY 设置为环境变量 —— 切勿 hardcode
```
**何时使用:** 混合使用公共记录(非敏感)和机密材料(敏感)。无论如何,图谱、嵌入和分析始终保留在本地。
### 远程模式
一切通过远程 API 完成。不建议用于敏感材料。
**何时使用:** 对纯公共数据集进行批量处理,且速度和质量比保密性更重要时。
有关详细的比较和提供商建议,请参阅 `docs/privacy-guide.md`。
### 伦理:身份模糊与消息源保护
自动化提取给任何发布调查结果的人(无论是记者、OSINT 调查员还是研究人员)带来的两个风险:
**身份模糊。** 流水线会将“John Smith”、“J. Smith”和“John S. Smith”提取为三个独立的实体它也可能将“BP US”和“British Petroleum”拆分为不同的组织。在发布任何基于图谱联系的发现之前,**请手动核实有联系的实体是否确实是同一个人或组织。** 错误归因自动化图谱中的联系可能会错误地指控个人。`config.py` 中的去重阈值(`DEDUP_THRESHOLD = 0.92`)可以通过向量相似性捕捉到一些重复项,但对于名称相似但指向不同人员的名字,这并不足够。
**三角测量风险。** 组合多个数据集(公共记录 + 泄露的内部邮件 + 机密消息源访谈)会生成一个图谱,其中实体的结构位置可能会在无意中泄露机密消息源。如果您发布了图谱的一部分——即使隐去了姓名——消息源周围独特的联系模式也可能足以让对手识别出是谁泄露了信息。在分享任何图谱可视化或导出数据之前:
- 审查结构布局是否通过独特的关系位置暴露了消息源身份
- 考虑移除或泛化可追溯到机密消息源的边
- 请记住,即使是聚合统计数据(社区成员身份、介数得分)也可以缩小候选人的范围
**这份图谱是一份情报产品。** 请像对待您的消息源名单一样,采取严格的操作安全措施来对待它。
## 配置
所有配置都位于 `investigation_graph/config.py` 中:
### 路径
```
GRAPH_DIR = Path("data/graph.lbug") # Where the graph database lives
INGEST_DIR = Path("ingest") # Where documents go for ingestion
BRIEFING_DIR = Path("briefings") # Where daily briefings are written
OBSIDIAN_VAULT = "" # Optional: Obsidian vault for briefing delivery
```
### 模型
```
EMBEDDING_MODEL = "nomic-embed-text" # Local embedding model (768 dimensions)
EMBEDDING_DIM = 768 # Must match model output dimension
LOCAL_EXTRACTION_MODEL = "llama3.2:3b" # Local LLM for entity/relationship extraction
```
默认的提取模型(`llama3.2:3b`)体积小且速度快。如果希望以牺牲速度为代价换取更好的提取质量,可以尝试 `mistral`、`llama3:8b` 或 `gemma2`。
### 提取调优
```
MIN_CONFIDENCE = 0.5 # Minimum confidence to keep an entity (0.0-1.0)
MAX_ENTITIES_PER_DOC = 200 # Safety limit per document
DEDUP_THRESHOLD = 0.92 # Cosine similarity above this = likely duplicate
```
### 分析调优
```
AUTO_ANALYSIS = False # Run analysis after every ingestion
PRUNE_AGE_DAYS = 7 # Flag unlinked entities older than this
MIN_COMMUNITY_SIZE = 5 # Minimum community size for gap analysis
MAX_CROSS_EDGES_FOR_GAP = 3 # Below this = flagged as gap
TOP_BETWEENNESS = 10 # How many high-betweenness entities to report
```
### 每日简报板块
```
BRIEFING_SECTIONS = [
"new_entities", # Entities added in last 24h
"contradictions", # CONTRADICTS edges found
"structural_gaps", # Community pairs with low cross-connection
"surprising_connections", # High betweenness on low-frequency entities
"unlinked_entities", # Entities needing attention
]
```
移除某个板块名称即可将其从简报中排除。
## 扩展本体
编辑 `ONTOLOGY.md`,为您的报道领域添加实体类型和边类型。
**经验法则:** 只有在您看到 3 个以上不适合现有类型的实例时,才添加新类型。导入后检查拒绝日志——它会告诉您您的文档需要哪些类型。
### 添加实体类型
在 `ONTOLOGY.md` 的实体类型表中添加一行:
```
| permit | A government permit, license, or approval | "Building permit #2024-087" | "Informal verbal approval" | "The permit office" → organization |
```
最后三列(archetypical、atypical、exotypical)可提高提取的准确性。exotypical 列尤为重要——它向 LLM 展示了什么*不属于*该类型。
### 添加边类型
在边类型表中添加一行:
```
| ISSUED_BY | permit → organization | Government body that issued the permit | Regulatory authority, approval chain |
```
每种边类型都应有明确的调查目的。如果您无法解释某种关系对调查为何重要,就不要添加它。
### 验证更改
```
python scripts/validate_ontology.py
```
这将检查:
- 所有类型的语法是否有效
- 更新本体后的图谱健康指标(ICR/CI/IPR)
- 哪些类型已填充数据,哪些为空
## 技术栈
全部开源。均可通过 pip 安装(Ollama 除外)。
| 工具 | 版本 | 用途 | 选择它的原因 |
|------|---------|---------|-------------|
| [DuckDB](https://duckdb.org) | 1.0+ | 数据块 + 向量嵌入 + 全文搜索(FTS)(底层基础) | 单文件列式数据库。通过 RRF 融合 BM25 全文搜索与 HNSW 向量搜索。是块文本、向量嵌入和记录集的真相来源。 |
| [LadybugDB](https://ladybugdb.com) | 0.15.3 | 图数据库(投影层) | 嵌入式图数据库,支持 Cypher 查询和带类型的边。每次导入时从 DuckDB 记录中重建。无需服务器。KuzuDB 的延续。 |
| [kg-common](https://github.com/M0nkeyFl0wer/kg-common) | 固定版本 | 共享的 KG 底层结构 | GraphWriter(边损坏防护)、Ontology 契约(基于层级/位置的评估)、实体消歧和验证机制——直接导入使用,无需重新发明。 |
| [PyArrow](https://arrow.apache.org) | 15.0+ | 批量数据加载 | `COPY FROM` Parquet 的速度远快于逐行插入;PyArrow 负责为 DuckDB 和 LadybugDB 编写 Parquet 文件。 |
| [Pandas](https://pandas.pydata.org) | 3.0+ | 数据处理 | 在导出为 Parquet 之前,使用 DataFrame 操作进行批量实体准备。 |
| [spaCy](https://spacy.io) | 3.8+ | NLP 提取(阶段 2) | 命名实体识别。使用 `en_core_web_sm` 模型——体积小、速度快,对于人员/组织/地点的识别足够好。 |
| [NetworkX](https://networkx.org) | 3.6+ | 图谱分析 | Louvain 社区发现、介数中心性、桥接检测、连通分量。在提取出的图谱上运行。 |
| [Ripser](https://ripser.scikit-tda.org) | 0.6+ | 持久同调(可选) | 寻找拓扑空洞——社区检测无法发现的更高阶结构性缺口。 |
| [Ollama](https://ollama.com) | 0.3+ | 本地 AI 模型 | 在您的硬件上运行嵌入和提取模型。无需 API 密钥。无云服务。 |
| [Obsidian](https://obsidian.md) | 任意 | 读写(可选) | 如果进行配置,每日简报将自动复制到您的 Obsidian 仓库收件箱。 |
### 为什么选择 DuckDB + LadybugDB(您无需做出选择)?
您不需要挑选数据库——它始终采用这种混合架构,因此没有什么可以配置错误的:
- **DuckDB 负责检索**:开箱即用的 BM25 全文搜索 + HNSW 向量搜索,并通过倒数秩融合(RRF)进行融合。单文件、无服务器、易于备份。
- **LadybugDB 负责结构**:一个真正的图数据库(支持 Cypher、带类型的边),用于实现调查的核心价值——路径、缺口、桥接、社区。
- **图谱是根据 DuckDB 记录重建的投影**,而不是一个增量变异的存储。这避开了 LadybugDB 的边写入损坏模式,并确保重新导入的安全性(见 `SPEC.md` §2.1)。
- **嵌入式**:两者都是 `data/` 下的文件/目录——可以将整个调查作为一个整体进行复制、备份或加密。
### 为什么不使用云端图数据库?
您的调查数据——泄露的文档、消息源身份、财务记录——不应该存放在别人的服务器上。这款工具包的设计初衷是确保在默认配置下**没有任何数据离开您的机器**。
## 架构
```
ONTOLOGY.md ← You edit this
│
▼
investigation_graph/ontology.py ← Parses types, validates at write time
│
▼
ingest/ ──► extract.py ← Three-phase extraction
│ │
│ ├─ Phase 1: Deterministic (regex)
│ ├─ Phase 2: spaCy NER
│ └─ Phase 3: LLM (Ollama)
│ │
│ ▼
│ embed.py ← Ollama nomic-embed-text
│ │
│ ▼
└────────► graph.py ← LadybugDB: entities + edges + vectors
│
▼
queries.py ← All Cypher centralized here
│
┌───────────┼───────────┐
▼ ▼ ▼
search_cli topology.py briefing.py
(keyword/ (Louvain, (daily
semantic/ gaps, markdown
hybrid/ bridges, summary)
path) homology)
```
### 数据流
1. 文档被存入 `ingest/`
2. `ingest_folder.py` 读取每个文档,运行三阶段提取
3. 实体根据 ONTOLOGY.md 进行验证——被拒绝的类型会被记录
4. 有效实体通过 Parquet 批量加载到 LadybugDB 中
5. 每个实体会获得一个由 Ollama 计算的 768 维向量嵌入,并存储在图谱中
6. 边(关系)通过参数化 Cypher 写入
7. 搜索、分析和简报都查询同一个图谱
### 查询安全
**所有 Cypher 查询都是预先构建并参数化的**,位于 `investigation_graph/queries.py` 中。代码库中没有任何地方进行动态 Cypher 生成。这意味着:
- 没有查询注入风险
- 没有 LLM 幻觉出错误的 Cypher 语法
- 系统运行的每个查询都是可审计的——阅读 `queries.py` 即可确切了解其功能
### 数据库 schema
**DuckDB (`data/chunks.duckdb`) —— 真相来源。**
```
chunk id, doc_id, source_uri, title, body, chunk_index,
entity_ids, embedding FLOAT[N], sensitivity, embedded_at (+ BM25 FTS index)
document id, path, title, ingested_at ← the full record set, so the
entity id, doc_id, entity_type, label, ... graph can be rebuilt from here
edge doc_id, source_id, target_id, edge_type, evidence, ...
```
**LadybugDB (`data/graph.lbug`) —— 重建的投影。** Schema 来自 kg-common 的 `Ontology`(因此它与写入者的验证保持一致):
```
Entity (Node) id (PK), entity_type, label, description, confidence,
source_url, provenance, extraction_source, quality_flag, ...
Document (Node) id (PK), path, title, ingested_at
RELATES_TO (Edge) edge_type, weight, confidence, evidence, provenance,
valid_at_ms, invalid_at_ms, expired_at_ms (bi-temporal trio)
MENTIONED_IN (Edge: Entity → Document)
CHUNK_OF (Edge: Chunk → Document)
```
实体不包含向量嵌入列——语义搜索是在 DuckDB 的数据块上运行的,而不是在图谱节点上(一种嵌入模型,一个位置)。边写入通过 kg-common 带有损坏防护的 `GraphWriter` 进行;图谱在每次导入时通过一次“重建并替换”的过程完成更新。
### 与 AI 助手集成(MCP / Claude Code)
如果您通过 MCP(Model Context Protocol)或类似的工具使用框架将此图谱连接到 AI 助手,请使用**渐进式披露**以避免上下文冗余:
- **不要**预先将完整的 schema、本体和查询库倾倒到系统 prompt 中。这会浪费 token 并降低推理能力。
- **务必**在初始时提供高层次的工具描述(“搜索知识图谱”,“查找实体间的路径”,“运行拓扑分析”)。
- **只有当助手决定查询图谱时**,才应让它获取所需的特定 schema(实体类型、边类型)和查询模式。
在实践中:将 `search_cli.py` 和 `run_analysis.py` 作为工具暴露出来,并提供简短描述。让助手用自然语言查询调用它们。助手不需要了解 Cypher 或完整的 ONTOLOGY.md,除非它要构建自定义查询——而 `queries.py` 的存在意味着它不需要这么做。
## 使用秘籍
### 开始新的调查
```
mkdir my-investigation && cd my-investigation
cp -r /path/to/investigation-graph/* .
bash setup.sh
# 为你的报道领域编辑 ONTOLOGY.md
mkdir ingest && cp /path/to/documents/* ingest/
python scripts/ingest_folder.py
python scripts/run_analysis.py
```
### 向现有调查中添加文档
```
# 将新文档放入 ingest/
cp new-documents/*.pdf ingest/
# 重新运行 ingestion(仅处理新文件 —— 现有 graph 被保留)
python scripts/ingest_folder.py
```
### 寻找资金流向
```
# Search 交易
python scripts/search_cli.py -q "$" --type transaction
# 查找 person 和 company 之间的路径
python scripts/search_cli.py --path "Robert Chen" "Meridian Holdings"
# 针对财务活动的 Semantic search
python scripts/search_cli.py -q "payments consulting fees" --mode semantic
```
### 检查是否存在矛盾
```
# 运行分析 —— contradictions 部分会显示冲突的声明
python scripts/run_analysis.py | grep -A5 "CONTRADICTIONS"
```
### 精简乱麻般的图谱以进行可视化
当您的图谱包含数千条边时,直接进行可视化是无法使用的——它会变成一团重叠线条组成的“乱麻”,什么都看不清。骨架提取器会按照介数中心性从高到低的顺序移除边,只保留结构骨架:
```
# 在 Python script 或 REPL 中
from investigation_graph.graph import Graph
from investigation_graph.topology import export_skeleton_json
import json
graph = Graph()
skeleton = export_skeleton_json(graph, max_edges=200)
print(f"Reduced {skeleton['original_edges']} edges to {skeleton['skeleton_edges']} "
f"({skeleton['reduction']:.0%} reduction)")
# Export 供 D3、vis-network、Gephi 等使用
with open("skeleton.json", "w") as f:
json.dump(skeleton, f, indent=2)
```
骨架保留了所有节点和具有最高介数中心性的边——这些是定义您调查轮廓的结构性桥梁。低权重、多余的边会被优先移除。
### 查询特定时间点的图谱
调查通常持续数月。证人会改变说法,早期的事实会被推翻。图谱在所有边上都存储了 `created_at` 时间戳,并保留 `expired_at` 用于软过期(在不删除的情况下将关系标记为已取代)。
```
# 10月1日的 graph 是什么样的?
# (仅包含在该日期之前创建的 edges,排除已过期的)
import time
from datetime import datetime
cutoff = int(datetime(2024, 10, 1).timestamp())
graph = Graph()
results = graph.query("""
MATCH (a:Entity)-[r:RELATES_TO]->(b:Entity)
WHERE r.created_at <= $cutoff AND r.expired_at = 0
RETURN a.label, r.edge_type, b.label, r.created_at
ORDER BY r.created_at DESC LIMIT 20
""", parameters={"cutoff": cutoff})
```
要将某个关系标记为已被取代(例如,证人翻供):
```
# Soft-expire 旧声明,添加新声明
now = int(time.time())
graph.query("""
MATCH (a:Entity {label: $claim})-[r:RELATES_TO]->(b:Entity)
SET r.expired_at = $now
""", parameters={"claim": "No payments were made", "now": now})
```
旧关系会保留在图谱中作为审计追踪记录。当前查询会根据 `expired_at = 0` 进行过滤;历史查询则会取消此过滤条件。
### 导出图谱以便分享
```
# Graph 位于 data/graph.lbug —— 复制它以分享给同事
# (敏感调查仅限 encrypted transfer)
cp -r data/graph.lbug /encrypted-usb/investigation-backup/
```
### 备份您的调查
```
# 整个调查状态位于 data/ 和 briefings/
tar czf investigation-backup-$(date +%Y%m%d).tar.gz data/ briefings/ ONTOLOGY.md
```
## 故障排除
### "Ollama: NOT RUNNING"
启动 Ollama:`ollama serve`(在后台运行)。然后拉取模型:
```
ollama pull nomic-embed-text
ollama pull llama3.2:3b
```
### "spaCy model: MISSING"
```
source .venv/bin/activate
python -m spacy download en_core_web_sm
```
### "LLM extraction failed: model not found"
配置的模型尚未在 Ollama 中拉取。检查 `investigation_graph/config.py` 中的 `LOCAL_EXTRACTION_MODEL` 并拉取它:
```
ollama pull llama3.2:3b
```
### 导入过程缓慢
大部分时间花在了 LLM 提取(阶段 3)上。可选方案:
- 使用更小的模型:在 config.py 中设置 `LOCAL_EXTRACTION_MODEL = "llama3.2:1b"`
- 在批量导入时完全跳过阶段 3,稍后再重新运行
- 如果您有 GPU,Ollama 会自动使用它
### 某种类型的实体过多
检查 `validate_ontology.py` 的输出。如果 CI(类别不平衡)高于 0.5,说明有一种类型正在包揽一切。解决方法:
1. 在 ONTOLOGY.md 中为主要类型添加更好的 exotypical 示例
2. 添加主要类型正在吸收的新类型
### PDF 导入无效
安装 `pdftotext`:
```
# Ubuntu/Debian
sudo apt install poppler-utils
# macOS
brew install poppler
```
## 文件参考
```
investigation-graph/
├── ONTOLOGY.md # Entity and edge type definitions (you edit this)
├── README.md # This file
├── LICENSE # MIT
├── requirements.txt # Python dependencies
├── setup.sh # One-command setup script
├── investigation_graph/
│ ├── __init__.py # Package init (version 0.1.0)
│ ├── config.py # All configuration (paths, models, thresholds)
│ ├── ontology.py # ONTOLOGY.md parser + write-time validator
│ ├── graph.py # LadybugDB wrapper (schema, CRUD, vector search, bulk load)
│ ├── embed.py # Ollama embedding wrapper (single + batch)
│ ├── extract.py # Three-phase extraction pipeline
│ ├── topology.py # NetworkX graph analysis
│ ├── briefing.py # Daily briefing markdown generator
│ ├── queries.py # All Cypher query patterns (centralized)
│ └── check.py # Dependency verification
├── scripts/
│ ├── ingest_folder.py # Main entry point: documents → graph
│ ├── search_cli.py # Search: keyword, semantic, hybrid, path
│ ├── run_analysis.py # Topology analysis with report output
│ ├── daily_briefing.py # Generate daily briefing markdown
│ └── validate_ontology.py # Ontology health check (ICR/CI/IPR)
├── docs/
│ └── privacy-guide.md # Detailed privacy mode comparison
├── data/ # Graph database (gitignored)
├── ingest/ # Drop documents here (gitignored)
└── briefings/ # Generated briefings (gitignored)
```
## 贡献
欢迎提交 Issues 和 PR。保持简单——这是为调查人员和研究人员提供的工具,不是为开发人员准备的框架。
## 许可证
MIT
## 联系方式
由 [Ben West](https://benwest.blog) 构建。如果您需要帮助将其部署到您的新闻编辑室、团队或调查中,请通过 [benwest.bsky.social](https://bsky.app/profile/benwest.bsky.social) 联系。标签:AI风险缓解, ESC4, OSINT, 代码示例, 数据分析, 本地大模型, 特权检测, 网络安全, 逆向工具, 隐私保护