You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Battle Plan: Medical Knowledge Graph Ingestion Optimization
This document outlines the strategic roadmap for integrating Large Language Models (LLMs) into the ingestion pipeline, specifically targeting Stage 4 (Claims Extraction) and Stage 5 (Evidence Aggregation).
1. LLM Infrastructure Strategy
The choice of model is critical for handling the specialized technical language and structured data extraction requirements of medical research papers.
Component
Model Recommendation
Rationale
Cloud Inference
Llama 3.1:8b
The "sweet spot" for specialized extraction. High accuracy for biomedical NER and reasoning (~90%+) when prompted for JSON output.
Local Inference
Llama 3.2:1b
Suitable only for "pre-pass" tasks like identifying entity-rich paragraphs. Lacks the reasoning depth for complex statistical grounding.
Optimization Techniques
Schema Enforcement: Utilize Ollama's format: json parameter to ensure the LLM strictly follows the EvidenceItem and ClaimEdge Pydantic models.
fromollamaimportchatfrompydanticimportBaseModelclassCountry(BaseModel):
name: strcapital: strlanguages: list[str]
response=chat(
model='gpt-oss',
messages=[{'role': 'user', 'content': 'Tell me about Canada.'}],
format=Country.model_json_schema(),
)
country=Country.model_validate_json(response.message.content)
print(country)
Cloud Acceleration: Use Lambda Labs A10 GPU ($0.75/hr) for inference. This provides a 10-30x speedup over local CPU processing, making full-text parsing viable.
2. Stage 4: Claims Extraction (Event vs. Meaning)
Moving beyond regex-based pattern matching to an LLM-based "Event Identification" approach.
The Philosophy: Edges are Events
Predicates (e.g., TREATS) represent the meaning.
Edges represent the act of assertion by a specific paper.
Task: The LLM must distinguish between the Author's claim and a citation of previous work.
3. Stage 5: Evidence Aggregation (Grounding)
Solving the "Zero Evidence Found" bottleneck by transitioning from abstracts to full-text Results sections.
Targeted Grounding Prompt
Provide the LLM with the Canonical Triple from Stage 4 and the Full Text from Stage 2 to extract empirical support.
Required Output Fields:
sample_size (int): The "N" of the study.
text_span (str): The exact statistical quote (e.g., "p < 0.001").
polarity (enum): Support, Counter-argue, or Neutral.
confidence (float): High for RCTs/Meta-analyses; lower for case reports.
4. Canonical ID Accessibility (JIT Context Injection)
To ensure the LLM uses established IDs from your PostgreSQL database without memorizing the entire graph.
The "Entity Glossary" Pattern
Before calling the LLM, perform a "Local RAG" pass:
Query PostgreSQL: Fetch all canonical_id and name entries already identified in Stage 1 (NER) for the specific paper_id.
Inject Glossary: Insert these as a "Background Glossary" into the LLM system prompt.
Constraint: Instruct the model to ONLY use these IDs when constructing JSON triples.
Maintain strict separation between the three graph layers to prevent "schema rot".
Extraction Graph: Noisy, raw model output.
Claim Graph: Author-level assertions.
Evidence Graph: Empirical measurements and p-values.
Test-Driven Epistemology
Implement Expressibility Tests in pytest to ensure these boundaries are never crossed (e.g., a clinical query should never touch raw extraction artifacts).
Next Action Items
Implement get_paper_glossary() helper in mapper.py.
Transition Stage 5 logic to use the OllamaNerExtractor architecture for evidence grounding.
Update pipeline.sh to include a Stage 8: Evidence Quality weighting based on study type.
Additional Thoughts
Operational Resilience
Checkpoint Strategy: For long-running ingestion jobs, add intermediate checkpointing at the paper level. If Stage 5 fails on paper 847 of 1000, you don't want to reprocess the first 846. Store processing state in a pipeline_state table with columns like paper_id, stage, status, last_attempt, error_log.
LLM Fallback Chain: Since you're already juggling multiple LLM providers at LexisNexis, consider a degradation strategy:
Primary: Llama 3.1:8b on Lambda Labs
Fallback: Claude Haiku via API (for higher-stakes extractions where quality > cost)
Emergency: Llama 3.2:1b locally (when cloud is unavailable)
Quality Assurance
Validation Layer: Between Stage 5 output and database commit, add a validate_evidence() function that checks:
Does text_span actually appear in the source text?
Is sample_size reasonable? (Flag N=1 or N>1,000,000)
Does the canonical triple use IDs from the glossary?
This catches hallucinations early, before they pollute your graph.
The Glossary Mismatch Problem
Your "Entity Glossary" pattern is smart, but what happens when the LLM encounters a novel entity not in the glossary? Two approaches:
Strict Mode: Reject the extraction, log it for manual review
Hybrid Mode: Allow new IDs with a needs_canonicalization: true flag, creating a Stage 6.5 reconciliation step
I'd lean toward option 2 - you don't want to lose legitimate discoveries just because Stage 1 missed something.
Cost Reality Check
At $0.75/hr for Lambda Labs and ~20 seconds per paper for full-text processing, you're looking at:
180 papers/hour
~$4.17 per 1,000 papers
For your corpus size, budget accordingly. Also consider spot instances or switching to modal.com which can be cheaper for bursty workloads.
Thought process: Celebrated successful GraphQL implementation and data retrieval.
The user is showing me a screenshot of GraphiQL working successfully! The query is returning proper structured data with entities (cancer, mac, breast, caf) showing their entityId, name, entityType, synonyms, and source fields. The relationships query is also visible.
This is great! Everything is working now:
1. The context injection is working (storage coming from info.context)
2. The proper Strawberry types are working
3. The GraphQL introspection is showing the field names correctly
4. The data is being returned properly from PostgreSQL
The user is happy - this is a success! I should acknowledge their enthusiasm and maybe point out what we accomplished.
π Excellent! Everything's working now!
Look at that beautiful structured data flowing through:
β Entities - Proper types with subfield selection
β Relationships - Clean GraphQL schema
β Context injection - Storage coming from info.context
β No more generator errors
β PostgreSQL data - Your ingested paper is queryable
You can see your 10 diseases extracted from that breast cancer paper (cancer, mac, breast, caf...) with proper canonical IDs like DISEASE:cancer.
What We Fixed Today
Documentation restructure - Three-book model (User/Developer/Research guides)
Docker permissions - Named volumes with proper user ownership
Thought process: Identified entity resolution gap between extracted and linked entities.
Yes, this is the entity resolution problem that's mentioned in the documentation. The relationships are being created with placeholder IDs instead of being linked to the actual canonical entities that were extracted in the NER stage.
Looking at the ingestion output from earlier:
`β`β`
Entities found: 17
- New canonical entities: 10
- Matched to existing: 7
ExtractionEdges: 52
`β`β`
So we have 10 canonical entities with proper IDs like `DISEASE:cancer`, `DISEASE:breast`, etc.
But then in the claims extraction:
`β`β`
Note: Relationships created with placeholder entity IDs.
Entity resolution is needed to replace placeholder IDs with canonical entity IDs.
Run entity resolution ingest to complete the relationships.
`β`β`
This is a known limitation mentioned in `docs/research-notes/next-steps.md` as a high-priority task. The system needs to:
1. Extract the entity mentions from the relationship text
2. Match them to the canonical entities (by name, synonyms, or embedding similarity)
3. Replace the placeholder IDs with the actual canonical entity IDs
This is actually one of the harder problems in knowledge graph construction - entity linking/resolution. The user needs to implement this missing piece.
I should acknowledge this is the expected current state, explain why it's happening, and give them options for how to fix it.
You're absolutely right - this is the entity resolution problem mentioned in your docs. Let me explain what's happening and how to fix it.
Why This Is Happening
Stage 2 (NER): Extracted 10 canonical entities with real IDs:
DISEASE:cancer
DISEASE:breast
DISEASE:mac
etc.
Stage 4 (Claims): Extracted 5 relationships but couldn't link them to those entities, so it created placeholders:
PLACEHOLDER_SUBJECT_PMC12778424_claim_0
PLACEHOLDER_OBJECT_PMC12778424_claim_0
The pipeline doesn't yet have the logic to say "this relationship mentions 'breast cancer' - that's entity DISEASE:breast".
How to Fix It
You need to implement entity resolution - matching relationship text to canonical entities. Here are your options:
Create a simple script to link placeholders to entities:
# ingest/resolve_entities.py"""Entity resolution: Link placeholder relationship IDs to canonical entities."""frommed_lit_schema.storage.backends.postgresimportPostgresPipelineStoragefromsqlmodelimportSession, selectfrommed_lit_schema.storage.models.entityimportEntityfrommed_lit_schema.storage.models.relationshipimportRelationshipimportjsondefresolve_relationships(database_url: str):
"""Resolve placeholder IDs to canonical entities."""from .storage_factoryimportget_engineengine=get_engine()
withSession(engine) assession:
# Get all relationships with placeholdersrelationships=session.exec(
select(Relationship).where(
Relationship.subject_id.like('PLACEHOLDER_%')
)
).all()
print(f"Found {len(relationships)} relationships to resolve")
# Get all entities for matchingentities=session.exec(select(Entity)).all()
entity_by_name= {e.name.lower(): e.idforeinentities}
forrelinrelationships:
# Extract paper_id from placeholder# PLACEHOLDER_SUBJECT_PMC12778424_claim_0 -> PMC12778424paper_id=rel.subject_id.split('_')[2]
# Get the relationship text from provenance (stored in metadata)# This is where you'd look up what entities are mentioned# For now, simple name matching from source_papers# You'd need to enhance this with the actual relationship text# TODO: Implement actual entity linking logic here# Options:# 1. Parse relationship text for entity mentions# 2. Match against entity names/synonyms# 3. Use embedding similaritypassif__name__=="__main__":
importsysresolve_relationships(sys.argv[1])
Option 2: Use LLM for Entity Linking (Recommended)
This is where your Qwen2.5/Llama battle plan comes in! Use the LLM to extract entity mentions from relationship text:
# ingest/llm_entity_resolver.py"""LLM-based entity resolution."""fromollamaimportchatdefresolve_entities_with_llm(relationship_text: str, entity_glossary: dict):
""" Use LLM to identify which entities are mentioned in relationship text. Args: relationship_text: "Tamoxifen treats breast cancer" entity_glossary: {"DISEASE:breast": ["breast cancer", "breast carcinoma"], ...} """prompt=f"""Given this medical relationship:"{relationship_text}"And these available entities:{json.dumps(entity_glossary, indent=2)}Identify which entities are the subject and object.Return ONLY JSON:{{ "subject_id": "DISEASE:drug_name", "object_id": "DISEASE:disease_name"}}"""response=chat(
model='qwen2.5:7b',
messages=[{'role': 'user', 'content': prompt}],
format={'type': 'json_object'}
)
returnjson.loads(response.message.content)
Option 3: The "Entity Glossary" Pattern (From Your Battle Plan)
This is what you outlined in your gist - inject entity glossary into Stage 4:
# In claims_pipeline.pydefget_paper_glossary(paper_id: str, storage) ->dict:
"""Get all entities already identified for this paper."""entities=storage.entities.get_by_paper(paper_id)
glossary= {}
forentityinentities:
glossary[entity.entity_id] = {
"name": entity.name,
"synonyms": entity.synonymsor [],
"type": entity.entity_type
}
returnglossary# Then in claims extraction:defextract_claims_with_resolution(paper_id: str, abstract: str, storage):
glossary=get_paper_glossary(paper_id, storage)
# Use LLM to extract relationships AND resolve entitiesprompt=f"""Available entities (use these IDs):{json.dumps(glossary, indent=2)}Extract relationships from:{abstract}Return JSON with actual entity IDs from the glossary."""# ... LLM call ...
Immediate Action
For your current data, here's a quick SQL fix to see what it would look like with real IDs:
-- Connect to database
docker compose exec postgres psql -U postgres medlit
-- See what entities we haveSELECT id, name, entity_type FROM entities LIMIT10;
-- Manually update one relationship to testUPDATE relationships
SET
subject_id ='DISEASE:tamoxifen', -- Replace with actual entity ID
object_id ='DISEASE:breast'-- Replace with actual entity IDWHERE id =1;
-- Check the resultSELECT subject_id, predicate, object_id FROM relationships;
Which Path Forward?
Given your battle plan focus on LLM integration, I'd recommend:
This week: Implement the entity glossary pattern (Option 3)
Next week: Add LLM-based entity linking (Option 2) to Stage 4
Month 2: Add embedding similarity fallback for entities not caught by exact match
Want me to help you implement the entity glossary injection into claims_pipeline.py? That's the cleanest path forward and aligns with your Stage 5 LLM integration plan.
Yes β thatβs a very common and very sensible pattern π
A release workflow can absolutely build documentation (e.g., Sphinx) and publish it automatically.
There are three main ways people do this, depending on where you want the docs to live.
Option A (most common): Build Sphinx docs and publish to GitHub Pages